import { describe, it, expect, afterEach, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import Pagination from './Pagination.svelte'; afterEach(() => { cleanup(); }); const makeHref = (p: number) => `/documents?page=${p}`; describe('Pagination', () => { it('renders the page-of-total label for the current page', async () => { render(Pagination, { page: 2, totalPages: 10, makeHref }); const label = page.getByTestId('pagination-page-label'); await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed await expect.element(label).toHaveTextContent(/10/); }); it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => { render(Pagination, { page: 0, totalPages: 3, makeHref }); const label = page.getByTestId('pagination-page-label'); await expect.element(label).toHaveAttribute('aria-hidden', 'true'); }); describe('page number buttons', () => { it('renders page number buttons when totalPages > 1', async () => { render(Pagination, { page: 4, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); // active page button — the current page (5, 1-indexed) const activeBtn = nav.getByTestId('pagination-page-5'); await expect.element(activeBtn).toBeInTheDocument(); }); it('does not render page number buttons when totalPages <= 1', async () => { render(Pagination, { page: 0, totalPages: 1, makeHref }); // entire nav is hidden const nav = page.getByRole('navigation'); await expect.element(nav).not.toBeInTheDocument(); }); it('marks the active page button with aria-current="page"', async () => { render(Pagination, { page: 4, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const activeBtn = nav.getByTestId('pagination-page-5'); await expect.element(activeBtn).toHaveAttribute('aria-current', 'page'); }); it('active page button has brand-navy background', async () => { render(Pagination, { page: 4, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const activeBtn = nav.getByTestId('pagination-page-5'); await expect.element(activeBtn).toHaveClass(/bg-brand-navy/); }); it('active page button has 44px touch target', async () => { render(Pagination, { page: 4, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const activeBtn = nav.getByTestId('pagination-page-5'); await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/); await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/); }); it('inactive page buttons link to their target page via makeHref', async () => { const spy = vi.fn(makeHref); render(Pagination, { page: 4, totalPages: 12, makeHref: spy }); const nav = page.getByRole('navigation'); // page button for page 1 (0-indexed: 0) should link to /documents?page=0 const firstPageBtn = nav.getByTestId('pagination-page-1'); await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0'); }); it('renders first and last page buttons always visible', async () => { render(Pagination, { page: 5, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument(); await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument(); }); it('renders ellipsis span between first page and window when gap exists', async () => { // page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5 render(Pagination, { page: 5, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const ellipses = nav.getByTestId('pagination-ellipsis-left'); await expect.element(ellipses).toBeInTheDocument(); }); it('renders ellipsis span between window and last page when gap exists', async () => { // page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12 render(Pagination, { page: 0, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const ellipsis = nav.getByTestId('pagination-ellipsis-right'); await expect.element(ellipsis).toBeInTheDocument(); }); it('does not render left ellipsis when window is adjacent to first page', async () => { // page 1 (0-indexed: 0) — window starts at 1, adjacent to first page render(Pagination, { page: 0, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const leftEllipsis = nav.getByTestId('pagination-ellipsis-left'); await expect.element(leftEllipsis).not.toBeInTheDocument(); }); it('does not render right ellipsis when window is adjacent to last page', async () => { // last page (0-indexed: 11) — window ends at 12, adjacent to last page render(Pagination, { page: 11, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const rightEllipsis = nav.getByTestId('pagination-ellipsis-right'); await expect.element(rightEllipsis).not.toBeInTheDocument(); }); it('page buttons container has hidden class on mobile (sm: prefix)', async () => { // The page buttons container must be hidden below sm: breakpoint render(Pagination, { page: 4, totalPages: 12, makeHref }); const nav = page.getByRole('navigation'); const pageButtons = nav.getByTestId('pagination-pages'); await expect.element(pageButtons).toHaveClass(/hidden/); await expect.element(pageButtons).toHaveClass(/sm:flex/); }); it('renders both pages without ellipsis when totalPages is 2', async () => { render(Pagination, { page: 0, totalPages: 2, makeHref }); const nav = page.getByRole('navigation'); await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument(); await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument(); await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument(); await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument(); }); }); it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => { render(Pagination, { page: 2, totalPages: 10, makeHref }); const label = page.getByTestId('pagination-page-label'); await expect.element(label).toHaveAttribute('aria-hidden', 'true'); }); it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => { render(Pagination, { page: 2, totalPages: 10, makeHref }); const nav = page.getByRole('navigation'); const srLabel = nav.getByTestId('pagination-current-page-sr'); await expect.element(srLabel).toBeInTheDocument(); await expect.element(srLabel).toHaveAttribute('aria-current', 'page'); }); it('renders prev as a link pointing at page - 1 when not on first page', async () => { render(Pagination, { page: 4, totalPages: 10, makeHref }); const prev = page.getByTestId('pagination-prev'); await expect.element(prev).toHaveAttribute('href', '/documents?page=3'); }); it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => { render(Pagination, { page: 0, totalPages: 3, makeHref }); const prev = page.getByTestId('pagination-prev'); // Not a link — no href, no role=link await expect.element(prev).not.toHaveAttribute('href'); // Hidden from assistive tech — AT shouldn't read "Previous, link, disabled" await expect.element(prev).toHaveAttribute('aria-hidden', 'true'); }); it('renders next as a link pointing at page + 1 when not on last page', async () => { render(Pagination, { page: 0, totalPages: 3, makeHref }); const next = page.getByTestId('pagination-next'); await expect.element(next).toHaveAttribute('href', '/documents?page=1'); }); it('renders disabled next as an aria-hidden non-link on the last page', async () => { render(Pagination, { page: 2, totalPages: 3, makeHref }); const next = page.getByTestId('pagination-next'); await expect.element(next).not.toHaveAttribute('href'); await expect.element(next).toHaveAttribute('aria-hidden', 'true'); }); it('calls makeHref with p-1 and p+1', async () => { const spy = vi.fn(makeHref); render(Pagination, { page: 3, totalPages: 10, makeHref: spy }); const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b); expect(calls).toContain(2); expect(calls).toContain(4); }); it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => { render(Pagination, { page: 1, totalPages: 3, makeHref }); const prev = page.getByTestId('pagination-prev'); await expect .element(prev.getByText('«', { exact: true })) .toHaveAttribute('aria-hidden', 'true'); }); it('prev and next have min 44px touch targets', async () => { render(Pagination, { page: 1, totalPages: 3, makeHref }); const prev = page.getByTestId('pagination-prev'); await expect.element(prev).toHaveClass(/min-h-\[44px\]/); await expect.element(prev).toHaveClass(/min-w-\[44px\]/); }); });