From 996ba8716f19352ab5ca8e973f30f92dce73746b Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 4 Jul 2026 13:45:53 +1000 Subject: [PATCH] =?UTF-8?q?a11y:=20real=20modal=20=E2=80=94=20focus=20trap?= =?UTF-8?q?,=20Tab=20cycle,=20Escape,=20focus=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/index.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index 86cd2e9..782fde3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -176,6 +176,9 @@ export function AgentDock({ const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const scrollRef = useRef(null); + // The slide-over panel — target for the focus trap while `open`. + const panelRef = useRef(null); + useFocusTrap(open, panelRef, () => setOpen(false)); useEffect(() => { let cancelled = false; @@ -336,6 +339,7 @@ export function AgentDock({ onClick={() => setOpen(false)} />
@@ -523,6 +527,89 @@ export function AgentDock({ ); } +/* ------------------------------------------------------------------ */ +/* Focus management */ +/* ------------------------------------------------------------------ */ + +/* Descendants that can receive keyboard focus. Kept in sync with the + * focus-trap's Tab cycling below. */ +const FOCUSABLE_SELECTOR = + 'button, [href], input, textarea, select, [tabindex]:not([tabindex="-1"])'; + +/** Small self-contained focus trap for a modal container — no external + * dependency, so the dock stays generic. While `active`, it moves focus + * into `containerRef`, keeps Tab / Shift+Tab cycling inside it, routes + * Escape to `onEscape`, and restores focus to whatever was focused before + * activation once the panel closes/unmounts. */ +function useFocusTrap( + active: boolean, + containerRef: React.RefObject, + onEscape: () => void, +) { + // Track the latest onEscape without re-running the effect each render. + const onEscapeRef = useRef(onEscape); + onEscapeRef.current = onEscape; + + useEffect(() => { + if (!active) return; + const container = containerRef.current; + if (!container) return; + + // Remember where focus was so we can hand it back on close. + const previouslyFocused = document.activeElement as HTMLElement | null; + + const focusables = () => + Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR), + ).filter((el) => !el.hasAttribute("disabled")); + + // Move focus into the panel — first focusable, or the container itself. + const first = focusables()[0]; + if (first) { + first.focus(); + } else { + container.tabIndex = -1; + container.focus(); + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onEscapeRef.current(); + return; + } + if (e.key !== "Tab") return; + const items = focusables(); + if (items.length === 0) { + // Nothing to cycle — keep focus pinned inside the panel. + e.preventDefault(); + return; + } + const firstEl = items[0]; + const lastEl = items[items.length - 1]; + const activeEl = document.activeElement; + if (e.shiftKey) { + // Shift+Tab at the first (or focus escaped the panel) → wrap to last. + if (activeEl === firstEl || !container.contains(activeEl)) { + e.preventDefault(); + lastEl.focus(); + } + } else if (activeEl === lastEl || !container.contains(activeEl)) { + // Tab at the last (or focus escaped the panel) → wrap to first. + e.preventDefault(); + firstEl.focus(); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + // Restore focus to the trigger that opened the panel. + previouslyFocused?.focus?.(); + }; + }, [active, containerRef]); +} + function IconButton({ label, dataAction,