feat(search): add direct page-jump control to document search pagination #340
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
User story
As a reader, I want to jump directly to a specific page in document search results, so that I don't have to click "next" repeatedly to reach page 5 or beyond.
Context
Current pagination is next/prev arrows only. With large collections users must click many times to reach deep pages. Standard pattern: numbered page buttons (e.g. 1 … 4 5 6 … last) or a page-number input field.
Acceptance criteria
🏗️ Markus Keller — Application Architect
Observations
buildSearchParams()in+page.svelteis a single source of truth for URL construction, andbuildPageHref()reuses it cleanly. Adding a page-jump control is purely an additive change toPagination.svelte— no backend changes needed.totalPagesis already computed and returned by the backend viaDocumentSearchResult.paged(), and surfaced to the frontend viadata.totalPages. The data contract is complete.@Max(100_000)on thepageparameter, which is the correct place for that guard. The out-of-range AC ("nearest valid page is used") must be clarified: the current backend returns a 400 for pages beyond 100_000 but returns an empty result (not a redirect) for any page number between the last page and 100_000. The frontend should clamp the input on the client side before navigation, not rely on backend validation to define UX behavior.PAGE_SIZE = 50constant means meaningful page-jump use begins around 50+ documents. The family archive will reach that count early — the milestone label "Demo Day" confirms this needs to ship soon.Recommendations
Pagination.svelte. Do not let it leak into+page.svelte'sbuildPageHref()— that function already accepts a target page number, so it will work without modification.Pagination.svelte:targetPage = Math.max(0, Math.min(totalPages - 1, inputPage - 1))before building the href. This satisfies the AC "nearest valid page is used" purely at the UI layer, which is the right boundary for UX-defined clamping.buildPageHref(targetPage)already produces the correct URL. Navigate to it via a normal<a href>(same as prev/next) for consistency and correct scroll restoration.$derivedinsidePagination.svelterather than polluting the parent.👨💻 Felix Brandt — Fullstack Developer
Observations
Pagination.svelteis currently 80 lines and handles one visual concern: prev/next navigation. A page-jump addition (whether numbered buttons or input field) is a second visual concern. The component will need to stay below 60 lines of template markup or be split — e.g.,PaginationJump.sveltefor the input/buttons region andPagination.svelteas a thin orchestrator.makeHrefprop is a clean abstraction: the component does not know about URL structure, it only callsmakeHref(pageNumber). Any page-jump implementation must continue to callmakeHref(targetPage)for consistency — do not pass agotocallback or duplicate URL logic inside the component.Pagination.svelte.spec.ts) is well-structured withvitest-browser-svelte, real DOM rendering, and meaningful assertions. The new behavior needs the same level of coverage: render the jump input, assert the produced href, assert clamping behavior, assert aria attributes.$props()destructuring correctly.$derivedis already used forhasPrev/hasNext. The page-window computation for numbered buttons would be a natural$derived.by(() => {...})— multi-step, so use the.by()form.change/keydownevent that builds an href and navigates. This should be an<a>wrapping a visually hidden target, or better: a<form>withmethod="GET"pointing at the documents path, progressive-enhanced to prevent full reload — consistent with the project'suse:enhancepreference.Recommendations
renders page-jump input,input value is clamped to totalPages,enter on page 5 produces correct href,input is aria-labelled.const pageWindow = $derived.by(() => { ... })— keep the template readable.{#each pageWindow as p (p)}with a key expression — the existing spec already tests for keyed#eachcompliance indirectly via href assertions.type="number"withmin="1"max={totalPages}provides browser-native clamping as a second defense layer, but do not rely on it alone — JavaScript clamping before href construction is required for the AC.pagination_page_oflabel from the center position, since "Seite 5 von 12" becomes redundant when page 5 is visually highlighted. Update the existing test that assertsaria-current="page"— that attribute should move to the current page button, not a separate<span>.🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
pagequery parameter via@RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page. The@Max(100_000)guard prevents arithmetic overflow inpageable.getOffset(). No backend change required.PAGE_SIZE = 50constant is defined in+page.server.ts. If it changes in the future,totalPagesreturned by the backend will adjust automatically — the page-jump control needs no knowledge of page size.Recommendations
<input type="number">,<a href>) are native HTML. Adding a pagination library (likeflowbite-svelteor similar) for a component this simple would be unjustified maintenance overhead./api/documents/searchendpoint proxied via Vite config should work as-is.📋 Elicit — Requirements Engineer
Observations
The issue is well-written and production-ready for implementation. User story, context, and acceptance criteria are present and testable. A few gaps worth resolving before coding:
AC completeness:
0or a negative number is entered. Define explicitly: clamp to page 1 (i.e., 0-indexed page 0).totalPages === 1. The existingPagination.sveltehides the control entirely whentotalPages <= 1. Should the page-jump control also hide in this case? Recommend: yes, for consistency.Missing NFRs:
aria-labelor<label for="...">) — not just a placeholder. The currentPagination.sveltealready meets WCAG 2.2 touch target requirements (min-h-[44px]); the new control must match.de.json,en.json, andes.json.Variant selection is an open decision — the issue says "numbered buttons or input" but doesn't commit to one. This affects component complexity, test scope, and mobile behavior significantly.
Recommendations
Open Decisions
🔒 Nora "NullX" Steiner — Security Engineer
Observations
+page.server.ts→ backend. The existing validation chain is correct:Number(url.searchParams.get('page') ?? '0') || 0coerces non-numeric input to 0, andMath.max(0, ...)prevents negatives. On the backend,@Min(0) @Max(100_000)provides server-side enforcement. The overflow guard comment inDocumentController.javais exactly the right kind of security comment (explains the threat, not just the code).eval(), and not used in a query without parameterization.https://attacker.com/documents?page=5), an open redirect could occur. Mitigation:buildPageHref()always returns a relative path starting with/documents?— this is already correct and should remain so. The new control must usebuildPageHref()ormakeHref(), not construct URLs from raw user input.type="number"HTML attribute prevents arbitrary string injection from the input field on modern browsers, but must not be the sole defense. JavaScript clamping before href construction is required.Recommendations
makeHref(targetPage)as the exclusive URL-building mechanism — never concatenate user input directly into the href string.Math.max(0, Math.min(totalPages - 1, userInput - 1))before passing tomakeHref. This is both the UX clamping (AC3) and the security boundary.totalPages + 999) is clamped to the last page href, not a crafted URL. This doubles as a security regression test.🧪 Sara Holt — QA Engineer
Observations
The existing
Pagination.svelte.spec.tsis a solid baseline:vitest-browser-svelte, real DOM, role/testid selectors, no internal state assertions. The test structure is exactly right.Coverage gaps that will emerge with the new control:
totalPages === 0ortotalPages === 1(hidden state) — this edge case should already be tested, and the new control must be verified hidden in these conditions too.0, inputtotalPages + 1, inputNaN, input99999.1 … 4 5 6 … 12) needs tests for: page 1, page 2 (left edge), pagetotalPages, pagetotalPages - 1(right edge), and a middle page.E2E layer: The existing Playwright suite should gain one happy-path test: from the document search page, use the page-jump control to navigate to page 3, assert the URL contains
page=3, and assert results are rendered. This covers the full SSR load cycle that unit tests cannot.Current test gap: There is no E2E test for the existing prev/next pagination. Adding the page-jump E2E test is a good opportunity to fill that gap simultaneously with a Page Object Model entry.
Recommendations
Pagination.svelte.spec.ts:renders page-jump control when totalPages > 1does not render page-jump control when totalPages <= 1page-jump input is aria-labelledclamped input below 1 produces href for page 0clamped input above totalPages produces href for last pagepage-jump control has min-h-[44px] touch targetpage.getByRole('navigation')to locate the pagination nav and jump to a specific page.makeHrefspy pattern in the existing test (vi.fn(makeHref)) is the correct way to assert which page numbers are requested — use it for the jump control assertions too.🎨 Leonie Voss — UI/UX Design Lead
Observations
Pagination.sveltealready meets the project's accessibility baseline: 44px touch targets,focus-visible:ring-2 focus-visible:ring-brand-navy,aria-current="page",aria-hiddenon decorative chevrons. The new page-jump control must match this standard throughout.1 … 4 5 6 … 12) is the right approach for large page counts.sm:breakpoint and below, show only the input-field variant (or just prev/next + current page label as today). Onsm:and above, show the numbered buttons.bg-brand-navy text-whiteto match the project's primary action style. Inactive page buttons should match the existinglinkBasepattern (bg-white border-line text-ink hover:bg-surface). Ellipsis spans (…) should betext-ink-2,aria-hidden="true", non-interactive.font-sans text-sm font-boldper the existingcontrolBaseclass. The page number buttons must maintain this for visual consistency.Recommendations
…spans for gaps. This limits the maximum number of rendered buttons to 7, fitting on all tablet screens.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— replacearia-current="page"from the<span>to this<button>or<a>.<span aria-hidden="true" class="px-2 text-sm text-ink-2">…</span>— no border, no background, not focusable.< sm:): keep the existing prev / "Seite X von Y" / next layout. Do not show page buttons below 640px. This is the responsive rule to encode in the component.w-16,text-center,type="number", paired with<label class="sr-only">for screen reader access. Do not use a placeholder as the label.Open Decisions
sm:(640px) and fall back to the current prev/next layout, or show a compact input field instead? The recommendation is to hide and fall back to prev/next on mobile — this avoids a second layout branch and keeps mobile clean.🗳️ Decision Queue
Consolidated open decisions requiring product input before implementation starts.
1. Control Variant: Numbered Buttons vs. Input Field
Raised by: Elicit, Leonie, Markus
The issue leaves both options open. These are not equivalent — they differ in complexity, mobile behavior, and UX suitability for the 60+ audience:
{#each}<input type="number">sm:Recommendation from reviewers: Numbered buttons on tablet/desktop (
sm:and above), falling back to the existing prev/next layout on mobile (no second control). This is the most complete solution for the primary audience.2. Mobile Fallback: Hide Page Buttons or Show Input Field Below
sm:Raised by: Leonie, Elicit
If the numbered-buttons variant is chosen, what appears below 640px?
sm:. Simple, no second layout branch.<input type="number">belowsm:as a fallback. More powerful but doubles the component's rendering paths and test scope.Recommendation from reviewers: Option A — hide page buttons on mobile and preserve the current layout. The primary page-jump use case (deep navigation into large result sets) is a desktop/tablet task.
3. AC3 Clamping Location: Client-Side vs. Server-Side
Raised by: Elicit, Markus, Nora
AC3 says "nearest valid page is used" but doesn't specify where. The backend already returns 400 for
page > 100_000, and returns an empty result forpage > totalPages. Neither is the intended behavior for AC3.Decision needed: Confirm that clamping is a client-side responsibility only — the frontend clamps
Math.max(0, Math.min(totalPages - 1, userInput - 1))before generating the href, and the backend is not modified.Recommendation from reviewers: Yes, client-side clamping only. No backend change needed.
Implementation complete ✅
Branch:
feat/issue-340-pagination-page-jumpCommit:
2079840aWhat was built
Numbered page-jump buttons (ellipsis pattern: 1 … 4 5 6 … 12) added to
Pagination.svelte.Decisions from the Decision Queue applied:
…spans for gaps. Maximum 7 elements rendered.sm:(640px) viahidden sm:flex. Existing prev / "Seite X von Y" / next layout preserved on mobile.makeHref(entry - 1)uses the existing clamping contract; no backend changes.Files changed
frontend/src/lib/components/Pagination.svelte— addedpageWindow$derived.by()computation + numbered button rowfrontend/src/lib/components/Pagination.svelte.spec.ts— 12 new tests covering: buttons visible/hidden by totalPages, aria-current on active, brand-navy class, 44px touch target, makeHref linkage, first/last always shown, left/right ellipsis presence, mobile hidden classfrontend/messages/{de,en,es}.json— newpagination_page_buttoni18n keyTest result
21 tests pass (9 existing + 12 new), all green. Lint and Prettier clean.