# 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).
