diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8522580c..e04fd285 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -816,6 +816,7 @@ "pagination_next": "Weiter", "pagination_page_of": "Seite {page} von {total}", "pagination_nav_label": "Seitennavigation", + "pagination_page_button": "Seite {page}", "common_opens_new_tab": "(öffnet in neuem Tab)", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cddafac1..32533e2a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -816,6 +816,7 @@ "pagination_next": "Next", "pagination_page_of": "Page {page} of {total}", "pagination_nav_label": "Pagination", + "pagination_page_button": "Page {page}", "common_opens_new_tab": "(opens in new tab)", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f5125884..564d24fc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -816,6 +816,7 @@ "pagination_next": "Siguiente", "pagination_page_of": "Página {page} de {total}", "pagination_nav_label": "Paginación", + "pagination_page_button": "Página {page}", "common_opens_new_tab": "(abre en pestaña nueva)", diff --git a/frontend/src/lib/components/Pagination.svelte b/frontend/src/lib/components/Pagination.svelte index 01659e9e..10f2d77d 100644 --- a/frontend/src/lib/components/Pagination.svelte +++ b/frontend/src/lib/components/Pagination.svelte @@ -20,6 +20,48 @@ const controlBase = 'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink'; const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`; const disabledBase = `${controlBase} cursor-not-allowed opacity-40`; +const activePageBase = + 'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white'; + +/** + * Builds the sliding window of 1-indexed page numbers to render as buttons. + * Always shows: first, last, current, one neighbor each side. + * null entries represent ellipsis gaps. + */ +const pageWindow = $derived.by(() => { + const first = 1; + const last = totalPages; + const current = page + 1; // convert to 1-indexed + + const windowStart = Math.max(first, current - 1); + const windowEnd = Math.min(last, current + 1); + + const result: (number | null)[] = []; + + result.push(first); + + if (windowStart > first + 1) { + result.push(null); // left ellipsis + } else if (windowStart === first + 1) { + result.push(windowStart); + } + + for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) { + result.push(p); + } + + if (windowEnd < last - 1) { + result.push(null); // right ellipsis + } else if (windowEnd === last - 1) { + result.push(windowEnd); + } + + if (last > first) { + result.push(last); + } + + return result; +}); {#if totalPages > 1} @@ -52,14 +94,54 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`; {/if} + {m.pagination_page_of({ page: page + 1, total: totalPages })} + + + {#if hasNext} { await expect.element(label).toHaveAttribute('aria-current', 'page'); }); + 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 button buttons have 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 prev as a link pointing at page - 1 when not on first page', async () => { render(Pagination, { page: 4, totalPages: 10, makeHref });