mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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]);
|
}, [stop]);
|
||||||
|
|
||||||
// Focus management — moves focus to the primary action when the
|
// 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 primaryRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stop) return;
|
if (!stop) return;
|
||||||
primaryRef.current?.focus();
|
primaryRef.current?.focus();
|
||||||
}, [stop]);
|
}, [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,
|
// Keyboard shortcuts: Esc = skip, ArrowLeft / ArrowRight = nav,
|
||||||
// Enter = next/done. We attach to window so the listener works
|
// Enter = next/done. We attach to window so the listener works
|
||||||
// regardless of which element holds focus inside the tooltip.
|
// regardless of which element holds focus inside the tooltip.
|
||||||
@@ -268,6 +283,29 @@ export function OnboardingTour({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSkip();
|
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") {
|
} else if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||||
// Don't hijack Enter inside form inputs (none exist in the
|
// Don't hijack Enter inside form inputs (none exist in the
|
||||||
// tour itself but be defensive against future content).
|
// tour itself but be defensive against future content).
|
||||||
@@ -328,7 +366,7 @@ export function OnboardingTour({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("onboarding.tour.skip")}
|
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}
|
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
/>
|
/>
|
||||||
@@ -345,9 +383,10 @@ export function OnboardingTour({
|
|||||||
|
|
||||||
{/* Tooltip card */}
|
{/* Tooltip card */}
|
||||||
<div
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
data-testid="onboarding-tour-tooltip"
|
data-testid="onboarding-tour-tooltip"
|
||||||
data-placement={tooltipPos.placement}
|
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={{
|
style={{
|
||||||
top: `${tooltipPos.top}px`,
|
top: `${tooltipPos.top}px`,
|
||||||
left: `${tooltipPos.left}px`,
|
left: `${tooltipPos.left}px`,
|
||||||
|
|||||||
Reference in new issue
Block a user