docs(plans): a11y focus/route/skip/semantics — 2-task plan (#52)
This commit is contained in:
@@ -0,0 +1,217 @@
|
|||||||
|
# Accessibility — Focus, Route Management, Honest Semantics — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Give every custom control a keyboard focus ring, make the authority "tabs" + lang switch honest, add a skip link + route focus management, and sync `<html lang>` on language change.
|
||||||
|
|
||||||
|
**Architecture:** A shared `focusRing` class is applied to the five bare controls. Authority tabs become honest `NavLink`s (`aria-current`), the lang switch gains a `role="group"`. The app-shell adds a skip link, a focusable `<main>`, and a route-change focus effect. A single `i18n.on("languageChanged")` listener syncs `document.documentElement.lang`.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, react-router 7 (`NavLink`/`useLocation`), react-i18next, Tailwind v4, Vitest + RTL. Test runner: `pnpm test` (single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (2 new keys); app source double-quote+semicolon; token classes only (`focus-visible:ring-ring` is a token); `focus-visible:` (not `:focus`) so mouse clicks don't ring.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-08-a11y-focus-design.md`
|
||||||
|
|
||||||
|
**Key facts (from code):**
|
||||||
|
- Bare controls lacking a ring: `lang-switch.tsx` (2 buttons, no `type`, inactive `text-muted-foreground`), `theme-switch.tsx` (3 icon buttons, `cn(...)`), `search-panel.tsx` facet chips (`className={\`rounded-md px-2 py-0.5 ${active ? … : "border"}\`}`), `field-list.tsx` row `<button className="flex flex-1 items-center gap-2 text-left">`, `authorities-page.tsx` tab `NavLink`s.
|
||||||
|
- `authorities-page.tsx`: `<div role="tablist" className="mb-3 flex gap-2">` + `NavLink ... role="tab" aria-selected={k === currentKind}`. `NavLink` adds `aria-current="page"` to the active link by default.
|
||||||
|
- `app-shell.tsx`: `<main className="flex-1 overflow-hidden"><Outlet/></main>`, no id/tabIndex/skip link/route effect.
|
||||||
|
- `i18n/index.ts`: i18n init; `i18n.language`; no html-lang sync.
|
||||||
|
- `ui/button.tsx` ring: `focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50`.
|
||||||
|
- Tests to update: `authorities.test.tsx` ("kind tabs link…" + "aria-selected…" use `getByRole("tab")`/`aria-selected`); `app-shell.test.tsx` (`tree()` has `/objects` + `/login`; nav + lang tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Focus rings + honest control semantics
|
||||||
|
|
||||||
|
**Files:** `web/src/lib/focus-ring.ts` (new), `web/src/shell/lang-switch.tsx`, `web/src/shell/theme-switch.tsx`, `web/src/search/search-panel.tsx`, `web/src/fields/field-list.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/authorities/authorities.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `web/src/lib/focus-ring.ts`:**
|
||||||
|
```ts
|
||||||
|
export const focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: i18n** — add `"language"` to the `common` namespace (en "Language" / sv "Språk"), both locales (parity).
|
||||||
|
|
||||||
|
- [ ] **Step 3: lang-switch.tsx** — wrap in a labelled group + add `type="button"` + ring (import `useTranslation`, `focusRing`, `cn`):
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useLocale } from "../i18n/use-locale";
|
||||||
|
import { focusRing } from "../lib/focus-ring";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function LangSwitch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { locale, setLocale } = useLocale();
|
||||||
|
const base = locale.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={t("common.language")} className="flex gap-1 text-xs">
|
||||||
|
{(["sv", "en"] as const).map((lng) => (
|
||||||
|
<button
|
||||||
|
key={lng}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale(lng)}
|
||||||
|
aria-pressed={base === lng}
|
||||||
|
className={cn("rounded-sm px-1", focusRing, base === lng ? "font-bold" : "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{lng.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: theme-switch.tsx** — add `focusRing` to the button `cn(...)`: change the `cn("rounded-md p-1 transition-colors", active ? … : …)` to include `focusRing` as a class arg. Import `focusRing`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: search-panel.tsx** — the facet chip `<button>` className: add `focusRing`. Use `cn` (import it) or append the string:
|
||||||
|
`className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}`. Import `focusRing` + `cn`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: field-list.tsx** — the row `<button className="flex flex-1 items-center gap-2 text-left">`: add `rounded-sm` + `focusRing` (import `focusRing` + `cn`): `className={cn("flex flex-1 items-center gap-2 rounded-sm text-left", focusRing)}`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: authorities-page.tsx — honest semantics + ring.** Replace the `<div role="tablist">` block:
|
||||||
|
```tsx
|
||||||
|
<nav aria-label={t("nav.authorities")} className="mb-3 flex gap-2">
|
||||||
|
{KINDS.map((k) => (
|
||||||
|
<NavLink
|
||||||
|
key={k}
|
||||||
|
to={`/authorities/${k}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`authorities.${k}`)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
Drop `role="tab"` + `aria-selected` (NavLink applies `aria-current="page"` to the active link automatically). Import `focusRing` + `cn`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Update `authorities.test.tsx`** — the two tab tests:
|
||||||
|
- "kind tabs link to the other kinds": `findByRole("tab", { name: /place/i })` → `findByRole("link", { name: /place/i })` (still assert `href="/authorities/place"`).
|
||||||
|
- "aria-selected…": rename to active-kind via `aria-current`: `expect(await screen.findByRole("link", { name: /^person$/i })).toHaveAttribute("aria-current", "page");` and `expect(screen.getByRole("link", { name: /^place$/i })).not.toHaveAttribute("aria-current");`.
|
||||||
|
(Confirm no link-name ambiguity — the page renders only the 3 kind links + the breadcrumb/PageTitle; if the harness includes other "person/place"-named links, scope with `within`. Don't weaken.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: Verify (vitest ONCE):** `cd web && pnpm vitest run src/authorities src/shell src/search src/fields && pnpm typecheck && pnpm lint && pnpm check:colors`. PASS. (The ring classes are token-based → check:colors clean. The other tests must stay green.)
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/focus-ring.ts web/src/shell/lang-switch.tsx web/src/shell/theme-switch.tsx web/src/search/search-panel.tsx web/src/fields/field-list.tsx web/src/authorities/authorities-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/authorities/authorities.test.tsx
|
||||||
|
git commit -m "feat(web): focus-visible rings on custom controls; honest authority links + lang group (#52)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: Skip link + route focus + html lang sync
|
||||||
|
|
||||||
|
**Files:** `web/src/shell/app-shell.tsx`, `web/src/i18n/index.ts`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/shell/app-shell.test.tsx`, `web/src/i18n/i18n.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add `"skipToContent"` to `common` (en "Skip to content" / sv "Hoppa till innehåll"), both locales (parity).
|
||||||
|
|
||||||
|
- [ ] **Step 2: app-shell.tsx — skip link + focusable main + route focus.**
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
// …existing imports…
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
|
const didMount = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didMount.current) {
|
||||||
|
didMount.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainRef.current?.focus();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<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:border focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:ring-3 focus:ring-ring/50"
|
||||||
|
>
|
||||||
|
{t("common.skipToContent")}
|
||||||
|
</a>
|
||||||
|
<Sidebar />
|
||||||
|
<BreadcrumbProvider>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
|
<Breadcrumb />
|
||||||
|
<HeaderSearch />
|
||||||
|
<ThemeSwitch />
|
||||||
|
<LangSwitch />
|
||||||
|
<UserMenu />
|
||||||
|
</header>
|
||||||
|
<main ref={mainRef} id="main-content" tabIndex={-1} className="flex-1 overflow-hidden outline-none">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BreadcrumbProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Verify `sr-only`/`focus:not-sr-only` exist in this Tailwind v4 setup (they're standard utilities; if the focus reveal doesn't work, use an explicit visually-hidden style and confirm by running the test). The skip link is the FIRST focusable element.
|
||||||
|
|
||||||
|
- [ ] **Step 3: i18n/index.ts — html lang sync.** After the `i18n.init(...)` call, add:
|
||||||
|
```ts
|
||||||
|
function syncHtmlLang(lng: string) {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.documentElement.lang = lng.startsWith("sv") ? "sv" : "en";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.on("languageChanged", syncHtmlLang);
|
||||||
|
syncHtmlLang(i18n.language);
|
||||||
|
```
|
||||||
|
(Place before `export default i18n;`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tests.**
|
||||||
|
- **app-shell.test.tsx — skip link + route focus.** Add:
|
||||||
|
- skip link: `expect(screen.getByRole("link", { name: /skip to content/i })).toHaveAttribute("href", "#main-content");` and the `<main>` has `id="main-content"` (query `document.getElementById("main-content")` → truthy, `tabIndex === -1`).
|
||||||
|
- route focus: extend `tree()` with a second route under `<AppShell>` (e.g. `<Route path="/fields" element={<div>fields outlet</div>} />`); render at `/objects`, click the sidebar **Fields** link (`screen.getByRole("link", { name: /fields/i })`), `await screen.findByText("fields outlet")`, then assert `document.activeElement === document.getElementById("main-content")` (the route change focused main). (Initial mount must NOT focus main — optionally assert activeElement is body/not-main right after the first render.)
|
||||||
|
- **i18n.test.tsx — html lang.** Add a test: after `await i18n.changeLanguage("sv")`, `expect(document.documentElement.lang).toBe("sv")`; after `await i18n.changeLanguage("en")`, `toBe("en")`. (The file already toggles language; the `afterEach` resets to en, so assert within the test.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: FULL GATE (run tests EXACTLY ONCE):**
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||||
|
```
|
||||||
|
All green. Report test totals, largest chunk, check:colors line. (Storybook-cache flake remedy if needed: `rm -rf node_modules/.cache/storybook node_modules/.vite`, re-run ONCE.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Codename + status:**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual smoke (recommended).** `pnpm dev`: Tab from the top → "Skip to content" appears first and jumps focus to the content; every custom control shows a focus ring on keyboard focus; navigating routes moves focus to the content region; the authority kind links read as links with the current one marked; switching to SV sets `<html lang="sv">` (check devtools).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/app-shell.tsx web/src/i18n/index.ts web/src/i18n/en.json web/src/i18n/sv.json web/src/shell/app-shell.test.tsx web/src/i18n/i18n.test.tsx
|
||||||
|
git commit -m "feat(web): skip link + route focus management + html lang sync (#52)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:** focusRing + 5 controls (T1 S1,S3–S7); lang group + authority honest links (T1 S3,S7); i18n common.language/skipToContent (T1 S2, T2 S1); skip link + focusable main + route focus (T2 S2); html lang sync (T2 S3); tests for tabs→links, skip link, route focus, html lang (T1 S8, T2 S4); gate (T2 S5). Acceptance criteria 1–5 mapped. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** the `sr-only`/`focus:not-sr-only` reveal is "verify it works by running" (a real validation, with an explicit fallback), not a TODO. Test steps name exact queries + the harness extension. No vague steps. ✓
|
||||||
|
|
||||||
|
**Type/consistency:** `focusRing` (string) defined in T1 S1, imported by all 5 controls + applied via `cn`; `NavLink` `aria-current` (native) replaces `role="tab"`/`aria-selected` consistently in the component + the test; `mainRef`/`didMount` refs + `useLocation().pathname` dependency consistent. ✓
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new dependency; 2 new i18n keys (`common.language`, `common.skipToContent`), en+sv.
|
||||||
|
- `focus-visible:` (keyboard) vs `:focus` — rings only on keyboard focus.
|
||||||
|
- `<main tabIndex={-1}>` + `outline-none` is programmatically focusable but not in the tab order and shows no container outline; the skip link + route effect both target it.
|
||||||
|
- The i18n parity test (#60) will guard the 2 new keys.
|
||||||
Reference in New Issue
Block a user