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:
Marcel
2026-04-24 08:34:01 +02:00
committed by marcel
parent 826c0827dc
commit 78ac5d663d
10 changed files with 243 additions and 14 deletions

View File

@@ -10,6 +10,8 @@ type ValidSort = (typeof VALID_SORTS)[number];
const VALID_DIRS = ['asc', 'desc'] as const;
type ValidDir = (typeof VALID_DIRS)[number];
const PAGE_SIZE = 50;
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
const from = url.searchParams.get('from') || '';
@@ -27,6 +29,7 @@ export async function load({ url, fetch }) {
: 'desc';
const tagQ = url.searchParams.get('tagQ') || '';
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
const api = createApiClient(fetch);
@@ -44,14 +47,19 @@ export async function load({ url, fetch }) {
tagQ: tagQ && !tags.length ? tagQ : undefined,
tagOp: tagOp === 'OR' ? 'OR' : undefined,
sort,
dir: dir || undefined
dir: dir || undefined,
page,
size: PAGE_SIZE
}
}
});
} catch {
return {
items: [] as DocumentSearchItem[],
total: 0,
totalElements: 0,
pageNumber: 0,
pageSize: PAGE_SIZE,
totalPages: 0,
q,
from,
to,
@@ -77,7 +85,10 @@ export async function load({ url, fetch }) {
return {
items: (result.data?.items ?? []) as DocumentSearchItem[],
total: result.data?.total ?? 0,
totalElements: result.data?.totalElements ?? 0,
pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE,
totalPages: result.data?.totalPages ?? 0,
q,
from,
to,

View File

@@ -5,6 +5,7 @@ import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from '../SearchFilterBar.svelte';
import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/components/Pagination.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -35,6 +36,12 @@ let showAdvanced = $state(untrack(hasAdvancedFilters));
let searchTimer: ReturnType<typeof setTimeout>;
/**
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
* not carried over — any filter change implicitly resets back to page 0, which
* is the expected behaviour. For page-only navigation use {@link buildPageHref}
* instead, which preserves every filter from the server `data`.
*/
function triggerSearch() {
const params = new SvelteURLSearchParams();
if (q) params.set('q', q);
@@ -50,6 +57,29 @@ function triggerSearch() {
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
}
/**
* Builds the href for a Pagination prev/next link. Preserves every current
* filter param and only updates `page`. Uses a normal <a href> (not goto)
* so SvelteKit's default scroll restoration brings the user to the top of
* the new slice — the expected behaviour for page navigation.
*/
function buildPageHref(targetPage: number): string {
const params = new SvelteURLSearchParams();
if (data.q) params.set('q', data.q);
if (data.from) params.set('from', data.from);
if (data.to) params.set('to', data.to);
if (data.senderId) params.set('senderId', data.senderId);
if (data.receiverId) params.set('receiverId', data.receiverId);
(data.tags || []).forEach((t: string) => params.append('tag', t));
if (data.sort) params.set('sort', data.sort);
if (data.dir) params.set('dir', data.dir);
if (data.tagQ) params.set('tagQ', data.tagQ);
if (data.tagOp === 'OR') params.set('tagOp', 'OR');
if (targetPage > 0) params.set('page', String(targetPage));
const qs = params.toString();
return qs ? `/documents?${qs}` : '/documents';
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => triggerSearch(), 500);
@@ -115,10 +145,12 @@ $effect(() => {
<DocumentList
items={data.items}
total={data.total}
total={data.totalElements}
q={data.q}
canWrite={data.canWrite}
error={data.error}
sort={sort}
/>
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
</main>

View File

@@ -25,7 +25,7 @@ describe('documents page load — search params', () => {
it('passes q, from, to to the search API', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], total: 0 }
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -49,7 +49,7 @@ describe('documents page load — search params', () => {
it('passes senderId and receiverId to the search API', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], total: 0 }
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -73,7 +73,7 @@ describe('documents page load — search params', () => {
it('passes sort, dir, tagQ to the search API', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], total: 0 }
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -103,7 +103,7 @@ describe('documents page load — search params', () => {
};
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [item], total: 42 }
data: { items: [item], totalElements: 42, pageNumber: 0, pageSize: 50, totalPages: 1 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -115,13 +115,13 @@ describe('documents page load — search params', () => {
});
expect(result.items).toHaveLength(1);
expect(result.total).toBe(42);
expect(result.totalElements).toBe(42);
});
it('returns filter values in the result for pre-filling the UI', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], total: 0 }
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient

View File

@@ -118,4 +118,20 @@ describe('documents page — URL building', () => {
expect.objectContaining({ keepFocus: true, noScroll: true })
);
});
it('filter change does not carry the current page — goto URL drops page param', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
// User is mid-way through results at page 5; change the search text.
render(Page, { data: makeData({ q: 'old', pageNumber: 5 }) });
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
await input.fill('Brief');
vi.advanceTimersByTime(500);
const [url] = vi.mocked(goto).mock.calls[0];
expect(url).toContain('q=Brief');
expect(url).not.toContain('page=');
});
});