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)
- Shared
focusRingclass on the 5 bare controls. - Authority tabs → honest navigation (
NavLink+aria-current, drop tab roles); lang switch →role="group"+aria-label. - Skip link + focus
<main>on route change. - Sync
document.documentElement.langon 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>getstype="button"+focusRing(+rounded-sm px-1so the ring has shape).theme-switch.tsx: each<button>className gainsfocusRing(alreadyrounded-md).search-panel.tsx: each facet<button>className gainsfocusRing(alreadyrounded-md).field-list.tsx: the row<button className="flex flex-1 …">gainsfocusRing+rounded-sm.authorities-page.tsx: the tabNavLinks gainfocusRing(with the semantics change below).
B. Honest semantics
- Authority tabs (
authorities-page.tsx): replace the<div role="tablist">+role="tab"+aria-selectedwith a<nav aria-label={t("nav.authorities")}>containing theNavLinks; rely onNavLink's nativearia-current="page"for the active state (droprole/aria-selected). Keep the segmented styling (activebg-primary text-primary-foreground, elseborder) + addfocusRing. - Lang switch (
lang-switch.tsx): wrap the buttons in<div role="group" aria-label={t("common.language")}>; keeparia-pressed; inactive staystext-muted-foreground(token) — the ring +font-boldactive + group label make state/affordance clear.
C. Route focus + skip link (app-shell.tsx)
- 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>. (Ifsr-only/not-sr-onlyutilities 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 adidMountref, focus only on subsequentpathnamechanges.<main ref={mainRef} …>. (tabIndex={-1}makes<main>programmatically focusable without adding it to the tab order;outline-noneto 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 (setLocale → changeLanguage), 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
focusRinguses onlyfocus-visible:(keyboard focus), not:focus, so mouse clicks don't show the ring.- Route-focus effect skips the initial mount; only fires on
pathnamechange (covers nav + master-detail open; query-param-only changes like the objects filter/pagination don't changepathname, so no refocus there). <main tabIndex={-1}>is not in the tab order (negative) — only programmatically focusable.documentElement.langguarded for non-DOM (tests/SSR).- The authority tab role change:
aria-current="page"is the honest active indicator; tests that queriedrole="tab"/aria-selectedare updated torole="link"+aria-current.
Testing
- lang-switch: the buttons are within a
role="group"named bycommon.language; each is a keyboard-focusable button witharia-pressed. - authority tabs: the kind links are
role="link"(nottab); the active one hasaria-current="page"; navigating still works. Update the existingauthorities.test.tsxassertions (tab → link, aria-selected → aria-current) — don't weaken (still assert the active kind + href). - skip link: an anchor to
#main-contentis the first focusable element;<main>hasid="main-content"+tabIndex={-1}. - route focus: navigating to a new route focuses
<main>(assertdocument.activeElementis 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.langto "sv"/"en" (drive via the lang switch ori18n.changeLanguage; assertdocument.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:focusRinguses token utilities (ring-ring), not raw palette — OK.
Acceptance criteria
- All five bare controls (lang-switch, theme-switch, search facet chips, field-list row, authority
kind links) show a keyboard
focus-visiblering matching the kit. - 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). - Authority kind links no longer claim
role="tab"/aria-selected; they usearia-current="page"in a labelled<nav>. The lang switch is a labelledrole="group". document.documentElement.langupdates to the active language on every language change.typecheck/lint/test/build/check:colorsgreen;check:sizereported; 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-liveroute-announcement region (focusing<main>/the<h1>covers the core need).