fix(pagination): bound controls render as aria-hidden spans — address @leonievoss
<a aria-disabled="true"> is the documented pattern but screen readers still announce "Previous, link, disabled" on pagination bounds — noise users don't need because the disabled state is purely visual. Switching to <span aria-hidden="true"> removes the bound control from the AT tree entirely (Leonie's recommendation). Visual parity preserved via a disabledBase Tailwind class (same layout + cursor-not-allowed + opacity-40). Tests updated: "disabled prev/next" assertions now check for aria-hidden and no href — the active-state href/aria-current assertions are unchanged. (#316) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,10 @@ const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
|||||||
|
|
||||||
const hasPrev = $derived(page > 0);
|
const hasPrev = $derived(page > 0);
|
||||||
const hasNext = $derived(page < totalPages - 1);
|
const hasNext = $derived(page < totalPages - 1);
|
||||||
const linkBase =
|
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 transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40';
|
'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`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
@@ -25,16 +27,30 @@ const linkBase =
|
|||||||
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||||
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||||
>
|
>
|
||||||
<a
|
<!--
|
||||||
data-testid="pagination-prev"
|
At the bounds we render a <span aria-hidden="true"> instead of an
|
||||||
aria-label={m.pagination_prev()}
|
<a aria-disabled>. aria-disabled on a link is the documented pattern
|
||||||
aria-disabled={!hasPrev}
|
but screen readers still announce "Previous, link, disabled" — which
|
||||||
href={hasPrev ? makeHref(page - 1) : undefined}
|
is confusing on a pagination control where the disabled state is
|
||||||
class={linkBase}
|
purely visual. Hiding the element from the AT tree entirely is the
|
||||||
>
|
cleaner semantic.
|
||||||
<span aria-hidden="true">«</span>
|
-->
|
||||||
{m.pagination_prev()}
|
{#if hasPrev}
|
||||||
</a>
|
<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}
|
||||||
|
|
||||||
<span
|
<span
|
||||||
data-testid="pagination-page-label"
|
data-testid="pagination-page-label"
|
||||||
@@ -44,15 +60,21 @@ const linkBase =
|
|||||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<a
|
{#if hasNext}
|
||||||
data-testid="pagination-next"
|
<a
|
||||||
aria-label={m.pagination_next()}
|
data-testid="pagination-next"
|
||||||
aria-disabled={!hasNext}
|
aria-label={m.pagination_next()}
|
||||||
href={hasNext ? makeHref(page + 1) : undefined}
|
href={makeHref(page + 1)}
|
||||||
class={linkBase}
|
class={linkBase}
|
||||||
>
|
>
|
||||||
{m.pagination_next()}
|
{m.pagination_next()}
|
||||||
<span aria-hidden="true">»</span>
|
<span aria-hidden="true">»</span>
|
||||||
</a>
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
|
||||||
|
{m.pagination_next()}
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ describe('Pagination', () => {
|
|||||||
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables prev on page 0 (no href, aria-disabled="true")', async () => {
|
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
|
||||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const prev = page.getByTestId('pagination-prev');
|
const prev = page.getByTestId('pagination-prev');
|
||||||
await expect.element(prev).toHaveAttribute('aria-disabled', 'true');
|
// Not a link — no href, no role=link
|
||||||
await expect.element(prev).not.toHaveAttribute('href');
|
await expect.element(prev).not.toHaveAttribute('href');
|
||||||
|
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
|
||||||
|
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||||
@@ -48,12 +50,12 @@ describe('Pagination', () => {
|
|||||||
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables next on the last page (no href, aria-disabled="true")', async () => {
|
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
|
||||||
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const next = page.getByTestId('pagination-next');
|
const next = page.getByTestId('pagination-next');
|
||||||
await expect.element(next).toHaveAttribute('aria-disabled', 'true');
|
|
||||||
await expect.element(next).not.toHaveAttribute('href');
|
await expect.element(next).not.toHaveAttribute('href');
|
||||||
|
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls makeHref with p-1 and p+1', async () => {
|
it('calls makeHref with p-1 and p+1', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user