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..b9c4834b 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 + 2) {
+ result.push(null); // left ellipsis
+ } 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 - 2) {
+ result.push(null); // right ellipsis
+ } else if (windowEnd === last - 2) {
+ result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
+ }
+
+ if (last > first) {
+ result.push(last);
+ }
+
+ return result;
+});
{#if totalPages > 1}
@@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
{/if}
+
+
{m.pagination_page_of({ page: page + 1, total: totalPages })}
+
+
+ {m.pagination_page_of({ page: page + 1, total: totalPages })}
+
+
+
+
+ {#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
+ {#if entry === null}
+ {#if i === 1}
+
…
+ {:else}
+
…
+ {/if}
+ {:else if entry === page + 1}
+
+ {entry}
+
+ {:else}
+
+ {entry}
+
+ {/if}
+ {/each}
+
{#if hasNext}
{
await expect.element(label).toHaveTextContent(/10/);
});
- it('marks the current page label with aria-current="page"', async () => {
+ 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-current', 'page');
+ 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 () => {