Frontend a11y: focus-visible on custom controls, route focus management, skip link, tablist/lang semantics #52

Closed
opened 2026-06-06 18:52:20 +00:00 by logaritmisk · 1 comment
Owner

Severity: High. From a frontend UX audit. Keyboard-driven fast data entry is a stated efficiency goal, and this is a public-institution tool.

Problems

  • No focus-visible styling on hand-rolled controls. Grep for focus-visible|focus: outside components/ui/ → nothing. Raw <button> (lang-switch.tsx:10, search-panel.tsx:69 facet chips, field-list.tsx delete), the four raw <select>, and the sidebar NavLinks (app-shell.tsx:23-62) have no keyboard focus ring (only hover/active). The ui/* kit has rings; screens lose them the moment they hand-roll a control.
  • No focus management on route change (<Outlet/>, app-shell.tsx:74) and no skip-to-content link — keyboard/SR users tab through the whole sidebar on every navigation and focus is left wherever it was.
  • Authority tabs claim tab semantics they don't fulfill. authorities-page.tsx:48-62 sets role="tablist"/role="tab"/aria-selected on router NavLinks, but there's no roving tabindex/arrow-key handling and no aria-controls/tabpanel — announcing "tab" semantics without the keyboard contract is worse than plain links. (Search's visibility filter uses honest <button aria-pressed>.)
  • Lang switch is two bare aria-pressed buttons with no group label (role="group"/aria-label) and low-contrast inactive state (text-neutral-400).
  • <html lang> never updated. index.html hardcodes lang="en"; switching to Swedish doesn't update document.documentElement.lang (wrong for SR pronunciation / browser translation).

Suggested fixes

  • Route custom controls through ui/Button/ui/select, or add focus-visible:ring-3 focus-visible:ring-ring/50 outline-none to them and the NavLinks.
  • On route change, focus the <main> heading (tabIndex={-1}); add a "skip to content" link as the first focusable element.
  • Either drop the tab ARIA roles in favor of an honest <button aria-pressed> filter group, or implement the full tab pattern; wrap lang switch in role="group" aria-label; ensure inactive contrast ≥ AA.
  • Sync document.documentElement.lang on language change.

Source: frontend UX/design audit, 2026-06-06.

**Severity: High.** _From a frontend UX audit. Keyboard-driven fast data entry is a stated efficiency goal, and this is a public-institution tool._ ## Problems - **No focus-visible styling on hand-rolled controls.** Grep for `focus-visible|focus:` outside `components/ui/` → nothing. Raw `<button>` (`lang-switch.tsx:10`, `search-panel.tsx:69` facet chips, `field-list.tsx` delete), the four raw `<select>`, and the sidebar `NavLink`s (`app-shell.tsx:23-62`) have no keyboard focus ring (only hover/active). The `ui/*` kit has rings; screens lose them the moment they hand-roll a control. - **No focus management on route change** (`<Outlet/>`, `app-shell.tsx:74`) and **no skip-to-content link** — keyboard/SR users tab through the whole sidebar on every navigation and focus is left wherever it was. - **Authority tabs claim tab semantics they don't fulfill.** `authorities-page.tsx:48-62` sets `role="tablist"`/`role="tab"`/`aria-selected` on router `NavLink`s, but there's no roving tabindex/arrow-key handling and no `aria-controls`/`tabpanel` — announcing "tab" semantics without the keyboard contract is worse than plain links. (Search's visibility filter uses honest `<button aria-pressed>`.) - **Lang switch** is two bare `aria-pressed` buttons with no group label (`role="group"`/`aria-label`) and low-contrast inactive state (`text-neutral-400`). - **`<html lang>` never updated.** `index.html` hardcodes `lang="en"`; switching to Swedish doesn't update `document.documentElement.lang` (wrong for SR pronunciation / browser translation). ## Suggested fixes - Route custom controls through `ui/Button`/`ui/select`, or add `focus-visible:ring-3 focus-visible:ring-ring/50 outline-none` to them and the NavLinks. - On route change, focus the `<main>` heading (`tabIndex={-1}`); add a "skip to content" link as the first focusable element. - Either drop the tab ARIA roles in favor of an honest `<button aria-pressed>` filter group, or implement the full tab pattern; wrap lang switch in `role="group" aria-label`; ensure inactive contrast ≥ AA. - Sync `document.documentElement.lang` on language change. _Source: frontend UX/design audit, 2026-06-06._
Author
Owner

Done — merged to main (d082836).

  • Focus-visible rings — a shared focusRing (outline-none focus-visible:ring-3 focus-visible:ring-ring/50, matching the kit, keyboard-only) on the five remaining bare controls: lang-switch, theme-switch, search visibility chips, the field-list row button, and the authority kind links. (The four raw <select>s and sidebar links already gained rings in #51/#49.)
  • Skip link + route focus — a "Skip to content" link is now the first focusable element (sr-only until focused, jumps to #main-content); <main> is id="main-content" tabIndex={-1} and receives focus on every route change (skipping the initial mount so deep-links don't yank focus). Keyboard/SR users no longer tab through the sidebar on every navigation.
  • Honest semantics — the authority "tabs" were router links faking role="tab"/aria-selected with no keyboard tab-contract; they're now an honest <nav aria-label> of NavLinks using native aria-current="page". The lang switch is wrapped in a labelled role="group".
  • <html lang> sync — a single i18n.on("languageChanged") listener sets document.documentElement.lang to the active sv/en (covers the switcher, the config default, and startup), so SR pronunciation / browser translation are correct.

New i18n keys common.language + common.skipToContent (en/sv parity, guarded by the #60 parity test); 226 tests green; typecheck/lint/build/check:size (215.5 KB gz)/check:colors clean; no codename; no new dependency.

Follow-ups (out of scope): a full ARIA tab-widget is intentionally not used (these are navigation, not a tab panel); automated axe/a11y scanning in CI; an aria-live route announcer (focusing <main>/the per-route <h1> covers the core need).

Done — merged to `main` (`d082836`). - **Focus-visible rings** — a shared `focusRing` (`outline-none focus-visible:ring-3 focus-visible:ring-ring/50`, matching the kit, keyboard-only) on the five remaining bare controls: lang-switch, theme-switch, search visibility chips, the field-list row button, and the authority kind links. (The four raw `<select>`s and sidebar links already gained rings in #51/#49.) - **Skip link + route focus** — a "Skip to content" link is now the first focusable element (sr-only until focused, jumps to `#main-content`); `<main>` is `id="main-content" tabIndex={-1}` and receives focus on every route change (skipping the initial mount so deep-links don't yank focus). Keyboard/SR users no longer tab through the sidebar on every navigation. - **Honest semantics** — the authority "tabs" were router links faking `role="tab"`/`aria-selected` with no keyboard tab-contract; they're now an honest `<nav aria-label>` of `NavLink`s using native `aria-current="page"`. The lang switch is wrapped in a labelled `role="group"`. - **`<html lang>` sync** — a single `i18n.on("languageChanged")` listener sets `document.documentElement.lang` to the active `sv`/`en` (covers the switcher, the config default, and startup), so SR pronunciation / browser translation are correct. New i18n keys `common.language` + `common.skipToContent` (en/sv parity, guarded by the #60 parity test); 226 tests green; typecheck/lint/build/check:size (215.5 KB gz)/check:colors clean; no codename; no new dependency. Follow-ups (out of scope): a full ARIA tab-widget is intentionally not used (these are navigation, not a tab panel); automated axe/a11y scanning in CI; an `aria-live` route announcer (focusing `<main>`/the per-route `<h1>` covers the core need).
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#52