Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/web/app/components/ScrollToTop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ScrollToTop } from './ScrollToTop';

function mockMatchMedia(prefersReducedMotion: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)' && prefersReducedMotion,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}

function scrollTo(y: number) {
Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true });
window.dispatchEvent(new Event('scroll'));
}

describe('ScrollToTop', () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
mockMatchMedia(false);
window.scrollTo = vi.fn();
window.requestAnimationFrame = (cb: FrameRequestCallback) => {
cb(0);
return 0;
};
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true });
});

afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.restoreAllMocks();
});

function getButton() {
return container.querySelector('button.scroll-to-top') as HTMLButtonElement;
}

it('is hidden below the SHOW_AFTER_PX threshold', () => {
act(() => {
root.render(<ScrollToTop />);
});
act(() => {
scrollTo(399);
});
expect(getButton().className).not.toContain('is-visible');
expect(getButton().getAttribute('aria-hidden')).toBe('true');
});

it('becomes visible above the SHOW_AFTER_PX threshold', () => {
act(() => {
root.render(<ScrollToTop />);
});
act(() => {
scrollTo(401);
});
expect(getButton().className).toContain('is-visible');
expect(getButton().getAttribute('aria-hidden')).toBe('false');
});

it('hides again when scrolling back under the threshold', () => {
act(() => {
root.render(<ScrollToTop />);
});
act(() => {
scrollTo(500);
});
expect(getButton().className).toContain('is-visible');
act(() => {
scrollTo(100);
});
expect(getButton().className).not.toContain('is-visible');
});

it('calls window.scrollTo with smooth behavior on click by default', () => {
act(() => {
root.render(<ScrollToTop />);
});
act(() => {
getButton().click();
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});

it('calls window.scrollTo with auto behavior when reduced motion is preferred', () => {
mockMatchMedia(true);
act(() => {
root.render(<ScrollToTop />);
});
act(() => {
getButton().click();
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' });
});
});
71 changes: 71 additions & 0 deletions apps/web/app/components/ScrollToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';

const SHOW_AFTER_PX = 400;

export function ScrollToTop() {
Comment thread
StanislavBG marked this conversation as resolved.
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
// Assumes document/window-level scrolling (confirmed: the app's inner-scrolling
// regions — the mobile nav drawer, the search suggestions popover — are both
// self-contained overlays, not the main content area). If a page ever scrolls via
// an inner container instead, this threshold check needs to target that container.
const toggleVisibility = () => {
Comment thread
StanislavBG marked this conversation as resolved.
if (window.scrollY > SHOW_AFTER_PX) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};

let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(() => {
toggleVisibility();
ticking = false;
});
};

window.addEventListener('scroll', onScroll, { passive: true });
// Initial check in case the page is loaded already scrolled down
toggleVisibility();

return () => window.removeEventListener('scroll', onScroll);
}, []);

const scrollToTop = () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

window.scrollTo({
top: 0,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
};

return (
<button
type="button"
className={`scroll-to-top ${isVisible ? 'is-visible' : ''}`}
onClick={scrollToTop}
aria-label="Към началото"
aria-hidden={!isVisible}
tabIndex={isVisible ? 0 : -1}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
);
}
2 changes: 2 additions & 0 deletions apps/web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useNonce } from './nonce';
import { SiteHeader } from './components/SiteHeader';
import { SiteFooter } from './components/SiteFooter';
import { AccessibilityWidget } from './components/AccessibilityWidget';
import { ScrollToTop } from './components/ScrollToTop';
import { PageHeader } from './components/PageHeader';
import { getCoverageMeta } from './lib/coverage';
import { withDbRetry } from './lib/retry';
Expand Down Expand Up @@ -188,6 +189,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
refreshedAt={loaderData.refreshedAt}
endYear={loaderData.coverageEndYear}
/>
<ScrollToTop />
Comment thread
StanislavBG marked this conversation as resolved.
<AccessibilityWidget />
</>
);
Expand Down
70 changes: 70 additions & 0 deletions apps/web/app/styles/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,73 @@ main.narrow {
margin: -11px 0;
}
}

/* Scroll to top button */
/* Pinned bottom-left (not bottom-right) so it never shares a corner with the
third-party accessibility launcher, which anchors `right: 0` at every
breakpoint (see layout.css's .a11y-tools__button note) — its vertical
position is unreliable third-party CSS, so we separate horizontally
instead of trying to stack above it. */
.scroll-to-top {
position: fixed;
bottom: 24px;
Comment thread
StanislavBG marked this conversation as resolved.
left: 24px;
z-index: 50;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--ink);
color: var(--paper);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transform: translateY(16px);
transition:
Comment thread
StanislavBG marked this conversation as resolved.
opacity 0.3s ease,
visibility 0.3s ease,
transform 0.3s ease,
background 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.scroll-to-top.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}

.scroll-to-top:hover,
.scroll-to-top:focus-visible {
background: var(--accent);
outline: none;
}
.scroll-to-top:focus-visible {
box-shadow:
Comment thread
StanislavBG marked this conversation as resolved.
0 0 0 3px var(--paper),
0 0 0 5px var(--accent),
0 4px 12px rgba(0, 0, 0, 0.15);
}

.scroll-to-top svg {
width: 24px;
height: 24px;
}

@media (prefers-reduced-motion: reduce) {
.scroll-to-top {
transition: none;
}
}

@media (max-width: 760px) {
.scroll-to-top {
bottom: 16px;
left: 16px;
width: 44px;
height: 44px;
}
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"jsdom": "^29.1.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['app/**/*.test.ts', 'workers/**/*.test.ts'],
include: ['app/**/*.test.ts', 'app/**/*.test.tsx', 'workers/**/*.test.ts'],
},
});
Loading