Files
biggus-dickus/docs/superpowers/specs/2026-06-08-a11y-defect-bundle-design.md
T

8.6 KiB

Accessibility Defect Bundle — Design

Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #62 (label-id collision, invalid table row semantics, unnamed drawer, unannounced table states, last untranslated strings).

Context

A frontend deep audit (post the #52 a11y pass, which is verified correct) found five remaining accessibility gaps. They are independent, low-risk fixes; no new dependency. The #52 work (focus ring, skip link, route focus, authority nav links, lang group, <html lang> sync) stays untouched.

Components

1. components/label-editor.tsx — id collision → useId()

LabelEditor hardcodes <Label htmlFor="label"> + <Input id="label"> (:35-36). It renders simultaneously in the create form and any inline-edit row (authorities + vocab), so two id="label" inputs coexist and every <label for="label"> resolves to the first — the second editor's label points at the wrong field (WCAG 1.3.1 / 4.1.1 / 4.1.2). Fix with React's useId() (zero call-site changes):

import { useId } from "react";
// …
const inputId = useId();
<Label htmlFor={inputId}>{t("labels.label")}</Label>
<Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />

The data rows are <tr role="link" tabIndex={0} aria-selected={selected} onClick onKeyDown> (:247-259). aria-selected is invalid on role="link" (AT ignores it). Decision (brainstorming): make the object-number cell a real React Router <Link> and keep the whole row clickable. The <tr> becomes a plain row:

<tr
  key={object.id}
  onClick={() => navigate(`/objects/${object.id}?${params}`)}
  className={`cursor-pointer border-b text-sm ${selected ? "bg-primary/10" : "hover:bg-muted"}`}
>
  <td className="px-3 py-2 text-muted-foreground">
    <Link
      to={`/objects/${object.id}?${params}`}
      aria-current={selected ? "page" : undefined}
      onClick={(event) => event.stopPropagation()}
      className={`${focusRing} rounded-sm hover:underline`}
    >
      {object.object_number}
    </Link>
  </td>
  {/* …other cells unchanged… */}
</tr>
  • Native anchor ⇒ keyboard focus + Enter activation + SR "link" + middle-click / open-in-new-tab, all free.
  • event.stopPropagation() on the link prevents the row onClick from double-navigating when the link itself is clicked.
  • The whole-row onClick stays, so clicking any non-link cell still opens the object (preserves current UX; the existing "clicking a row …" test clicks the object-name cell and still passes).
  • Selection is conveyed by aria-current="page" on the link (announced when focused); the visual highlight stays on the <tr> via the className.
  • Drop role="link", tabIndex, onKeyDown, and aria-selected from the <tr>.

2b. objects/objects-table.tsx — filter pills: restore focusRing

The visibility pills (:168-177) lack the keyboard focus ring their siblings (search-panel, authorities-page) have. Import focusRing from ../lib/focus-ring and append it to the pill className:

className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}

3. objects/objects-table.tsx — announce loading / error

Loading renders bare <Skeleton> rows and the error a plain <td> — neither is announced (WCAG 4.1.3).

  • Add aria-busy={isLoading || undefined} to the <table>.
  • Add an sr-only live <caption> that announces loading and settles to the table name (also gives the table an accessible name):
<table className="w-full border-collapse" aria-busy={isLoading || undefined}>
  <caption className="sr-only" aria-live="polite">
    {isLoading ? t("common.loading") : t("objects.tableLabel")}
  </caption>
  {columns}
  {body}
</table>
  • Add role="alert" to the error <td> (:223) so a load failure is announced assertively:
<td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
  {t("objects.loadError")}
</td>

4. objects/object-detail-drawer.tsx — name the drawer dialog

The Base UI drawer (a modal dialog) has no accessible name. DrawerContent spreads props onto the Popup, so pass an aria-label:

<DrawerContent aria-label={t("objects.detailTitle")}>

(No change to components/ui/drawer.tsx.)

5. Last untranslated strings

  • objects/options-combobox.tsx (:47-52): add import { useTranslation } from "react-i18next"; + const { t } = useTranslation();, then aria-label={t("common.clear")} on ComboboxClear, aria-label={t("common.open")} on ComboboxTrigger, and <ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty> (common.noMatches already exists).
  • shell/breadcrumb.tsx (:10): add useTranslation; <nav aria-label={t("nav.breadcrumb")}>.

i18n (en + sv parity — 5 new keys)

common.noMatches and common.loading already exist. New keys:

key en sv
common.clear Clear Rensa
common.open Open Öppna
nav.breadcrumb Breadcrumb Brödsmulor
objects.detailTitle Object detail Objektdetalj
objects.tableLabel Objects Objekt

Data flow / accessibility

No data-flow changes. Tab order in the objects table becomes: filter input → visibility pills (now with ring) → New button → each row's object-number link → pagination. The selected row's link carries aria-current="page". Loading politely announces via the caption; a load error asserts via the alert cell. The drawer and breadcrumb gain accessible names; every combobox control is named in the active locale.

Error handling / edges

  • aria-busy={isLoading || undefined} omits the attribute when not loading (no aria-busy="false" noise).
  • event.stopPropagation() on the row link means a plain click on the number navigates once (link), not twice; modifier/middle clicks open a new tab natively (React Router <Link> respects them).
  • aria-current is omitted (not "false") when the row isn't selected.
  • The <caption> is sr-only — no visual change to the table.
  • useId() ids are SSR/StrictMode stable and unique per instance.

Testing

  • label-editor: render two LabelEditors together; assert their inputs have different ids and each <label> is associated with its own input (e.g. getAllByLabelText returns two distinct nodes).
  • objects-table: (a) a data row exposes a link named by the object number (getByRole("link", { name: <object_number> })); (b) the row matching the selected :id has the link with aria-current="page"; (c) clicking a row still deep-links (existing test stays green); (d) the loading state sets aria-busy on the table; (e) an errored fetch renders role="alert". Update any existing assertion that referenced aria-selected/the row as a link.
  • options-combobox: the clear/open controls are named via t() and the empty text is translated (assert the English defaults render; the parity test guards sv).
  • breadcrumb: the <nav> accessible name comes from t("nav.breadcrumb").
  • object-detail-drawer: the open drawer dialog has an accessible name (getByRole("dialog", { name }) or equivalent for the Base UI popup).
  • Gate: typecheck/lint/test/build/check:size/check:colors green; en/sv parity (5 new keys, guarded by the #60 parity test); no codename; no new dependency. focusRing is token-based so check:colors stays green.

Acceptance criteria

  1. LabelEditor uses useId(); two instances never share an input id.
  2. Objects-table data rows expose a real <Link> (object-number cell) with aria-current="page" on the selected row; no role="link"/aria-selected on the <tr>; whole-row click still navigates; the filter pills show the keyboard focus ring.
  3. The table sets aria-busy while loading and exposes a polite live caption; a load error renders role="alert".
  4. The object-detail drawer dialog and the breadcrumb <nav> have accessible names; the combobox clear/open/empty strings are translated.
  5. typecheck/lint/test/build/check:colors green; check:size reported; en/sv parity (5 new keys); no codename; no new dependency.

Out of scope → follow-ups

  • The combobox wrapper's raw palette utilities (text-neutral-*, bg-indigo-50, bg-white) inside components/ui/combobox.tsx, and the segmented-control extraction → design-kit issue #66.
  • A full ARIA grid for the objects table (sortable/selectable grid semantics) — the navigation-table + real-link pattern is the correct, simpler choice here.
  • Automated axe/CI a11y scanning.