feat(pagination): add numbered page-jump buttons to document search
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 <noreply@anthropic.com>
This commit is contained in:
@@ -818,6 +818,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)",
|
||||
|
||||
|
||||
@@ -818,6 +818,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)",
|
||||
|
||||
|
||||
@@ -818,6 +818,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)",
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
@@ -52,14 +94,54 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
|
||||
<span
|
||||
data-testid="pagination-page-label"
|
||||
aria-current="page"
|
||||
class="font-sans text-sm text-ink-2"
|
||||
class="font-sans text-sm text-ink-2 sm:hidden"
|
||||
>
|
||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||
</span>
|
||||
|
||||
<!-- Desktop: numbered page buttons (hidden below sm:) -->
|
||||
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
|
||||
{#each pageWindow as entry, i (i)}
|
||||
{#if entry === null}
|
||||
{#if i === 1}
|
||||
<span
|
||||
data-testid="pagination-ellipsis-left"
|
||||
aria-hidden="true"
|
||||
class="px-2 text-sm text-ink-2">…</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="pagination-ellipsis-right"
|
||||
aria-hidden="true"
|
||||
class="px-2 text-sm text-ink-2">…</span
|
||||
>
|
||||
{/if}
|
||||
{:else if entry === page + 1}
|
||||
<span
|
||||
data-testid="pagination-page-{entry}"
|
||||
aria-current="page"
|
||||
aria-label={m.pagination_page_button({ page: entry })}
|
||||
class={activePageBase}
|
||||
>
|
||||
{entry}
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
data-testid="pagination-page-{entry}"
|
||||
aria-label={m.pagination_page_button({ page: entry })}
|
||||
href={makeHref(entry - 1)}
|
||||
class={linkBase}
|
||||
>
|
||||
{entry}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasNext}
|
||||
<a
|
||||
data-testid="pagination-next"
|
||||
|
||||
@@ -26,6 +26,114 @@ describe('Pagination', () => {
|
||||
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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user