merge: a11y — focus rings, route focus, skip link, honest semantics, html lang (#52)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
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.
|
||||
@@ -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).
|
||||
@@ -13,8 +13,10 @@ import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
import { AuthorityRow } from "./authority-row";
|
||||
import { byLabel } from "../lib/sort";
|
||||
import { labelText } from "../lib/labels";
|
||||
import { focusRing } from "../lib/focus-ring";
|
||||
import { useDocumentTitle } from "../lib/use-document-title";
|
||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
@@ -64,21 +66,19 @@ export function AuthoritiesPage() {
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
<PageTitle className="mb-3">{t("nav.authorities")}</PageTitle>
|
||||
<div role="tablist" className="mb-3 flex gap-2">
|
||||
<nav aria-label={t("nav.authorities")} className="mb-3 flex gap-2">
|
||||
{KINDS.map((k) => (
|
||||
<NavLink
|
||||
key={k}
|
||||
to={`/authorities/${k}`}
|
||||
role="tab"
|
||||
aria-selected={k === currentKind}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-1 text-sm ${isActive ? "bg-primary text-primary-foreground" : "border"}`
|
||||
cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")
|
||||
}
|
||||
>
|
||||
{t(`authorities.${k}`)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
|
||||
@@ -33,13 +33,13 @@ test("lists authorities for the kind and creates one", async () => {
|
||||
|
||||
test("kind tabs link to the other kinds", async () => {
|
||||
renderApp(tree(), { route: "/authorities/person" });
|
||||
expect(await screen.findByRole("tab", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
|
||||
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
|
||||
});
|
||||
|
||||
test("aria-selected is on the tab element and reflects the active kind", async () => {
|
||||
test("aria-current marks the active kind link", async () => {
|
||||
renderApp(tree(), { route: "/authorities/person" });
|
||||
expect(await screen.findByRole("tab", { name: /^person$/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: /^place$/i })).toHaveAttribute("aria-selected", "false");
|
||||
expect(await screen.findByRole("link", { name: /^person$/i })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("link", { name: /^place$/i })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
test("create without EN label shows required alert and does not POST", async () => {
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { components } from "../api/schema";
|
||||
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
|
||||
import { labelText } from "../lib/labels";
|
||||
import { byLabel, compareStrings } from "../lib/sort";
|
||||
import { focusRing } from "../lib/focus-ring";
|
||||
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
@@ -86,7 +88,7 @@ export function FieldList({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
className={cn("flex flex-1 items-center gap-2 rounded-sm text-left", focusRing)}
|
||||
aria-pressed={def.key === selectedKey}
|
||||
onClick={() => onSelect(def)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches" },
|
||||
"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches", "language": "Language", "skipToContent": "Skip to content" },
|
||||
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" },
|
||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
|
||||
|
||||
@@ -33,3 +33,10 @@ test("switches language at runtime", async () => {
|
||||
});
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("Föremål");
|
||||
});
|
||||
|
||||
test("syncs document.documentElement.lang on language change", async () => {
|
||||
await i18n.changeLanguage("sv");
|
||||
expect(document.documentElement.lang).toBe("sv");
|
||||
await i18n.changeLanguage("en");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
@@ -15,4 +15,13 @@ void i18n.use(initReactI18next).init({
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
function syncHtmlLang(lng: string) {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = lng.startsWith("sv") ? "sv" : "en";
|
||||
}
|
||||
}
|
||||
|
||||
i18n.on("languageChanged", syncHtmlLang);
|
||||
syncHtmlLang(i18n.language);
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar" },
|
||||
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar", "language": "Språk", "skipToContent": "Hoppa till innehåll" },
|
||||
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" },
|
||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
@@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSearch, HttpError } from "../api/queries";
|
||||
import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||
import { focusRing } from "../lib/focus-ring";
|
||||
import { SearchResultRow } from "./search-result-row";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
@@ -71,7 +73,7 @@ export function SearchPanel() {
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setVisibility(value)}
|
||||
className={`rounded-md px-2 py-0.5 ${active ? "bg-primary text-primary-foreground" : "border"}`}
|
||||
className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}
|
||||
>
|
||||
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,7 @@ function tree() {
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/objects" element={<div>objects outlet</div>} />
|
||||
<Route path="/fields" element={<div>fields outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
</Routes>
|
||||
@@ -36,6 +37,35 @@ test("shows active and disabled nav and renders the outlet", async () => {
|
||||
expect(screen.getByRole("link", { name: /fields/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a skip link targeting the focusable main region", async () => {
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
await screen.findByText("objects outlet");
|
||||
|
||||
expect(screen.getByRole("link", { name: /skip to content/i })).toHaveAttribute(
|
||||
"href",
|
||||
"#main-content",
|
||||
);
|
||||
|
||||
const main = document.getElementById("main-content");
|
||||
expect(main).toBeTruthy();
|
||||
expect(main?.tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
test("moves focus to the main region on route change", async () => {
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
await screen.findByText("objects outlet");
|
||||
|
||||
// Initial mount must NOT steal focus to main.
|
||||
expect(document.activeElement).not.toBe(document.getElementById("main-content"));
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /fields/i }));
|
||||
await screen.findByText("fields outlet");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(document.getElementById("main-content")),
|
||||
);
|
||||
});
|
||||
|
||||
test("language switch toggles to Swedish", async () => {
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
await userEvent.click(await screen.findByRole("button", { name: "SV" }));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LangSwitch } from "./lang-switch";
|
||||
import { ThemeSwitch } from "./theme-switch";
|
||||
@@ -9,8 +11,27 @@ import { HeaderSearch } from "./header-search";
|
||||
import { UserMenu } from "./user-menu";
|
||||
|
||||
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">
|
||||
@@ -21,7 +42,7 @@ export function AppShell() {
|
||||
<LangSwitch />
|
||||
<UserMenu />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<main ref={mainRef} id="main-content" tabIndex={-1} className="flex-1 overflow-hidden outline-none">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
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 className="flex gap-1 text-xs">
|
||||
<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={base === lng ? "font-bold" : "text-muted-foreground"}
|
||||
className={cn("rounded-sm px-1", focusRing, base === lng ? "font-bold" : "text-muted-foreground")}
|
||||
>
|
||||
{lng.toUpperCase()}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useTheme } from "../theme/use-theme";
|
||||
import type { Theme } from "../theme/theme";
|
||||
import { focusRing } from "../lib/focus-ring";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [
|
||||
@@ -29,6 +30,7 @@ export function ThemeSwitch() {
|
||||
title={t(`theme.${value}`)}
|
||||
className={cn(
|
||||
"rounded-md p-1 transition-colors",
|
||||
focusRing,
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
|
||||
Reference in New Issue
Block a user