From 2079840adbef7d0b5514e95db2df538c1d9f6362 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 20:58:44 +0200 Subject: [PATCH 1/5] feat(pagination): add numbered page-jump buttons to document search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ellipsis-style numbered page button row (1 … 4 5 6 … 12) to Pagination.svelte. Buttons are hidden on mobile (sm: breakpoint) and fall back to the existing prev/next layout. Active page uses brand-navy background. Client-side clamping via makeHref(entry - 1) satisfies AC3. i18n key pagination_page_button added for de/en/es. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/lib/components/Pagination.svelte | 84 +++++++++++++- .../lib/components/Pagination.svelte.spec.ts | 108 ++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) 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 }); -- 2.49.1 From 92241447aea6ea414ffa2fd8ee357842dfdc47f5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 21:35:42 +0200 Subject: [PATCH 2/5] test(pagination): fix test name typo and add totalPages===2 boundary test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames 'page button buttons' → 'page buttons container' (Decision Queue #3). Adds 'renders both pages without ellipsis when totalPages is 2' to cover the boundary between the 1-page (hidden) and full-ellipsis-window cases (Decision Queue #5). Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/Pagination.svelte.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/Pagination.svelte.spec.ts b/frontend/src/lib/components/Pagination.svelte.spec.ts index e9e9a576..8dc904c0 100644 --- a/frontend/src/lib/components/Pagination.svelte.spec.ts +++ b/frontend/src/lib/components/Pagination.svelte.spec.ts @@ -123,7 +123,7 @@ describe('Pagination', () => { await expect.element(rightEllipsis).not.toBeInTheDocument(); }); - it('page button buttons have hidden class on mobile (sm: prefix)', async () => { + 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 }); @@ -132,6 +132,16 @@ describe('Pagination', () => { 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('renders prev as a link pointing at page - 1 when not on first page', async () => { -- 2.49.1 From d97b62c072c5b021c6f88acdcab96e90b58548cf Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 21:36:44 +0200 Subject: [PATCH 3/5] fix(pagination): use stable key in {#each} and fix duplicate page number bug Replaces position-based key `i` with `entry === null ? 'ellipsis-' + i : entry` so DOM reconciliation is stable when the window shifts (Decision Queue #2). The index-based key was masking a duplicate-push bug in pageWindow: when windowStart === first+1 or windowEnd === last-1, the loop already included that number, causing Svelte to throw `each_key_duplicate` once stable keys are used. Fixed the bridge-page conditions to use first+2 / last-2 thresholds so the loop and the bridge branches never push the same page number. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/Pagination.svelte | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/Pagination.svelte b/frontend/src/lib/components/Pagination.svelte index 10f2d77d..45ba1ae5 100644 --- a/frontend/src/lib/components/Pagination.svelte +++ b/frontend/src/lib/components/Pagination.svelte @@ -40,20 +40,20 @@ const pageWindow = $derived.by(() => { result.push(first); - if (windowStart > first + 1) { + if (windowStart > first + 2) { result.push(null); // left ellipsis - } else if (windowStart === first + 1) { - result.push(windowStart); + } else if (windowStart === first + 2) { + result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis } for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) { result.push(p); } - if (windowEnd < last - 1) { + if (windowEnd < last - 2) { result.push(null); // right ellipsis - } else if (windowEnd === last - 1) { - result.push(windowEnd); + } else if (windowEnd === last - 2) { + result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis } if (last > first) { @@ -105,7 +105,7 @@ const pageWindow = $derived.by(() => {