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>
170 lines
5.4 KiB
Svelte
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}
|