Files

13 KiB
Raw Permalink Blame History

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 NavLinks (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.tsxrow, authorities-page.tsxtabNavLink`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:
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):

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:

<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

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.

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:
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):

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:
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

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,S3S7); 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 15 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.