docs(specs): a11y — focus rings, route focus, skip link, honest semantics, html lang (#52)

This commit is contained in:
2026-06-08 09:36:44 +02:00
parent 4c24f0387c
commit 1948d09d16
@@ -0,0 +1,135 @@
# 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
`NavLink`s 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 `NavLink`s 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 `NavLink`s; 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.
### 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>`.
(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:
```ts
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
- `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).