From 45691918a9b6ea8fde7ffe34a9f4791e18077d2d Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Sun, 26 Apr 2026 18:17:10 +0900 Subject: [PATCH 1/2] fix(admin/spa): drop redundant abort guard and add Modal a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the review of #649: 1. useApi.ts: removed the `if (ctrl.signal.aborted) return;` check inside the catch handler. The cleanup function sets `cancelled = true` *before* calling `ctrl.abort()`, so any abort path is already covered by the preceding `cancelled` check. The second guard was harmless belt-and-suspenders dead code; the comment now records why one check is enough. 2. Modal.tsx: added the WAI-ARIA dialog contract (`role="dialog"`, `aria-modal="true"`, `aria-labelledby` pointing at the title) and a focus trap: - On open: store the previously-focused element, then focus the first focusable inside the dialog so keyboard users do not stay stranded on the page underneath. Empty dialogs fall back to focusing the dialog container itself with tabindex=-1. - Tab / Shift+Tab cycle within the dialog instead of escaping to the page underneath. - On close: restore focus to whoever opened the dialog (skipped when that element has been unmounted, e.g. the dialog deleted the row whose button opened it). - Escape and backdrop click stay wired through the existing `busy` gate so a half-applied operation cannot be dismissed. Implementation is vanilla DOM querying — focus-trap-react / react-focus-lock would have added 5–7 KB gzip for what is a ~40 line vanilla solution. Bundle delta from this PR is +0.4 KB gzip (60.23 -> 60.59 KB). Verified with `npm run build` and `npm run lint` (tsc --strict --noUnusedLocals --noUncheckedSideEffectImports), and `go build ./...`. Stacked on top of #649 (feat/admin-dashboard-spa); merge that one first, then this rebases cleanly onto main. --- web/admin/src/components/Modal.tsx | 103 +++++++++++++++++++++++++++-- web/admin/src/lib/useApi.ts | 4 +- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/web/admin/src/components/Modal.tsx b/web/admin/src/components/Modal.tsx index d83bef603..f5cad1269 100644 --- a/web/admin/src/components/Modal.tsx +++ b/web/admin/src/components/Modal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useId, useRef } from "react"; import type { ReactNode } from "react"; interface ModalProps { @@ -15,14 +15,69 @@ interface ModalProps { // Lightweight modal: no portal, no animation library. shadcn/ui's // Dialog primitive would pull in @radix-ui/react-dialog (~10KB gzip) // and we only need a single confirmation/edit surface per page. +// +// Accessibility (per the WAI-ARIA Authoring Practices for dialogs): +// - role="dialog" + aria-modal="true" so AT announce the dialog +// and treat the rest of the page as inert. +// - aria-labelledby on the title
so the dialog is named. +// - Focus is moved into the dialog on open and restored to the +// previously-focused element on close. +// - Tab and Shift+Tab are wrapped to keep focus inside the dialog +// until it closes, so keyboard users cannot accidentally tab to +// the page underneath. export function Modal({ title, open, onClose, children, busy }: ModalProps) { + const dialogRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const titleId = useId(); + useEffect(() => { if (!open) return; + + previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null; + // Focus the first focusable element so keyboard users land + // inside the dialog instead of the page underneath. Run on the + // next tick so the dialog DOM exists and any autofocus has had + // a chance to settle. + queueMicrotask(() => focusFirstFocusable(dialogRef.current)); + const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) onClose(); + if (e.key === "Escape" && !busy) { + onClose(); + return; + } + if (e.key !== "Tab") return; + const root = dialogRef.current; + if (!root) return; + const focusables = focusableElements(root); + if (focusables.length === 0) { + // Empty dialog: keep Tab from leaving via the page. + e.preventDefault(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } }; + window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + // Restore focus to whoever opened the dialog. Guard against + // the trigger being unmounted (e.g. the dialog deleted the + // row whose button opened it) by checking isConnected. + const restore = previouslyFocusedRef.current; + previouslyFocusedRef.current = null; + if (restore && restore.isConnected) { + restore.focus(); + } + }; }, [open, busy, onClose]); if (!open) return null; @@ -33,9 +88,15 @@ export function Modal({ title, open, onClose, children, busy }: ModalProps) { if (e.target === e.currentTarget && !busy) onClose(); }} > -
+
-
{title}
+
{title}