Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
451904daeb fix(pagination): hide mobile page label from AT tree with aria-hidden
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m23s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m59s
The mobile 'Seite X von Y' span had aria-current='page', which created two
elements announcing the current page on wide screens: the hidden mobile label
and the active desktop button. On sm:+ screens the mobile span is display:none
(removed from AT tree), but on small screens both the span and the desktop
button were redundant.

Replace aria-current with aria-hidden='true' on the mobile label so AT always
relies on the desktop button's aria-current. Updates spec test accordingly and
adds a second assertion in a broader test context (Decision Queue #1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:37:50 +02:00
Marcel
d97b62c072 fix(pagination): use stable key in {#each} and fix duplicate page number bug
Replaces position-based key `i` with `entry === null ? 'ellipsis-' + i : entry`
so DOM reconciliation is stable when the window shifts (Decision Queue #2).

The index-based key was masking a duplicate-push bug in pageWindow: when
windowStart === first+1 or windowEnd === last-1, the loop already included that
number, causing Svelte to throw `each_key_duplicate` once stable keys are used.
Fixed the bridge-page conditions to use first+2 / last-2 thresholds so the loop
and the bridge branches never push the same page number.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:36:44 +02:00
Marcel
92241447ae test(pagination): fix test name typo and add totalPages===2 boundary test
Renames 'page button buttons' → 'page buttons container' (Decision Queue #3).
Adds 'renders both pages without ellipsis when totalPages is 2' to cover the
boundary between the 1-page (hidden) and full-ellipsis-window cases (Decision Queue #5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:35:42 +02:00
Marcel
2079840adb feat(pagination): add numbered page-jump buttons to document search
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m6s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m2s
CI / Unit & Component Tests (pull_request) Failing after 3m10s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m16s
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>
2026-04-26 20:58:44 +02:00
10 changed files with 221 additions and 104 deletions

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Briefwechsel",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen",
@@ -818,6 +816,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)",

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Letters",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_confirm": "Confirm",
@@ -818,6 +816,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)",

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Cartas",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_confirm": "Confirmar",
@@ -818,6 +816,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)",

View File

@@ -48,12 +48,6 @@ function handleKeydown(event: KeyboardEvent) {
}
}
const bellLabel = $derived(
stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()
);
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
@@ -78,11 +72,12 @@ onDestroy(() => {
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={bellLabel}
title={bellLabel}
aria-label={stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()}
aria-expanded={open}
aria-haspopup="true"
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -55,34 +55,6 @@ async function openDropdownAndClickFirstNotification() {
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];

View File

@@ -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 + 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>
{#if totalPages > 1}
@@ -52,14 +94,55 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
</span>
{/if}
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
<!-- aria-hidden: desktop numbered buttons carry the aria-current="page" role; this label is redundant on wide screens -->
<span
data-testid="pagination-page-label"
aria-current="page"
class="font-sans text-sm text-ink-2"
aria-hidden="true"
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 (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}
<a
data-testid="pagination-next"

View File

@@ -19,11 +19,136 @@ describe('Pagination', () => {
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 });
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('renders prev as a link pointing at page - 1 when not on first page', async () => {

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark';
@@ -20,10 +19,6 @@ onMount(() => {
theme = resolveInitialTheme();
});
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
@@ -34,8 +29,8 @@ function toggle() {
<button
type="button"
onclick={toggle}
aria-label={themeLabel}
title={themeLabel}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={theme === 'dark' ? 'light mode' : 'dark mode'}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}

View File

@@ -1,45 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ThemeToggle from './ThemeToggle.svelte';
afterEach(() => {
cleanup();
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
});
it('title equals aria-label in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});

View File

@@ -365,11 +365,6 @@
text-underline-offset: 4px;
}
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
button {
cursor: pointer;
}
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
:focus-visible {
outline: 2px solid var(--c-focus-ring);