Files
familienarchiv/frontend/src/lib/components/Pagination.svelte
Marcel 3d3fe8d626
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
fix(pagination): add sr-only span to preserve aria-current on mobile AT
When the mobile label is aria-hidden and the desktop button container is
display:none (below sm:), mobile screen reader users had no aria-current
indicator. Added a sr-only span with aria-current="page" that stays in
the AT tree at all breakpoints regardless of CSS display state.

On desktop the active page button also carries aria-current — both
announce the same page information, which is acceptable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00

170 lines
5.4 KiB
Svelte

<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 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}
<nav
aria-label={ariaLabel ?? m.pagination_nav_label()}
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
>
<!--
At the bounds we render a <span aria-hidden="true"> instead of an
<a aria-disabled>. aria-disabled on a link is the documented pattern
but screen readers still announce "Previous, link, disabled" — which
is confusing on a pagination control where the disabled state is
purely visual. Hiding the element from the AT tree entirely is the
cleaner semantic.
-->
{#if hasPrev}
<a
data-testid="pagination-prev"
aria-label={m.pagination_prev()}
href={makeHref(page - 1)}
class={linkBase}
>
<span aria-hidden="true">«</span>
{m.pagination_prev()}
</a>
{:else}
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
<span aria-hidden="true">«</span>
{m.pagination_prev()}
</span>
{/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
data-testid="pagination-page-label"
aria-hidden="true"
class="font-sans text-sm text-ink-2 sm:hidden"
>
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</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}
<a
data-testid="pagination-next"
aria-label={m.pagination_next()}
href={makeHref(page + 1)}
class={linkBase}
>
{m.pagination_next()}
<span aria-hidden="true">»</span>
</a>
{:else}
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
{m.pagination_next()}
<span aria-hidden="true">»</span>
</span>
{/if}
</nav>
{/if}