feat(documents): paginate search with a Pagination control
Frontend side of the /documents pagination work. The page.server.ts load reads ?page= from the URL, forwards page+size=50 to the backend, and exposes the new totalElements/pageNumber/pageSize/totalPages fields on `data`. +page.svelte renders a <Pagination> component below the result list; buildPageHref preserves every filter param and only updates page. The existing triggerSearch debounce flow intentionally drops `page` when any filter changes, so filter edits reset to page 0 automatically. <Pagination> uses plain <a href> links (not goto) so SvelteKit's default scroll restoration scrolls new pages to the top — the expected senior-UX behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch targets, focus-visible ring, stacks vertically under 640px. The control hides itself when totalPages ≤ 1. Test coverage: 9 cases on Pagination (label, aria-current, prev/next enable/disable, makeHref invocation, decorative chevron, touch target), plus a filter-reset assertion on +page.svelte (page 5 → edit q → goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to api.ts pending a post-merge npm run generate:api against a rebuilt dev backend. (#315) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
58
frontend/src/lib/components/Pagination.svelte
Normal file
58
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
/** 0-indexed current page. */
|
||||
page: number;
|
||||
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
|
||||
totalPages: number;
|
||||
/** Given a 0-indexed page number, returns the href the link should point at. */
|
||||
makeHref: (page: number) => string;
|
||||
/** Optional override for the outer `<nav>`'s aria-label. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
||||
|
||||
const hasPrev = $derived(page > 0);
|
||||
const hasNext = $derived(page < totalPages - 1);
|
||||
const linkBase =
|
||||
'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 transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40';
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<nav
|
||||
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||
>
|
||||
<a
|
||||
data-testid="pagination-prev"
|
||||
aria-label={m.pagination_prev()}
|
||||
aria-disabled={!hasPrev}
|
||||
href={hasPrev ? makeHref(page - 1) : undefined}
|
||||
class={linkBase}
|
||||
>
|
||||
<span aria-hidden="true">«</span>
|
||||
{m.pagination_prev()}
|
||||
</a>
|
||||
|
||||
<span
|
||||
data-testid="pagination-page-label"
|
||||
aria-current="page"
|
||||
class="font-sans text-sm text-ink-2"
|
||||
>
|
||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||
</span>
|
||||
|
||||
<a
|
||||
data-testid="pagination-next"
|
||||
aria-label={m.pagination_next()}
|
||||
aria-disabled={!hasNext}
|
||||
href={hasNext ? makeHref(page + 1) : undefined}
|
||||
class={linkBase}
|
||||
>
|
||||
{m.pagination_next()}
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
84
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
84
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import Pagination from './Pagination.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeHref = (p: number) => `/documents?page=${p}`;
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('renders the page-of-total label for the current page', async () => {
|
||||
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||
|
||||
const label = page.getByTestId('pagination-page-label');
|
||||
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
|
||||
await expect.element(label).toHaveTextContent(/10/);
|
||||
});
|
||||
|
||||
it('marks the current page label with aria-current="page"', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const label = page.getByTestId('pagination-page-label');
|
||||
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||
render(Pagination, { page: 4, totalPages: 10, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||
});
|
||||
|
||||
it('disables prev on page 0 (no href, aria-disabled="true")', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect.element(prev).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const next = page.getByTestId('pagination-next');
|
||||
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||
});
|
||||
|
||||
it('disables next on the last page (no href, aria-disabled="true")', async () => {
|
||||
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||
|
||||
const next = page.getByTestId('pagination-next');
|
||||
await expect.element(next).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect.element(next).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it('calls makeHref with p-1 and p+1', async () => {
|
||||
const spy = vi.fn(makeHref);
|
||||
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
|
||||
|
||||
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
|
||||
expect(calls).toContain(2);
|
||||
expect(calls).toContain(4);
|
||||
});
|
||||
|
||||
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
|
||||
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect
|
||||
.element(prev.getByText('«', { exact: true }))
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('prev and next have min 44px touch targets', async () => {
|
||||
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
|
||||
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user