From 646541245d88281a098fdbbc1e31390fd092a5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 22:15:50 +0200 Subject: [PATCH] fix(a11y): trap focus + restore + ring on onboarding tour Phase D design review HIGH cluster (H1, H2, H3) on the v1.4.15 B5 onboarding spotlight tour: - H1: tab cycles inside the tooltip's three actions (Skip/Back/Next), shift+tab honoured; on close the previously-focused element regains focus so Settings -> Account "Restart" returns the caret correctly - H2: tooltip card gains `max-h-[80vh] overflow-y-auto` so DE copy with longer translations can scroll inside the card without ejecting the Next/Done button below the fold on small viewports - H3: backdrop button gets `focus-visible:ring-2 ring-primary` so keyboard users see a visible focus signal when the tour is active Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/onboarding/tour.tsx | 45 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index 5e46b4a3b..c2de9a77a 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -225,13 +225,28 @@ export function OnboardingTour({ }, [stop]); // Focus management — moves focus to the primary action when the - // step changes so keyboard users can hit Enter to advance. + // step changes so keyboard users can hit Enter to advance. The + // tooltip ref + `previousFocus` carry the focus-trap contract: tab + // cycles inside the tooltip, and on close the originally-focused + // element (e.g. the Settings → Account "Restart" button) regains + // focus. const primaryRef = useRef(null); + const tooltipRef = useRef(null); + const previousFocusRef = useRef(null); + useEffect(() => { if (!stop) return; primaryRef.current?.focus(); }, [stop]); + useEffect(() => { + if (typeof document === "undefined") return; + previousFocusRef.current = document.activeElement as HTMLElement | null; + return () => { + previousFocusRef.current?.focus?.(); + }; + }, []); + // Keyboard shortcuts: Esc = skip, ArrowLeft / ArrowRight = nav, // Enter = next/done. We attach to window so the listener works // regardless of which element holds focus inside the tooltip. @@ -268,6 +283,29 @@ export function OnboardingTour({ if (e.key === "Escape") { e.preventDefault(); handleSkip(); + } else if (e.key === "Tab") { + // Trap focus inside the tooltip so the user can't tab onto the + // dimmed page underneath while the tour is active. + const root = tooltipRef.current; + if (!root) return; + const focusables = Array.from( + root.querySelectorAll( + 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + ), + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !root.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else if (active === last) { + e.preventDefault(); + first.focus(); + } } else if (e.key === "ArrowRight" || e.key === "Enter") { // Don't hijack Enter inside form inputs (none exist in the // tour itself but be defensive against future content). @@ -328,7 +366,7 @@ export function OnboardingTour({