docs(specs): a11y — focus rings, route focus, skip link, honest semantics, html lang (#52)
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user