642c5b7322
Add the a11y foundation and bring login, lobby, and the in-game shell to WCAG 2.2 AA: - Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus trap + restore) and restoreFocus (menu focus restore) actions, the --color-focus visible ring. - In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape + focus restore (account / view / turn-navigator / map-toggles / bottom-tabs); mail compose as a role=dialog modal with a focus trap. - login / lobby / lobby-create: skip link + main landmark, field labels, role=alert / role=status live regions. - Map canvas: aria-label naming it a visual overview, with its data reachable by keyboard via the sidebar inspector and tables (accessible alternative; in-canvas keyboard nav deferred). Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts covers the skip link, menu Escape+restore, and tab roving. Adds @axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2 done in ui/PLAN-finalize.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
71 lines
2.0 KiB
TypeScript
71 lines
2.0 KiB
TypeScript
/**
|
|
* Focus management for modal dialogs.
|
|
*
|
|
* `trapFocus` is a Svelte action for an element with `role="dialog"` and
|
|
* `aria-modal="true"`. On mount it remembers the currently-focused
|
|
* element, moves focus into the dialog, and keeps Tab / Shift+Tab cycling
|
|
* within it; on destroy it restores focus to the original element. ESC
|
|
* handling stays with the component (it owns the open/close state).
|
|
*
|
|
* Initial focus goes to the element marked `data-autofocus`, else the
|
|
* first focusable element, else the dialog node itself.
|
|
*/
|
|
|
|
const FOCUSABLE_SELECTOR = [
|
|
"a[href]",
|
|
"button:not([disabled])",
|
|
"input:not([disabled])",
|
|
"select:not([disabled])",
|
|
"textarea:not([disabled])",
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
].join(",");
|
|
|
|
function focusableWithin(node: HTMLElement): HTMLElement[] {
|
|
return Array.from(
|
|
node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
}
|
|
|
|
/** Svelte action: trap and restore focus for a modal dialog node. */
|
|
export function trapFocus(node: HTMLElement): { destroy(): void } {
|
|
const previouslyFocused =
|
|
document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: null;
|
|
|
|
function onKeydown(event: KeyboardEvent): void {
|
|
if (event.key !== "Tab") return;
|
|
const items = focusableWithin(node);
|
|
if (items.length === 0) {
|
|
event.preventDefault();
|
|
node.focus();
|
|
return;
|
|
}
|
|
const first = items[0];
|
|
const last = items[items.length - 1];
|
|
const active = document.activeElement;
|
|
if (event.shiftKey && active === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && active === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
|
|
const initial =
|
|
node.querySelector<HTMLElement>("[data-autofocus]") ??
|
|
focusableWithin(node)[0] ??
|
|
node;
|
|
initial.focus();
|
|
|
|
node.addEventListener("keydown", onKeydown);
|
|
|
|
return {
|
|
destroy(): void {
|
|
node.removeEventListener("keydown", onKeydown);
|
|
previouslyFocused?.focus();
|
|
},
|
|
};
|
|
}
|