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

8.4 KiB

Accessibility — Focus, Route Management, Honest Semantics — Design

Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #52.

Context

Keyboard-driven fast data entry is a stated goal and this is a public-institution tool, but several custom controls drop the ui/* kit's focus ring, route changes leave focus stranded, there's no skip link, the authority "tabs" announce tab semantics they don't fulfill, the lang switch lacks a group label, and <html lang> never updates.

Current state (post #49/#51/#54/#59): the four raw <select>s are now ui/Select (have rings); sidebar NavLinks + collapse button have rings; reference-data Edit/Delete are ui/Button (rings). The remaining bare controls without a focus ring: lang-switch.tsx buttons, theme-switch.tsx buttons, search-panel.tsx visibility facet chips, field-list.tsx row button, and the authorities-page.tsx tab NavLinks. ui/Button's ring = focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50. app-shell.tsx: <main className="flex-1 overflow-hidden"> (no id/tabIndex), no skip link, no route-focus effect. Authority tabs are router NavLinks with role="tablist"/role="tab"/aria-selected but no roving tabindex/arrow/aria-controls. Lang switch: two aria-pressed buttons, no role="group"/aria-label. index.html hardcodes lang="en"; no documentElement.lang sync (i18n init in i18n/index.ts, changes via use-locale.ts setLocale + config-provider default-language effect).

Decisions (from brainstorming)

  1. Shared focusRing class on the 5 bare controls.
  2. Authority tabs → honest navigation (NavLink + aria-current, drop tab roles); lang switch → role="group" + aria-label.
  3. Skip link + focus <main> on route change.
  4. Sync document.documentElement.lang on every language change.

Components

web/src/lib/focus-ring.ts (new)

export const focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50"; — applied via cn(...)/template to bare controls (they already have rounded-md so the ring shape is correct).

A. Focus rings (5 controls)

  • lang-switch.tsx: each <button> gets type="button" + focusRing (+ rounded-sm px-1 so the ring has shape).
  • theme-switch.tsx: each <button> className gains focusRing (already rounded-md).
  • search-panel.tsx: each facet <button> className gains focusRing (already rounded-md).
  • field-list.tsx: the row <button className="flex flex-1 …"> gains focusRing + rounded-sm.
  • authorities-page.tsx: the tab NavLinks gain focusRing (with the semantics change below).

B. Honest semantics

  • Authority tabs (authorities-page.tsx): replace the <div role="tablist"> + role="tab" + aria-selected with a <nav aria-label={t("nav.authorities")}> containing the NavLinks; rely on NavLink's native aria-current="page" for the active state (drop role/aria-selected). Keep the segmented styling (active bg-primary text-primary-foreground, else border) + add focusRing.
  • Lang switch (lang-switch.tsx): wrap the buttons in <div role="group" aria-label={t("common.language")}>; keep aria-pressed; inactive stays text-muted-foreground (token) — the ring + font-bold active + group label make state/affordance clear.
  • Skip link as the FIRST focusable element (before <Sidebar/>): a visually-hidden-until-focused anchor — <a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:ring-3 focus:ring-ring/50">{t("common.skipToContent")}</a>. (If sr-only/not-sr-only utilities aren't available in this Tailwind v4 setup, use an explicit visually-hidden pattern; verify by running.)
  • <main id="main-content" tabIndex={-1} className="flex-1 overflow-hidden …">.
  • A focus effect: const location = useLocation(); + const mainRef = useRef<HTMLElement>(null); + useEffect(() => { mainRef.current?.focus(); }, [location.pathname]); — but skip the initial mount (so a deep-link load doesn't yank focus): track a didMount ref, focus only on subsequent pathname changes. <main ref={mainRef} …>. (tabIndex={-1} makes <main> programmatically focusable without adding it to the tab order; outline-none to avoid an outline on the container.)

D. <html lang> sync (i18n/index.ts)

After i18n.init, register once:

function syncHtmlLang(lng: string) {
  if (typeof document !== "undefined") {
    document.documentElement.lang = lng.startsWith("sv") ? "sv" : "en";
  }
}
i18n.on("languageChanged", syncHtmlLang);
syncHtmlLang(i18n.language);

This covers the switcher (setLocalechangeLanguage), the config default-language effect, and startup — a single source of truth.

i18n (en + sv parity)

  • common.language = "Language" / "Språk"
  • common.skipToContent = "Skip to content" / "Hoppa till innehåll"

Data flow / accessibility

Tab order: skip link → sidebar → header → main content. Activating the skip link (or any route change) moves focus into <main> (which contains the route's <h1> from #57). aria-current="page" marks the active authority kind + nav route. documentElement.lang reflects the active language for SR pronunciation / browser translation.

Error handling / edges

  • focusRing uses only focus-visible: (keyboard focus), not :focus, so mouse clicks don't show the ring.
  • Route-focus effect skips the initial mount; only fires on pathname change (covers nav + master-detail open; query-param-only changes like the objects filter/pagination don't change pathname, so no refocus there).
  • <main tabIndex={-1}> is not in the tab order (negative) — only programmatically focusable.
  • documentElement.lang guarded for non-DOM (tests/SSR).
  • The authority tab role change: aria-current="page" is the honest active indicator; tests that queried role="tab"/aria-selected are updated to role="link" + aria-current.

Testing

  • lang-switch: the buttons are within a role="group" named by common.language; each is a keyboard-focusable button with aria-pressed.
  • authority tabs: the kind links are role="link" (not tab); the active one has aria-current="page"; navigating still works. Update the existing authorities.test.tsx assertions (tab → link, aria-selected → aria-current) — don't weaken (still assert the active kind + href).
  • skip link: an anchor to #main-content is the first focusable element; <main> has id="main-content" + tabIndex={-1}.
  • route focus: navigating to a new route focuses <main> (assert document.activeElement is the main element after a nav, or that main received focus). Mirror the app-shell test harness.
  • html lang sync: switching the locale sets document.documentElement.lang to "sv"/"en" (drive via the lang switch or i18n.changeLanguage; assert document.documentElement.lang).
  • The i18n parity test (#60) covers the 2 new keys automatically.
  • Gate: typecheck/lint/test/build/check:size/check:colors; en/sv parity; no codename; no new dependency. check:colors: focusRing uses token utilities (ring-ring), not raw palette — OK.

Acceptance criteria

  1. All five bare controls (lang-switch, theme-switch, search facet chips, field-list row, authority kind links) show a keyboard focus-visible ring matching the kit.
  2. A skip-to-content link is the first focusable element; <main id="main-content" tabIndex={-1}> receives focus on route change (not on initial mount).
  3. Authority kind links no longer claim role="tab"/aria-selected; they use aria-current="page" in a labelled <nav>. The lang switch is a labelled role="group".
  4. document.documentElement.lang updates to the active language on every language change.
  5. typecheck/lint/test/build/check:colors green; check:size reported; en/sv parity (2 new keys); no codename; no new dependency.

Out of scope → follow-ups

  • A full ARIA tab-widget (roving tabindex/arrows/tabpanel) for authorities — they're navigation, not a tab panel, so honest links are correct.
  • Automated axe/a11y scanning in CI; a broader sweep beyond the listed controls.
  • An aria-live route-announcement region (focusing <main>/the <h1> covers the core need).