Frontend a11y: label-id collision, invalid table row semantics, unnamed drawer, unannounced table states #62

Closed
opened 2026-06-08 13:42:12 +00:00 by logaritmisk · 1 comment
Owner

Severity: High (mixed). From a frontend deep audit, 2026-06-08 (post #48). The #52 a11y pass is verified correct; these are the remaining gaps it didn't cover.

Problems

  • [High] Duplicate id="label" collides across simultaneous editors. LabelEditor hard-codes <Label htmlFor="label"> + <Input id="label"> (web/src/components/label-editor.tsx:35-36). It renders simultaneously in the create form and any inline-edit row — authorities (authorities-page.tsx:125 + authority-row.tsx:30) and vocab (vocabulary-terms.tsx + term-row.tsx:29). With two id="label" inputs in the DOM, every <label for="label"> resolves to the first match, so the second editor's label points at the wrong field. WCAG 1.3.1 / 4.1.1 / 4.1.2.
  • [Med] Invalid role="link" + aria-selected on table rows. objects-table.tsx:247-259aria-selected is not a supported state for role="link" (AT ignores/warns). The segmented filter pills in the same toolbar (objects-table.tsx:173) also dropped the focusRing present on the equivalent pills in search-panel.tsx/authorities-page.tsx — a keyboard-focus regression. WCAG 4.1.2 / 2.4.7.
  • [Med] Drawer dialog has no accessible name. The narrow-viewport object-detail drawer (objects/object-detail-drawer.tsx, components/ui/drawer.tsx:35) supplies no Drawer.Title/aria-label, so it announces as an anonymous modal. WCAG 4.1.2 / 2.4.6.
  • [Med] Table loading/error states aren't announced. objects-table.tsx:207-228 renders bare <Skeleton> rows (no aria-busy/role="status") and a plain <td> error (no role="alert"), unlike the ListSkeleton/FormSkeleton pattern in components/ui/skeletons.tsx. WCAG 4.1.3.
  • [Low] Only untranslated UI strings in the app. objects/options-combobox.tsx:47-52aria-label="Clear", aria-label="Open", <ComboboxEmpty>No matches.</ComboboxEmpty> bypass t() (render English for Swedish users). common.noMatches already exists and is unused. Also shell/breadcrumb.tsx:10 aria-label="Breadcrumb" is hardcoded.

Suggested fixes

  • LabelEditor: accept an id/idPrefix prop or use useId(); pass unique ids (label-create, label-${record.id}).
  • Table rows: drop role="link"/aria-selected; make the object-number cell a real <Link> (gains middle-click/open-in-new-tab) and keep <tr> plain — OR move to a valid role="grid"/role="row" structure. Restore focusRing on the filter pills.
  • Drawer: add aria-label/visually-hidden Drawer.Title.
  • Table states: aria-busy + role="status" while loading, role="alert" on error.
  • Translate the combobox strings (t("common.noMatches"), add common.clear/common.open) and the breadcrumb label.

Source: frontend deep audit (a11y dimension), 2026-06-08.

**Severity: High (mixed).** _From a frontend deep audit, 2026-06-08 (post #48). The #52 a11y pass is verified correct; these are the remaining gaps it didn't cover._ ## Problems - **[High] Duplicate `id="label"` collides across simultaneous editors.** `LabelEditor` hard-codes `<Label htmlFor="label">` + `<Input id="label">` (`web/src/components/label-editor.tsx:35-36`). It renders simultaneously in the create form **and** any inline-edit row — authorities (`authorities-page.tsx:125` + `authority-row.tsx:30`) and vocab (`vocabulary-terms.tsx` + `term-row.tsx:29`). With two `id="label"` inputs in the DOM, every `<label for="label">` resolves to the first match, so the second editor's label points at the wrong field. WCAG 1.3.1 / 4.1.1 / 4.1.2. - **[Med] Invalid `role="link"` + `aria-selected` on table rows.** `objects-table.tsx:247-259` — `aria-selected` is not a supported state for `role="link"` (AT ignores/warns). The segmented filter pills in the same toolbar (`objects-table.tsx:173`) also **dropped the `focusRing`** present on the equivalent pills in `search-panel.tsx`/`authorities-page.tsx` — a keyboard-focus regression. WCAG 4.1.2 / 2.4.7. - **[Med] Drawer dialog has no accessible name.** The narrow-viewport object-detail drawer (`objects/object-detail-drawer.tsx`, `components/ui/drawer.tsx:35`) supplies no `Drawer.Title`/`aria-label`, so it announces as an anonymous modal. WCAG 4.1.2 / 2.4.6. - **[Med] Table loading/error states aren't announced.** `objects-table.tsx:207-228` renders bare `<Skeleton>` rows (no `aria-busy`/`role="status"`) and a plain `<td>` error (no `role="alert"`), unlike the `ListSkeleton`/`FormSkeleton` pattern in `components/ui/skeletons.tsx`. WCAG 4.1.3. - **[Low] Only untranslated UI strings in the app.** `objects/options-combobox.tsx:47-52` — `aria-label="Clear"`, `aria-label="Open"`, `<ComboboxEmpty>No matches.</ComboboxEmpty>` bypass `t()` (render English for Swedish users). `common.noMatches` already exists and is unused. Also `shell/breadcrumb.tsx:10` `aria-label="Breadcrumb"` is hardcoded. ## Suggested fixes - `LabelEditor`: accept an `id`/`idPrefix` prop or use `useId()`; pass unique ids (`label-create`, `label-${record.id}`). - Table rows: drop `role="link"`/`aria-selected`; make the object-number cell a real `<Link>` (gains middle-click/open-in-new-tab) and keep `<tr>` plain — OR move to a valid `role="grid"`/`role="row"` structure. Restore `focusRing` on the filter pills. - Drawer: add `aria-label`/visually-hidden `Drawer.Title`. - Table states: `aria-busy` + `role="status"` while loading, `role="alert"` on error. - Translate the combobox strings (`t("common.noMatches")`, add `common.clear`/`common.open`) and the breadcrumb label. _Source: frontend deep audit (a11y dimension), 2026-06-08._
Author
Owner

Fixed in merge 285a132. All five gaps closed:

  • Label-id collisionLabelEditor now uses React useId() (unique per instance), so the create form + inline-edit rows no longer share id="label".
  • Invalid table-row semantics — the objects-table data row is now a plain <tr> with the object-number cell as a real React Router <Link aria-current="page"> (selected row); dropped role="link"/aria-selected/tabIndex/onKeyDown. Whole-row click preserved; gains keyboard/SR "link" + middle-click/open-in-new-tab. Filter pills regained the focusRing.
  • Unannounced table states<table aria-busy> + an sr-only polite live <caption> (also names the table); the load-error cell is role="alert".
  • Unnamed drawer — the object-detail drawer dialog gets aria-label={t("objects.detailTitle")}.
  • Untranslated strings — combobox Clear/Open/empty + the breadcrumb <nav> label now go through t().

5 new en/sv i18n keys (parity). 253 tests pass; typecheck/lint/build clean; check:size 216.3 KB gz; check:colors clean; no new dependency; no codename.

Out of scope → #66: the combobox wrapper's raw-palette internals + the segmented-control extraction.

Fixed in merge `285a132`. All five gaps closed: - **Label-id collision** — `LabelEditor` now uses React `useId()` (unique per instance), so the create form + inline-edit rows no longer share `id="label"`. - **Invalid table-row semantics** — the objects-table data row is now a plain `<tr>` with the object-number cell as a real React Router `<Link aria-current="page">` (selected row); dropped `role="link"`/`aria-selected`/`tabIndex`/`onKeyDown`. Whole-row click preserved; gains keyboard/SR "link" + middle-click/open-in-new-tab. Filter pills regained the `focusRing`. - **Unannounced table states** — `<table aria-busy>` + an `sr-only` polite live `<caption>` (also names the table); the load-error cell is `role="alert"`. - **Unnamed drawer** — the object-detail drawer dialog gets `aria-label={t("objects.detailTitle")}`. - **Untranslated strings** — combobox Clear/Open/empty + the breadcrumb `<nav>` label now go through `t()`. 5 new en/sv i18n keys (parity). 253 tests pass; typecheck/lint/build clean; check:size 216.3 KB gz; check:colors clean; no new dependency; no codename. **Out of scope → #66:** the combobox wrapper's raw-palette internals + the segmented-control extraction.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logaritmisk/biggus-dickus#62