diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx index c1cef066..14907524 100644 --- a/src/components/OnboardingTour.tsx +++ b/src/components/OnboardingTour.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { createPortal } from "react-dom"; +import { FocusTrap } from "focus-trap-react"; const TOUR_KEY = "reframe_onboarding_complete"; @@ -44,7 +45,7 @@ const TOUR_STEPS: TourStep[] = [ }, ]; -const PADDING = 12; // spotlight padding around target element +const PADDING = 12; const TOOLTIP_OFFSET = 16; interface Rect { @@ -76,16 +77,19 @@ function getTooltipStyle( top: sr.top - th - TOOLTIP_OFFSET, left: sr.left + sr.width / 2 - tw / 2, }; + case "left": return { top: sr.top + sr.height / 2 - th / 2, left: sr.left - tw - TOOLTIP_OFFSET, }; + case "right": return { top: sr.top + sr.height / 2 - th / 2, left: sr.left + sr.width + TOOLTIP_OFFSET, }; + case "bottom": default: return { @@ -126,13 +130,14 @@ function Spotlight({ rect }: SpotlightProps) { /> + - {/* Highlight ring */} + - {/* Progress bar */} - {/* Step counter */} Step {stepIndex + 1} of {totalSteps} {step.title} + {step.description} @@ -209,6 +213,7 @@ function Tooltip({ > Skip tour + { @@ -231,8 +236,10 @@ export default function OnboardingTour() { const [stepIndex, setStepIndex] = useState(0); const [visible, setVisible] = useState(false); const [targetRect, setTargetRect] = useState(null); + const tooltipRef = useRef(null); const isFirstRender = useRef(true); + const currentStep = TOUR_STEPS[stepIndex]; const dismiss = useCallback(() => { @@ -240,58 +247,75 @@ export default function OnboardingTour() { setVisible(false); }, []); - const measureTarget = useCallback((id: string): Promise => { - return new Promise((resolve) => { - const attempt = (tries: number) => { - const el = document.getElementById(id); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - setTimeout(() => { - const r = el.getBoundingClientRect(); - resolve({ - top: r.top, - left: r.left, - width: r.width, - height: r.height, + const measureTarget = useCallback( + (id: string): Promise => { + return new Promise((resolve) => { + const attempt = (tries: number) => { + const el = document.getElementById(id); + + if (el) { + el.scrollIntoView({ + behavior: "smooth", + block: "center", }); - }, 400); // wait for scroll to finish - return; - } - if (tries <= 0) { - resolve(null); - return; - } - setTimeout(() => attempt(tries - 1), 300); - }; - attempt(5); - }); - }, []); - // Initialise on mount + setTimeout(() => { + const r = el.getBoundingClientRect(); + + resolve({ + top: r.top, + left: r.left, + width: r.width, + height: r.height, + }); + }, 400); + + return; + } + + if (tries <= 0) { + resolve(null); + return; + } + + setTimeout(() => attempt(tries - 1), 300); + }; + + attempt(5); + }); + }, + [], + ); + useEffect(() => { if (localStorage.getItem(TOUR_KEY)) return; + const t = setTimeout(async () => { - const rect = await measureTarget(TOUR_STEPS[0]?.targetId ?? ""); + const rect = await measureTarget( + TOUR_STEPS[0]?.targetId ?? "", + ); + if (rect) { setTargetRect(rect); setVisible(true); } }, 600); + return () => clearTimeout(t); }, [measureTarget]); - // Measure target whenever step changes (skip on first render — init effect handles that) useEffect(() => { if (!visible) return; + if (isFirstRender.current) { isFirstRender.current = false; return; } + if (!currentStep) { dismiss(); return; } - let retryCount = 0; const maxRetries = 10; // Retry up to ~5s with 500ms delays let retryTimer: number | null = null; @@ -301,6 +325,7 @@ export default function OnboardingTour() { measureTarget(currentStep.targetId) .then((rect) => { if (cancelled) return; + if (rect) { setTargetRect(rect); setTimeout(() => tooltipRef.current?.focus(), 50); @@ -310,8 +335,11 @@ export default function OnboardingTour() { retryTimer = window.setTimeout(tryMeasure, 500); } else { // If we've retried enough, fallback to advancing or dismissing - if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1); - else dismiss(); + if (stepIndex < TOUR_STEPS.length - 1) { + setStepIndex((i) => i + 1); + } else { + dismiss(); + } } }) .catch((error) => { @@ -326,67 +354,94 @@ export default function OnboardingTour() { cancelled = true; if (retryTimer !== null) clearTimeout(retryTimer); }; - }, [stepIndex, visible, measureTarget, dismiss, currentStep]); + }, [ + stepIndex, + visible, + measureTarget, + dismiss, + currentStep, + ]); - // Re-measure on resize or scroll so spotlight stays anchored to target. - // requestAnimationFrame prevents layout thrashing on rapid scroll/resize events. useEffect(() => { if (!visible) return; - let rafId: number; - const remeasure = () => { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { - measureTarget(TOUR_STEPS[stepIndex]?.targetId ?? "").then(setTargetRect); - }); + + const onResize = () => { + measureTarget( + TOUR_STEPS[stepIndex]?.targetId ?? "", + ).then(setTargetRect); }; - window.addEventListener("resize", remeasure); - window.addEventListener("scroll", remeasure, true); + + window.addEventListener("resize", onResize); + return () => { - cancelAnimationFrame(rafId); - window.removeEventListener("resize", remeasure); - window.removeEventListener("scroll", remeasure, true); + window.removeEventListener("resize", onResize); }; }, [visible, stepIndex, measureTarget]); - // Keyboard support useEffect(() => { if (!visible) return; + const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") dismiss(); + if (e.key === "Escape") { + dismiss(); + } + if (e.key === "ArrowRight" || e.key === "Enter") { - if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1); - else dismiss(); + if (stepIndex < TOUR_STEPS.length - 1) { + setStepIndex((i) => i + 1); + } else { + dismiss(); + } } }; + window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + + return () => { + window.removeEventListener("keydown", onKey); + }; }, [visible, stepIndex, dismiss]); - if (!visible || !targetRect || !currentStep) return null; + if (!visible || !targetRect || !currentStep) { + return null; + } return createPortal( - <> - {/* Clickable backdrop to skip */} - - - { - if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1); - else dismiss(); - }} - onSkip={dismiss} - tooltipRef={tooltipRef} - /> - >, + tooltipRef.current!, + }} + > + + + + + + { + if (stepIndex < TOUR_STEPS.length - 1) { + setStepIndex((i) => i + 1); + } else { + dismiss(); + } + }} + onSkip={dismiss} + tooltipRef={tooltipRef} + /> + + , document.body, ); -} +} \ No newline at end of file
Step {stepIndex + 1} of {totalSteps}
{step.description}