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)} />
2. objects/objects-table.tsx — rows: real link + aria-current
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 rowonClickfrom double-navigating when the link itself is clicked.- The whole-row
onClickstays, 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, andaria-selectedfrom 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-onlylive<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): addimport { useTranslation } from "react-i18next";+const { t } = useTranslation();, thenaria-label={t("common.clear")}onComboboxClear,aria-label={t("common.open")}onComboboxTrigger, and<ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty>(common.noMatchesalready exists).shell/breadcrumb.tsx(:10): adduseTranslation;<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 (noaria-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-currentis omitted (not"false") when the row isn't selected.- The
<caption>issr-only— no visual change to the table. useId()ids are SSR/StrictMode stable and unique per instance.
Testing
label-editor: render twoLabelEditors together; assert their inputs have different ids and each<label>is associated with its own input (e.g.getAllByLabelTextreturns two distinct nodes).objects-table: (a) a data row exposes alinknamed by the object number (getByRole("link", { name: <object_number> })); (b) the row matching the selected:idhas the link witharia-current="page"; (c) clicking a row still deep-links (existing test stays green); (d) the loading state setsaria-busyon the table; (e) an errored fetch rendersrole="alert". Update any existing assertion that referencedaria-selected/the row as a link.options-combobox: the clear/open controls are named viat()and the empty text is translated (assert the English defaults render; the parity test guards sv).breadcrumb: the<nav>accessible name comes fromt("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:colorsgreen; en/sv parity (5 new keys, guarded by the #60 parity test); no codename; no new dependency.focusRingis token-based socheck:colorsstays green.
Acceptance criteria
LabelEditorusesuseId(); two instances never share an input id.- Objects-table data rows expose a real
<Link>(object-number cell) witharia-current="page"on the selected row; norole="link"/aria-selectedon the<tr>; whole-row click still navigates; the filter pills show the keyboard focus ring. - The table sets
aria-busywhile loading and exposes a polite live caption; a load error rendersrole="alert". - The object-detail drawer dialog and the breadcrumb
<nav>have accessible names; the combobox clear/open/empty strings are translated. typecheck/lint/test/build/check:colorsgreen;check:sizereported; 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) insidecomponents/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.