mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
cd3b890067
commit
646541245d
1 file changed
+42
-3
@@ -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<HTMLButtonElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(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<HTMLElement>(
|
||||
'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({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("onboarding.tour.skip")}
|
||||
className="absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 motion-reduce:transition-none"
|
||||
className="absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 motion-reduce:transition-none"
|
||||
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
|
||||
onClick={handleSkip}
|
||||
/>
|
||||
@@ -345,9 +383,10 @@ export function OnboardingTour({
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
data-testid="onboarding-tour-tooltip"
|
||||
data-placement={tooltipPos.placement}
|
||||
className="bg-card border-border absolute rounded-xl border p-5 shadow-2xl"
|
||||
className="bg-card border-border absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
style={{
|
||||
top: `${tooltipPos.top}px`,
|
||||
left: `${tooltipPos.left}px`,
|
||||
|
||||
Reference in new issue
Block a user