feat(search): add direct page-jump control to document search pagination (#340) #346
@@ -816,6 +816,7 @@
|
|||||||
"pagination_next": "Weiter",
|
"pagination_next": "Weiter",
|
||||||
"pagination_page_of": "Seite {page} von {total}",
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
"pagination_nav_label": "Seitennavigation",
|
"pagination_nav_label": "Seitennavigation",
|
||||||
|
"pagination_page_button": "Seite {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||||
|
|
||||||
|
|||||||
@@ -816,6 +816,7 @@
|
|||||||
"pagination_next": "Next",
|
"pagination_next": "Next",
|
||||||
"pagination_page_of": "Page {page} of {total}",
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
"pagination_nav_label": "Pagination",
|
"pagination_nav_label": "Pagination",
|
||||||
|
"pagination_page_button": "Page {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(opens in new tab)",
|
"common_opens_new_tab": "(opens in new tab)",
|
||||||
|
|
||||||
|
|||||||
@@ -816,6 +816,7 @@
|
|||||||
"pagination_next": "Siguiente",
|
"pagination_next": "Siguiente",
|
||||||
"pagination_page_of": "Página {page} de {total}",
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
"pagination_nav_label": "Paginación",
|
"pagination_nav_label": "Paginación",
|
||||||
|
"pagination_page_button": "Página {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
"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';
|
'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 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 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;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
@@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
|
||||||
|
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
|
||||||
<span
|
<span
|
||||||
data-testid="pagination-page-label"
|
data-testid="pagination-page-label"
|
||||||
aria-current="page"
|
aria-hidden="true"
|
||||||
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 })}
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Always in the AT tree: announces current page regardless of breakpoint.
|
||||||
|
On mobile, the desktop button container is display:none so this is the only AT anchor.
|
||||||
|
On desktop, the active page button also carries aria-current — both announce the same info. -->
|
||||||
|
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
|
||||||
|
{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 (entry === null ? 'ellipsis-' + i : entry)}
|
||||||
|
{#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}
|
{#if hasNext}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -19,11 +19,145 @@ describe('Pagination', () => {
|
|||||||
await expect.element(label).toHaveTextContent(/10/);
|
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 });
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const label = page.getByTestId('pagination-page-label');
|
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 () => {
|
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user