From d6060a57949c51580f5bf8eb60e5171ffe06bb0c Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 1 Jul 2026 19:18:01 +0530 Subject: [PATCH 1/3] wip: tour --- apps/www/src/app/examples/tour/page.tsx | 654 ++++++++++++++++-- apps/www/src/components/demo/demo.tsx | 2 + apps/www/src/components/playground/index.ts | 1 + .../components/playground/tour-examples.tsx | 76 ++ apps/www/src/components/tour-demo.tsx | 95 +++ .../src/content/docs/components/tour/demo.ts | 6 + .../content/docs/components/tour/index.mdx | 188 +++++ .../src/content/docs/components/tour/props.ts | 211 ++++++ .../components/tour/__tests__/tour.test.tsx | 590 ++++++++++++++++ packages/raystack/components/tour/index.tsx | 17 + .../raystack/components/tour/tour-context.tsx | 62 ++ .../raystack/components/tour/tour-overlay.tsx | 196 ++++++ .../raystack/components/tour/tour-parts.tsx | 177 +++++ .../raystack/components/tour/tour-popover.tsx | 149 ++++ .../raystack/components/tour/tour-root.tsx | 343 +++++++++ .../raystack/components/tour/tour.module.css | 156 +++++ packages/raystack/components/tour/tour.tsx | 26 + packages/raystack/components/tour/types.ts | 99 +++ .../components/tour/use-tour-target.ts | 125 ++++ packages/raystack/index.tsx | 2 +- 20 files changed, 3130 insertions(+), 45 deletions(-) create mode 100644 apps/www/src/components/playground/tour-examples.tsx create mode 100644 apps/www/src/components/tour-demo.tsx create mode 100644 apps/www/src/content/docs/components/tour/demo.ts create mode 100644 apps/www/src/content/docs/components/tour/index.mdx create mode 100644 apps/www/src/content/docs/components/tour/props.ts create mode 100644 packages/raystack/components/tour/__tests__/tour.test.tsx create mode 100644 packages/raystack/components/tour/index.tsx create mode 100644 packages/raystack/components/tour/tour-context.tsx create mode 100644 packages/raystack/components/tour/tour-overlay.tsx create mode 100644 packages/raystack/components/tour/tour-parts.tsx create mode 100644 packages/raystack/components/tour/tour-popover.tsx create mode 100644 packages/raystack/components/tour/tour-root.tsx create mode 100644 packages/raystack/components/tour/tour.module.css create mode 100644 packages/raystack/components/tour/tour.tsx create mode 100644 packages/raystack/components/tour/types.ts create mode 100644 packages/raystack/components/tour/use-tour-target.ts diff --git a/apps/www/src/app/examples/tour/page.tsx b/apps/www/src/app/examples/tour/page.tsx index 751c20d1a..48f77d223 100644 --- a/apps/www/src/app/examples/tour/page.tsx +++ b/apps/www/src/app/examples/tour/page.tsx @@ -19,11 +19,13 @@ import { Navbar, Search, Sidebar, + Switch, Text, Tour, type TourActions, type TourEvent, - type TourStep + type TourStep, + useTour } from '@raystack/apsara'; import { useEffect, useRef, useState } from 'react'; @@ -34,27 +36,555 @@ const card: React.CSSProperties = { backgroundColor: 'var(--rs-color-background-base-primary)' }; -/** Per-step flags read by the custom popover layout below. */ -type StepData = { - /** When set, the step has no Next button — this hint shows instead. */ - requiresAction?: string; - hidePrev?: boolean; +const labCard: React.CSSProperties = { + ...card, + padding: 'var(--rs-space-5)', + height: '100%' }; -const Page = () => { +/* -------------------------------------------------------------------------- */ +/* Shared helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * A small live status row, driven by `useTour` — shows idle/waiting/running. + * Rendered as a child of `` so it can read the tour context. + */ +function StatusRow() { + const { status } = useTour(); + return ( + + + useTour status: + + + {status} + + + ); +} + +/** Consistent shell for each isolated edge-case demo. */ +function LabCard({ + title, + children, + note +}: { + title: string; + children: React.ReactNode; + note: React.ReactNode; +}) { + return ( + + + {title} + + + {note} + + + {children} + + + ); +} + +/** Tracks the most recent lifecycle line for a demo-local mini event log. */ +function useLastEvent() { + const [last, setLast] = useState('—'); + const onEvent = (e: TourEvent) => { + const detail = + e.type === 'tour:end' ? `status=${e.status}` : (e.step?.id ?? ''); + setLast(`${e.type} · #${e.index} ${detail}`.trim()); + }; + return { last, onEvent }; +} + +function EventLine({ last }: { last: string }) { + return ( + + {last} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case: late-mounting target (MutationObserver wait → "waiting") */ +/* -------------------------------------------------------------------------- */ + +function LateMountDemo() { + const actionsRef = useRef(null); + const [mounted, setMounted] = useState(false); + const [phase, setPhase] = useState<'idle' | 'waiting' | 'running'>('idle'); + + const run = () => { + setMounted(false); + setPhase('waiting'); + actionsRef.current?.start(); + // The target only appears ~1.2s later — the tour waits for it. + window.setTimeout(() => setMounted(true), 1200); + }; + + return ( + + + + + {phase} + + +
+ {mounted && ( + + I mounted late. + + )} +
+ { + if (e.type === 'step:active') setPhase('running'); + if (e.type === 'tour:end') setPhase('idle'); + }} + /> +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case: target never appears → skip vs stop */ +/* -------------------------------------------------------------------------- */ + +function MissingTargetDemo() { + const actionsRef = useRef(null); + const [policy, setPolicy] = useState<'skip' | 'stop'>('skip'); + const { last, onEvent } = useLastEvent(); + + return ( + + + setPolicy(c ? 'stop' : 'skip')} + /> + + policy: {policy} + + + + + + +
+ Fallback target. +
+ +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case: a resolved target unmounts mid-step (tour recovers) */ +/* -------------------------------------------------------------------------- */ + +function UnmountMidStepDemo() { + const actionsRef = useRef(null); + const [present, setPresent] = useState(true); + const { last, onEvent } = useLastEvent(); + + const start = () => { + setPresent(true); + actionsRef.current?.start(); + // Simulate the target disappearing mid-step — as if a dialog holding it + // closed, or a route unmounted it — while the tour points at it. + window.setTimeout(() => setPresent(false), 1600); + }; + + return ( + + + + + +
+ {present && ( +
+ I vanish mid-tour… +
+ )} +
+
+ Stable fallback. +
+ +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case (headline): close tour, close dialog, resume onto missing target */ +/* -------------------------------------------------------------------------- */ + +function ResumeMissingDemo() { + const actionsRef = useRef(null); + const [open, setOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const { last, onEvent } = useLastEvent(); + + return ( + + + + + + + + { + if (!next && open) return; // tour owns dismissal while running + setDialogOpen(next); + }} + > + + + Invite a teammate + + + + + + + + { + setOpen(next); + if (!next) setDialogOpen(false); // closing the tour closes the dialog + }} + /> + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case: spotlight follows scroll (and scrolls the target into view) */ +/* -------------------------------------------------------------------------- */ + +function ScrollTrackingDemo() { + const actionsRef = useRef(null); + + return ( + + +
+ + {Array.from({ length: 8 }, (_, i) => ( + + Row {i + 1} + + ))} +
+ Target row (below the fold) +
+ {Array.from({ length: 6 }, (_, i) => ( + + Row {i + 9} + + ))} +
+
+ +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case: spotlight a child of the anchor (spotlightTarget) */ +/* -------------------------------------------------------------------------- */ + +function SpotlightChildDemo() { + const actionsRef = useRef(null); + + return ( + + +
+
+ + Panel header + +
+
+ + Panel body content that is not spotlighted. + +
+
+ +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Edge case: action-gated step (advance from app state, no Next button) */ +/* -------------------------------------------------------------------------- */ + +function ActionGatedDemo() { + const actionsRef = useRef(null); + const [open, setOpen] = useState(false); + const [index, setIndex] = useState(0); + const [checked, setChecked] = useState(false); + + // Advance when the user performs the required action (toggles the switch). + useEffect(() => { + if (open && index === 0 && checked) actionsRef.current?.next(); + }, [open, index, checked]); + + return ( + + + + + Toggle to continue + +
+ Finish line. +
+ + + + + {({ step, index, isLastStep, actions }) => { + const gated = index === 0; + return ( + <> + + + + + + + + {gated ? ( + + Waiting for you… + + ) : ( + + )} + + + ); + }} + + +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* The main product tour */ +/* -------------------------------------------------------------------------- */ + +type StepData = { requiresAction?: string; hidePrev?: boolean }; + +function ProductTour() { const [tourOpen, setTourOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [events, setEvents] = useState([]); const [notifications, setNotifications] = useState(3); const [showLateCard, setShowLateCard] = useState(false); - // Track progress so the hero can offer "Resume" after a mid-tour stop. const [lastIndex, setLastIndex] = useState(0); const [resumable, setResumable] = useState(false); const searchRef = useRef(null); const actionsRef = useRef(null); - // The "Reports" card mounts 1.5s after the tour starts, to demo the - // MutationObserver wait (status becomes `waiting` until it appears). + // The "Reports" card mounts 1.5s after the tour starts, to demo the wait. useEffect(() => { if (!tourOpen) return; const timer = setTimeout(() => setShowLateCard(true), 1500); @@ -64,17 +594,15 @@ const Page = () => { const steps: TourStep[] = [ { id: 'welcome', - // No target: renders as a centered, detached step. title: 'Welcome to Raystack', content: - 'This tour is built entirely on Apsara primitives — Base UI popover, design tokens, and a spotlight backdrop. Use Next or press Escape to leave at any time.' + 'A tour built entirely on Apsara primitives — Base UI popover, design tokens, and a spotlight backdrop. This first step has no target, so it is centered.' }, { id: 'sidebar', target: '[data-tour="sidebar-nav"]', title: 'Navigate your workspace', - content: - 'Everything lives in the sidebar. Dashboards, analytics and settings are one click away.', + content: 'Anchored to the sidebar with side="right", align="start".', side: 'right', align: 'start' }, @@ -83,7 +611,7 @@ const Page = () => { target: () => searchRef.current, title: 'Search anything', content: - 'This step targets a React ref instead of a selector, and spotlightClicks lets you type into the field while the tour is running — try it.', + 'This step targets a React ref, and spotlightClicks lets you type in the field while the tour runs.', side: 'bottom', spotlightClicks: true }, @@ -91,8 +619,7 @@ const Page = () => { id: 'notifications', target: '[data-tour="notifications"]', title: 'Stay in the loop', - content: - 'The bell shows unread alerts. The spotlight here uses a larger padding and a pill radius.', + content: 'Larger spotlightPadding and a pill spotlightRadius here.', side: 'left', spotlightPadding: 8, spotlightRadius: 999 @@ -100,9 +627,9 @@ const Page = () => { { id: 'invite-open', target: '[data-tour="invite"]', - title: 'Invite your team', + title: 'Some steps require doing', content: - 'Some steps require doing instead of reading. This one has no Next button — click the highlighted button to open the invite dialog and continue.', + 'This step has no Next button — click the highlighted button to open the invite dialog and continue.', side: 'top', spotlightClicks: true, data: { @@ -114,7 +641,7 @@ const Page = () => { target: '[data-tour="invite-email"]', title: 'Steps can live inside dialogs', content: - 'This field mounted with the dialog, after the tour had already started. The tour dims the dialog itself and ignores light dismissal, so the dialog stays open while you type.', + 'This field mounted with the dialog, after the tour started. The tour dims the dialog itself and ignores light dismissal, so it stays open while you type.', side: 'bottom', spotlightClicks: true, data: { hidePrev: true } satisfies StepData @@ -136,7 +663,7 @@ const Page = () => { target: '[data-tour="reports"]', title: 'Targets can mount late', content: - 'This card did not exist when the tour started. The tour waited for it to appear in the DOM before showing this step.', + 'This card did not exist when the tour started. The tour waited for it before showing this step.', side: 'top', data: { hidePrev: true } satisfies StepData }, @@ -144,8 +671,7 @@ const Page = () => { id: 'scrolled-card', target: '[data-tour="billing"]', title: 'Scrolled into view', - content: - 'This card was below the fold — the tour scrolled it into view before anchoring.', + content: 'This card was below the fold — the tour scrolled to it.', side: 'top' }, { @@ -153,7 +679,7 @@ const Page = () => { target: '[data-tour="event-log"]', title: 'Overlay is optional', content: - 'This step sets disableOverlay — no dimming, no spotlight, and the whole page stays interactive. Pass it on the Tour itself to disable the overlay for every step.', + 'This step sets disableOverlay — no dimming, no spotlight, the page stays fully interactive.', side: 'right', disableOverlay: true }, @@ -161,7 +687,7 @@ const Page = () => { id: 'done', title: 'You are all set', content: - 'That is the whole tour. Restart it from the button in the header, or jump to any step with the controls below the event log.', + 'That is the whole tour. Restart from the header, or explore the edge-case lab below.', data: { hidePrev: true } satisfies StepData } ]; @@ -174,8 +700,6 @@ const Page = () => { setEvents(prev => [`${event.type} · #${event.index} ${detail}`.trim(), ...prev].slice(0, 8) ); - // Offer "Resume" only when the tour was left mid-way (Escape or Stop), - // not when it ran to the end (finished) or was skipped. if (event.type === 'tour:end') { setResumable(event.status === 'closed' && event.index > 0); } @@ -244,11 +768,9 @@ const Page = () => { @@ -258,10 +780,11 @@ const Page = () => { align='start' size='small' variant='secondary' - style={{ maxWidth: 440 }} + style={{ maxWidth: 460 }} > - Ten steps across the sidebar, search, a dialog, plus late-mounting - and scrolled targets. Press Escape any time to leave. + Eleven steps across the sidebar, search, a dialog, plus + late-mounting and scrolled targets. Press Escape any time to leave. + Isolated edge-case demos are further down the page. + + + + + + ); +} diff --git a/apps/www/src/components/tour-demo.tsx b/apps/www/src/components/tour-demo.tsx new file mode 100644 index 000000000..ba542c240 --- /dev/null +++ b/apps/www/src/components/tour-demo.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { BarChartIcon, BellIcon, RocketIcon } from '@radix-ui/react-icons'; +import { + Button, + Flex, + IconButton, + Search, + Text, + Tour, + type TourActions, + type TourStep +} from '@raystack/apsara'; +import { useRef } from 'react'; + +const panel: React.CSSProperties = { + padding: 'var(--rs-space-5)', + border: '1px solid var(--rs-color-border-base-primary)', + borderRadius: 'var(--rs-radius-3)', + backgroundColor: 'var(--rs-color-background-base-primary)' +}; + +const steps: TourStep[] = [ + { + id: 'welcome', + title: 'Welcome aboard', + content: + 'A quick tour of the workspace. Use Next to move on, or press Escape to leave any time.' + }, + { + id: 'search', + target: '[data-tour="search"]', + title: 'Search anything', + content: 'Jump to any resource from here. Try typing while the tour runs.', + side: 'bottom', + spotlightClicks: true + }, + { + id: 'analytics', + target: '[data-tour="analytics"]', + title: 'Track usage', + content: 'Analytics break down usage across your whole workspace.', + side: 'bottom' + }, + { + id: 'notifications', + target: '[data-tour="notifications"]', + title: 'Stay in the loop', + content: 'Unread alerts land on the bell. That is the whole tour!', + side: 'left', + spotlightRadius: 999, + spotlightPadding: 6 + } +]; + +export default function TourDemo() { + const actionsRef = useRef(null); + + return ( + + +
+ +
+ + + + + + + + +
+ + + + Guided tour + + + Four steps: a centered welcome, then the search box, analytics, and + notifications — each anchored and spotlighted. + + + + + +
+ ); +} diff --git a/apps/www/src/content/docs/components/tour/demo.ts b/apps/www/src/content/docs/components/tour/demo.ts new file mode 100644 index 000000000..f53a01464 --- /dev/null +++ b/apps/www/src/content/docs/components/tour/demo.ts @@ -0,0 +1,6 @@ +'use client'; + +export const preview = { + type: 'code', + code: `` +}; diff --git a/apps/www/src/content/docs/components/tour/index.mdx b/apps/www/src/content/docs/components/tour/index.mdx new file mode 100644 index 000000000..3b49b27cc --- /dev/null +++ b/apps/www/src/content/docs/components/tour/index.mdx @@ -0,0 +1,188 @@ +--- +title: Tour +description: Guided product walkthroughs — a sequence of cards that point at real elements, spotlight them, and let people move on, go back, or leave. +source: packages/raystack/components/tour +tag: new +--- + +import { preview } from "./demo.ts"; + + + +## Anatomy + +Describe a tour as an array of steps and drop in the root. With no children it +renders the overlay plus the standard card. + +```tsx +import { Tour } from "@raystack/apsara"; + +const steps = [ + { target: '[data-tour="search"]', title: "Search", content: "Find anything." }, + { target: filtersRef, title: "Filter", content: "Narrow results.", side: "right" }, + { title: "You're all set!", content: "Explore at your own pace." }, // no target → centered +]; + +; +``` + +To compose the card yourself, pass the parts as children: + +```tsx + + + + + + + + + + +``` + +## API Reference + +### Tour + +The root. Holds the steps and the open/index state, resolves targets, emits +events, and owns the actions. Renders a context provider — its children default +to `` and ``. + + + +### Step + +Each entry in the `steps` array. + + + +### Tour.Popover + +The card. Accepts positioning defaults and either static children, a render +function, or the default layout. + + + +### Tour.Overlay + +The dimmed backdrop with a spotlight cutout over the target. + + + +### Tour.Progress + +Renders the step counter, e.g. "2 of 5". + + + +### Tour.Title / Tour.Description + +Give the card its accessible name and description, falling back to the step's +`title` and `content`. + +### Tour.Next / Tour.Prev / Tour.Skip / Tour.Close + +Navigation buttons. Each runs your `onClick` first, then its action +(`next`, `prev`, `skip`, `stop`). `Tour.Next` finishes the tour past the last +step. + +### Actions + +`start`, `next`, `prev`, `go`, `skip`, and `stop`. Reach them through +`actionsRef`, or anywhere inside `` with the `useTour()` hook. `start` is +the only action that runs while the tour is closed. + + + +### Events + +Every lifecycle moment is reported to `onEvent`. + + + +## Examples + +### Controlled with imperative controls + +Control `open` and `stepIndex`, pass an `actionsRef`, and drive the tour from +your own UI. + +```tsx +const actionsRef = useRef(null); +const [open, setOpen] = useState(false); +const [index, setIndex] = useState(0); + + + + { + setOpen(open); + if (!open) analytics.track("tour_end", { status }); + }} + onStepChange={setIndex} +/>; +``` + +### Custom card with a render function + +Leave out `Tour.Next` and advance from app code when the user does something — +useful for action-gated steps (draw a shape, open a panel). + +```tsx + + + {({ step, index, totalSteps, isLastStep, actions }) => ( + + {step.title} + {step.content} + + {index + 1} of {totalSteps} + + + + )} + +; + +// advance the draw step once the app reports a shape on the map +useEffect(() => { + if (open && index === 2 && store.hasShape) actionsRef.current?.go(3); +}, [open, index, store.hasShape]); +``` + +### Targets and spotlight + +A target can be a CSS selector, an element, a React ref, or a function. Targets +that mount after the tour starts are awaited automatically; if one never +appears within `targetTimeout` the tour skips it (or stops, per +`targetNotFound`). If a target unmounts mid-step — its dialog closes, its route +changes — the tour recovers instead of leaving a broken overlay. + +```tsx +const steps = [ + { target: "#search", title: "Selector target" }, + { target: searchRef, title: "Ref target", spotlightClicks: true }, + { target: () => editor.getNode("aoi"), title: "Function target" }, + { target: "#panel", spotlightTarget: "#panel-header", title: "Spotlight a child" }, +]; +``` + +## Accessibility + +- The card is a Base UI `Popover`, so `Tour.Title` and `Tour.Description` provide + its accessible name and description and it is exposed as a `dialog`. +- Focus moves into the card when a step opens and when the step changes, so + keyboard users can reach the navigation buttons. Steps with `spotlightClicks` + keep focus on the page so the highlighted element stays operable. +- Escape ends the tour. Outside clicks and focus moves do not, so a step can keep + focus on the element it highlights. +- The tour is non-modal (`modal={false}`); it does not trap focus or lock scroll. +- Motion — the spotlight glide, card entry, and scroll-into-view — is enabled only + when `prefers-reduced-motion: no-preference`. diff --git a/apps/www/src/content/docs/components/tour/props.ts b/apps/www/src/content/docs/components/tour/props.ts new file mode 100644 index 000000000..fbd4bee8d --- /dev/null +++ b/apps/www/src/content/docs/components/tour/props.ts @@ -0,0 +1,211 @@ +import type React from 'react'; + +type TourTarget = + | string + | Element + | React.RefObject + | (() => Element | null); + +export interface TourProps { + /** Ordered list of steps that make up the tour. */ + steps: TourStep[]; + + /** Whether the tour is currently open (controlled). */ + open?: boolean; + + /** + * Whether the tour is initially open (uncontrolled). + * @defaultValue false + */ + defaultOpen?: boolean; + + /** Called when the tour opens or closes. `status` is set when closing. */ + onOpenChange?: ( + open: boolean, + details: { status?: 'finished' | 'skipped' | 'closed' } + ) => void; + + /** Active step index (controlled). */ + stepIndex?: number; + + /** + * Initially active step when uncontrolled. + * @defaultValue 0 + */ + defaultStepIndex?: number; + + /** Called when the active step changes. */ + onStepChange?: (index: number, step: TourStep) => void; + + /** Receives every tour lifecycle event. */ + onEvent?: (event: TourEvent) => void; + + /** A ref populated with the imperative tour controls (`start`, `next`, `prev`, `go`, `skip`, `stop`). */ + actionsRef?: React.RefObject; + + /** + * How long to wait for a step target to appear in the DOM before giving up, in ms. + * @defaultValue 5000 + */ + targetTimeout?: number; + + /** + * What to do when a step target cannot be found: skip to the next step or stop the tour. + * @defaultValue 'skip' + */ + targetNotFound?: 'skip' | 'stop'; + + /** + * Hide the dimmed overlay for the whole tour — only the popover shows and the page stays interactive. + * @defaultValue false + */ + disableOverlay?: boolean; + + /** Tour UI. Defaults to `` + ``. */ + children?: React.ReactNode; +} + +export interface TourStep { + /** Stable identifier reported in events. Falls back to the step index. */ + id?: string; + + /** Element to anchor and spotlight. Omit for a centered, detached step. */ + target?: TourTarget; + + /** Rendered by `Tour.Title` and the default layout. */ + title?: React.ReactNode; + + /** Rendered by `Tour.Description` and the default layout. */ + content?: React.ReactNode; + + /** + * Side of the target to place the popover on. + * @defaultValue 'bottom' + */ + side?: 'top' | 'right' | 'bottom' | 'left'; + + /** + * Alignment against the target. + * @defaultValue 'center' + */ + align?: 'start' | 'center' | 'end'; + + /** Distance between the target and the popover in pixels. */ + sideOffset?: number; + + /** Element to spotlight when it differs from the popover anchor. */ + spotlightTarget?: TourTarget; + + /** Space between the target and the spotlight edge in pixels. */ + spotlightPadding?: number; + + /** Spotlight corner radius in pixels. */ + spotlightRadius?: number; + + /** Allow pointer interaction with the spotlighted element. */ + spotlightClicks?: boolean; + + /** Hide the dimmed overlay on this step, overriding the tour-level setting. */ + disableOverlay?: boolean; + + /** Element to scroll into view when it differs from the target. */ + scrollTarget?: TourTarget; + + /** Skip scrolling the target into view when the step activates. */ + disableScroll?: boolean; + + /** Arbitrary data echoed back in events and render props. */ + data?: unknown; +} + +export interface TourActions { + /** Open the tour, optionally at a given step. The only action that runs while closed. */ + start: (index?: number) => void; + /** Advance to the next step; finishes the tour past the last step. */ + next: () => void; + /** Return to the previous step. */ + prev: () => void; + /** Jump to an arbitrary step. */ + go: (index: number) => void; + /** End the tour with the `skipped` status. */ + skip: () => void; + /** End the tour with the `closed` status. */ + stop: () => void; +} + +export interface TourEvent { + /** `tour:start`, `step:active`, `error:target-not-found`, or `tour:end`. */ + type: 'tour:start' | 'step:active' | 'error:target-not-found' | 'tour:end'; + /** Index of the step the event relates to. */ + index: number; + /** The step the event relates to (absent on some `tour:start`/`tour:end`). */ + step?: TourStep; + /** On `tour:end`, how the tour ended. */ + status?: 'finished' | 'skipped' | 'closed'; +} + +export interface TourPopoverProps { + /** + * Default side of the target to place the popover on; steps override. + * @defaultValue 'bottom' + */ + side?: 'top' | 'right' | 'bottom' | 'left'; + + /** + * Default alignment against the target; steps override. + * @defaultValue 'center' + */ + align?: 'start' | 'center' | 'end'; + + /** + * Default distance to the target in pixels; steps override. + * @defaultValue 12 + */ + sideOffset?: number; + + /** + * Whether to render the pointing arrow. + * @defaultValue true + */ + showArrow?: boolean; + + /** Additional CSS class name. */ + className?: string; + + /** Additional inline styles. */ + style?: React.CSSProperties; + + /** Static nodes, or a render function receiving the active step and actions. */ + children?: React.ReactNode | ((props: unknown) => React.ReactNode); +} + +export interface TourOverlayProps { + /** + * Space between the target and the spotlight edge in pixels; steps override. + * @defaultValue 4 + */ + spotlightPadding?: number; + + /** + * Spotlight corner radius in pixels; steps override. + * @defaultValue 6 + */ + spotlightRadius?: number; + + /** + * Allow pointer interaction with the spotlighted element; steps override. + * @defaultValue false + */ + spotlightClicks?: boolean; + + /** Additional CSS class name. */ + className?: string; +} + +export interface TourProgressProps { + /** Custom formatter, e.g. show "2/5" instead of "2 of 5". */ + format?: (index: number, total: number) => React.ReactNode; + + /** Additional CSS class name. */ + className?: string; +} diff --git a/packages/raystack/components/tour/__tests__/tour.test.tsx b/packages/raystack/components/tour/__tests__/tour.test.tsx new file mode 100644 index 000000000..0f7653f31 --- /dev/null +++ b/packages/raystack/components/tour/__tests__/tour.test.tsx @@ -0,0 +1,590 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ComponentProps, useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { Tour } from '../tour'; +import styles from '../tour.module.css'; +import { useTour } from '../tour-context'; +import type { TourActions, TourEvent, TourStep } from '../types'; + +const STEPS: TourStep[] = [ + { + id: 'one', + target: '#step-one', + title: 'Step one', + content: 'First content' + }, + { + id: 'two', + target: '#step-two', + title: 'Step two', + content: 'Second content' + }, + // Detached step: no target, renders centered. + { id: 'three', title: 'Step three', content: 'Centered content' } +]; + +const Page = (props: Partial>) => ( +
+ + + +
+); + +describe('Tour', () => { + it('renders nothing while closed', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); + }); + + it('opens at the first step and resolves selector targets', async () => { + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(screen.getByText('Step one')).toBeInTheDocument(); + expect(screen.getByText('First content')).toBeInTheDocument(); + expect(screen.getByText('1 of 3')).toBeInTheDocument(); + }); + + it('renders the spotlight overlay while running', async () => { + render(); + await waitFor(() => + expect( + document.querySelector('[data-status="running"]') + ).toBeInTheDocument() + ); + }); + + it('hides the overlay when disableOverlay is set on the tour', async () => { + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); + }); + + it('hides the overlay per step via step.disableOverlay', async () => { + const user = userEvent.setup(); + const steps: TourStep[] = [ + { + id: 'one', + target: '#step-one', + title: 'Step one', + disableOverlay: true + }, + { id: 'two', target: '#step-two', title: 'Step two' } + ]; + render( +
+ + + +
+ ); + await waitFor(() => + expect(screen.getByText('Step one')).toBeInTheDocument() + ); + expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(document.querySelector('[data-status]')).toBeInTheDocument() + ); + }); + + it('navigates with Next and Back', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(screen.getByText('Step two')).toBeInTheDocument() + ); + await user.click(screen.getByRole('button', { name: 'Back' })); + await waitFor(() => + expect(screen.getByText('Step one')).toBeInTheDocument() + ); + }); + + it('renders detached steps centered without a target', async () => { + render(); + await waitFor(() => + expect(screen.getByText('Step three')).toBeInTheDocument() + ); + // A detached step dims the whole viewport (no cutout) but still shows. + expect( + document.querySelector('[data-status="running"]') + ).toBeInTheDocument(); + }); + + it('finishes from the last step and reports status', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const events: TourEvent[] = []; + render( + events.push(event)} + /> + ); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Finish' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'finished' }); + expect(events[events.length - 1]).toMatchObject({ + type: 'tour:end', + status: 'finished' + }); + }); + + it('closes from the close button with closed status', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Close tour' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'closed' }); + }); + + it('ends the tour on Escape with closed status', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.keyboard('{Escape}'); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'closed' }); + }); + + it('emits lifecycle events in order', async () => { + const user = userEvent.setup(); + const events: TourEvent[] = []; + render( events.push(event)} />); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(screen.getByText('Step two')).toBeInTheDocument() + ); + const types = events.map(event => event.type); + expect(types[0]).toBe('tour:start'); + expect(types).toContain('step:active'); + expect( + events.filter(event => event.type === 'step:active').map(e => e.index) + ).toEqual([0, 1]); + }); + + it('waits for late-mounting targets and hides the overlay meanwhile', async () => { + const LateMount = () => { + const [show, setShow] = useState(false); + return ( +
+ + {show &&
Late content
} + +
+ ); + }; + render(); + // Nothing shows while the target is missing — no blank overlay, no card. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('mount')); + await waitFor(() => + expect(screen.getByText('Late step')).toBeInTheDocument() + ); + }); + + it('skips steps whose target never appears', async () => { + const events: TourEvent[] = []; + render( +
+
+ events.push(event)} + /> +
+ ); + await waitFor(() => + expect(screen.getByText('Real step')).toBeInTheDocument() + ); + expect(events.some(event => event.type === 'error:target-not-found')).toBe( + true + ); + }); + + it('stops when a target is missing under the stop policy', async () => { + const onOpenChange = vi.fn(); + render( + + ); + await waitFor(() => + expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'closed' }) + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('recovers when a resolved target unmounts mid-step', async () => { + const events: TourEvent[] = []; + const Harness = () => { + const [showTarget, setShowTarget] = useState(true); + return ( +
+ + {showTarget &&
vanish
} +
+ events.push(e)} + /> +
+ ); + }; + render(); + await waitFor(() => + expect(screen.getByText('Vanishing step')).toBeInTheDocument() + ); + // The target disappears while its step is active. + fireEvent.click(screen.getByText('hide')); + // The tour must not strand a broken card on the gone target — it advances. + await waitFor(() => + expect(screen.getByText('Fallback step')).toBeInTheDocument() + ); + expect(events.some(e => e.type === 'error:target-not-found')).toBe(true); + }); + + it('leaves no orphaned overlay when resuming onto a target that no longer exists', async () => { + const onOpenChange = vi.fn(); + const Harness = () => { + const actionsRef = useRef(null); + const [mounted, setMounted] = useState(true); + const [open, setOpen] = useState(true); + return ( +
+ {mounted &&
field
} + + + { + setOpen(next); + onOpenChange(next, details); + }} + /> +
+ ); + }; + render(); + await waitFor(() => + expect(screen.getByText('Inside dialog')).toBeInTheDocument() + ); + // Close the tour, then destroy the element the step pointed at. + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + fireEvent.click(screen.getByText('unmount')); + + // Resume onto the now-missing target. + fireEvent.click(screen.getByText('resume')); + // No card and — crucially — no orphaned dimmed overlay while waiting. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); + // It resolves cleanly by ending the tour (single step, target gone). + await waitFor(() => + expect(onOpenChange).toHaveBeenLastCalledWith(false, { + status: 'closed' + }) + ); + expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); + }); + + it('drops the center hit-strip when spotlightClicks is set', async () => { + const { rerender } = render( +
+ + +
+ ); + await waitFor(() => + expect(document.querySelectorAll(`.${styles.overlayHit}`).length).toBe(5) + ); + rerender( +
+ + +
+ ); + await waitFor(() => + expect(document.querySelectorAll(`.${styles.overlayHit}`).length).toBe(4) + ); + }); + + it('exposes imperative actions through actionsRef', async () => { + const Harness = () => { + const actionsRef = useRef(null); + return ( +
+ + + + +
+ ); + }; + render(); + fireEvent.click(screen.getByText('launch')); + await waitFor(() => + expect(screen.getByText('Step two')).toBeInTheDocument() + ); + }); + + it('jumps to an arbitrary step with go()', async () => { + const Harness = () => { + const actionsRef = useRef(null); + return ( +
+ + + + +
+ ); + }; + render(); + await waitFor(() => + expect(screen.getByText('Step one')).toBeInTheDocument() + ); + fireEvent.click(screen.getByText('jump')); + await waitFor(() => + expect(screen.getByText('Step three')).toBeInTheDocument() + ); + }); + + it('ignores navigation actions while closed', () => { + const onStepChange = vi.fn(); + const Harness = () => { + const actionsRef = useRef(null); + return ( +
+ + + + +
+ ); + }; + render(); + fireEvent.click(screen.getByText('poke')); + expect(onStepChange).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('supports controlled open and stepIndex', async () => { + const Controlled = () => { + const [index, setIndex] = useState(0); + return ( +
+ + + + +
+ ); + }; + render(); + await waitFor(() => + expect(screen.getByText('Step one')).toBeInTheDocument() + ); + fireEvent.click(screen.getByText('jump')); + await waitFor(() => + expect(screen.getByText('Step two')).toBeInTheDocument() + ); + }); + + it('supports custom popover content via a render function', async () => { + render( +
+ + + + {({ step, index, totalSteps }) => ( +
+ custom:{String(step.id)}:{index + 1}/{totalSteps} +
+ )} +
+
+
+ ); + await waitFor(() => + expect(screen.getByText('custom:one:1/1')).toBeInTheDocument() + ); + }); + + it('renders composable parts and skips with skipped status', async () => { + const onOpenChange = vi.fn(); + render( +
+ + + + + + + + + +
+ ); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'skipped' }); + }); + + it('formats progress with a custom formatter', async () => { + render( +
+ + + + `${i + 1}/${total}`} /> + + +
+ ); + await waitFor(() => expect(screen.getByText('1/3')).toBeInTheDocument()); + }); + + it('exposes tour state through useTour', async () => { + const Readout = () => { + const { open, index, totalSteps, isFirstStep, isLastStep, status } = + useTour(); + return ( +
+ {String(open)}:{index}:{totalSteps}:{String(isFirstStep)}: + {String(isLastStep)}:{status} +
+ ); + }; + render( +
+ + + + +
+ ); + await waitFor(() => + expect(screen.getByTestId('readout')).toHaveTextContent( + 'true:0:3:true:false:running' + ) + ); + }); + + it('moves focus into the card when the step changes', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(screen.getByText('Step two')).toBeInTheDocument() + ); + await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus()); + }); + + it('throws when a part is used outside ', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => render()).toThrow(/within /); + spy.mockRestore(); + }); +}); diff --git a/packages/raystack/components/tour/index.tsx b/packages/raystack/components/tour/index.tsx new file mode 100644 index 000000000..94bbcad9e --- /dev/null +++ b/packages/raystack/components/tour/index.tsx @@ -0,0 +1,17 @@ +export { Tour } from './tour'; +export { type UseTourReturn, useTour } from './tour-context'; +export type { TourOverlayProps } from './tour-overlay'; +export type { TourProgressProps } from './tour-parts'; +export type { TourPopoverProps } from './tour-popover'; +export type { TourRootProps } from './tour-root'; +export type { + TourActions, + TourAlign, + TourEndStatus, + TourEvent, + TourRenderProps, + TourSide, + TourStatus, + TourStep, + TourTarget +} from './types'; diff --git a/packages/raystack/components/tour/tour-context.tsx b/packages/raystack/components/tour/tour-context.tsx new file mode 100644 index 000000000..817b994a2 --- /dev/null +++ b/packages/raystack/components/tour/tour-context.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { TourActions, TourStatus, TourStep } from './types'; + +export interface TourContextValue { + steps: TourStep[]; + index: number; + step: TourStep | null; + open: boolean; + status: TourStatus; + /** Resolved anchor element for the active step; null for detached steps or while waiting. */ + anchor: Element | null; + /** Resolved element to spotlight; usually the anchor. */ + spotlightElement: Element | null; + /** Whether the popover should be visible (tour open and target resolved). */ + popoverOpen: boolean; + /** Tour-level default for hiding the dimmed overlay. */ + disableOverlay: boolean; + actions: TourActions; +} + +export const TourContext = createContext(null); + +export function useTourContext(part: string): TourContextValue { + const context = useContext(TourContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context; +} + +export interface UseTourReturn { + open: boolean; + status: TourStatus; + index: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + /** Active step, or null while the tour is closed. */ + step: TourStep | null; + actions: TourActions; +} + +/** + * Access the active tour from anywhere inside ``, e.g. to build fully + * custom popover content or to drive the tour from the spotlighted UI. + */ +export function useTour(): UseTourReturn { + const { steps, index, step, status, actions, open } = + useTourContext('useTour'); + return { + open, + status, + actions, + step, + index, + totalSteps: steps.length, + isFirstStep: index <= 0, + isLastStep: index >= steps.length - 1 + }; +} diff --git a/packages/raystack/components/tour/tour-overlay.tsx b/packages/raystack/components/tour/tour-overlay.tsx new file mode 100644 index 000000000..f5389bc31 --- /dev/null +++ b/packages/raystack/components/tour/tour-overlay.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { type ComponentPropsWithoutRef, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './tour.module.css'; +import { useTourContext } from './tour-context'; + +export interface TourOverlayProps extends ComponentPropsWithoutRef<'div'> { + /** + * Space between the target and the spotlight edge in pixels; steps can + * override. @default 4 + */ + spotlightPadding?: number; + /** Spotlight corner radius in pixels; steps can override. @default 6 */ + spotlightRadius?: number; + /** + * Allow pointer interaction with the spotlighted element; steps can + * override. @default false + */ + spotlightClicks?: boolean; +} + +interface SpotlightRect { + x: number; + y: number; + width: number; + height: number; +} + +function rectsEqual(a: SpotlightRect, b: SpotlightRect) { + return ( + Math.abs(a.x - b.x) < 0.5 && + Math.abs(a.y - b.y) < 0.5 && + Math.abs(a.width - b.width) < 0.5 && + Math.abs(a.height - b.height) < 0.5 + ); +} + +/** + * Tracks an element's viewport rect with a frame loop, so the spotlight + * follows through scrolling (any container), resizes and CSS animations — e.g. + * a dialog's entry transform, which fires no scroll/resize events. Re-renders + * only when the rect actually changes. + * + * When the element changes the previous rect is intentionally kept until the + * next one is measured, so the hole glides between steps instead of blinking. + * A disconnected element is skipped (rect frozen at its last good value) rather + * than collapsed to `0,0`, which would flash the whole screen dim. + */ +function useSpotlightRect(element: Element | null): SpotlightRect | null { + const [rect, setRect] = useState(null); + + useEffect(() => { + if (!element) { + setRect(null); + return; + } + let frame = 0; + const track = () => { + if (element.isConnected) { + const next = element.getBoundingClientRect(); + setRect(prev => + prev && rectsEqual(prev, next) + ? prev + : { x: next.x, y: next.y, width: next.width, height: next.height } + ); + } + frame = requestAnimationFrame(track); + }; + track(); + return () => cancelAnimationFrame(frame); + }, [element]); + + return element ? rect : null; +} + +export function TourOverlay({ + spotlightPadding = 4, + spotlightRadius = 6, + spotlightClicks: spotlightClicksProp = false, + className, + ...rest +}: TourOverlayProps) { + const { + open, + step, + status, + spotlightElement, + disableOverlay: disableOverlayTour + } = useTourContext('Tour.Overlay'); + const disabled = step?.disableOverlay ?? disableOverlayTour; + + // A step is "targeted" when it points at or spotlights an element. Detached + // steps (welcome/finish) dim the whole viewport with no cutout. + const targeted = + step != null && (step.target != null || step.spotlightTarget != null); + const rect = useSpotlightRect( + open && !disabled && targeted ? spotlightElement : null + ); + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!open || !mounted || disabled || !step) return null; + // A targeted step with no measured rect is still resolving (or its target + // just unmounted). Render nothing rather than a full-screen dim with no card. + if (targeted && (!spotlightElement || !rect)) return null; + + const padding = step.spotlightPadding ?? spotlightPadding; + const radius = step.spotlightRadius ?? spotlightRadius; + const spotlightClicks = step.spotlightClicks ?? spotlightClicksProp; + + const hole = + targeted && rect + ? { + x: rect.x - padding, + y: rect.y - padding, + width: rect.width + padding * 2, + height: rect.height + padding * 2 + } + : null; + + return createPortal( +
+
+ {hole ? ( + <> + {/* Hit strips block clicks around the hole. The strip over the hole + is dropped when spotlightClicks lets clicks reach the target. */} +
+
+
+
+ {!spotlightClicks && ( +
+ )} + + ) : ( +
+ )} +
, + document.body + ); +} + +TourOverlay.displayName = 'Tour.Overlay'; diff --git a/packages/raystack/components/tour/tour-parts.tsx b/packages/raystack/components/tour/tour-parts.tsx new file mode 100644 index 000000000..cfc44004c --- /dev/null +++ b/packages/raystack/components/tour/tour-parts.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import type { ComponentProps, ReactNode } from 'react'; +import { Button } from '../button'; +import { Flex } from '../flex'; +import { IconButton } from '../icon-button'; +import { Text } from '../text'; +import styles from './tour.module.css'; +import { useTourContext } from './tour-context'; + +export function TourTitle({ + className, + children, + ...props +}: PopoverPrimitive.Title.Props) { + const { step } = useTourContext('Tour.Title'); + const content = children ?? step?.title; + if (content == null) return null; + return ( + + {content} + + ); +} +TourTitle.displayName = 'Tour.Title'; + +export function TourDescription({ + className, + children, + ...props +}: PopoverPrimitive.Description.Props) { + const { step } = useTourContext('Tour.Description'); + const content = children ?? step?.content; + if (content == null) return null; + return ( + + {content} + + ); +} +TourDescription.displayName = 'Tour.Description'; + +export interface TourProgressProps extends ComponentProps { + /** Custom formatter, e.g. show "2/5" instead of "2 of 5". */ + format?: (index: number, total: number) => ReactNode; +} + +export function TourProgress({ format, ...props }: TourProgressProps) { + const { index, steps } = useTourContext('Tour.Progress'); + return ( + + {format ? format(index, steps.length) : `${index + 1} of ${steps.length}`} + + ); +} +TourProgress.displayName = 'Tour.Progress'; + +export function TourNext({ + children, + onClick, + ...props +}: ComponentProps) { + const { actions, index, steps } = useTourContext('Tour.Next'); + const isLastStep = index >= steps.length - 1; + return ( + + ); +} +TourNext.displayName = 'Tour.Next'; + +export function TourPrev({ + children, + onClick, + ...props +}: ComponentProps) { + const { actions } = useTourContext('Tour.Prev'); + return ( + + ); +} +TourPrev.displayName = 'Tour.Prev'; + +export function TourSkip({ + children, + onClick, + ...props +}: ComponentProps) { + const { actions } = useTourContext('Tour.Skip'); + return ( + + ); +} +TourSkip.displayName = 'Tour.Skip'; + +export function TourClose({ + onClick, + children, + ...props +}: ComponentProps) { + const { actions } = useTourContext('Tour.Close'); + return ( + { + onClick?.(event); + if (!event.defaultPrevented) actions.stop(); + }} + > + {children ?? + ); +} +TourClose.displayName = 'Tour.Close'; + +/** + * The standard card: title + close, description, then progress and the + * navigation buttons. Rendered by `Tour.Popover` when no children are given. + */ +export function TourDefaultLayout() { + const { index } = useTourContext('Tour.Popover'); + return ( + <> + + + + + + + + + {index > 0 && } + + + + + ); +} +TourDefaultLayout.displayName = 'Tour.DefaultLayout'; diff --git a/packages/raystack/components/tour/tour-popover.tsx b/packages/raystack/components/tour/tour-popover.tsx new file mode 100644 index 000000000..366b48a2e --- /dev/null +++ b/packages/raystack/components/tour/tour-popover.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { + type CSSProperties, + type ReactNode, + useEffect, + useMemo, + useRef +} from 'react'; +import styles from './tour.module.css'; +import { useTourContext } from './tour-context'; +import { TourDefaultLayout } from './tour-parts'; +import type { TourAlign, TourRenderProps, TourSide } from './types'; + +export interface TourPopoverProps { + /** + * Default side of the target to place the popover on; steps can override. + * @default 'bottom' + */ + side?: TourSide; + /** Default alignment against the target; steps can override. @default 'center' */ + align?: TourAlign; + /** Default distance to the target in pixels; steps can override. @default 12 */ + sideOffset?: number; + /** @default true */ + showArrow?: boolean; + className?: string; + style?: CSSProperties; + /** + * Popover content: static nodes or a render function receiving the active + * step. Defaults to the standard layout built from `Tour.Title`, + * `Tour.Description`, `Tour.Progress` and the navigation buttons. + */ + children?: ReactNode | ((props: TourRenderProps) => ReactNode); +} + +export function TourPopover({ + side = 'bottom', + align = 'center', + sideOffset = 12, + showArrow = true, + className, + style, + children +}: TourPopoverProps) { + const { popoverOpen, anchor, step, steps, index, status, actions } = + useTourContext('Tour.Popover'); + const detached = step != null && step.target == null; + const popupRef = useRef(null); + + // Detached steps anchor to the viewport center and open upwards, which + // optically centers the popup while keeping it positioner-driven (so it + // still glides to and from regular steps). The rect is read lazily, so the + // positioner re-centers it on window resize. + const centerAnchor = useMemo( + () => ({ + getBoundingClientRect: () => + DOMRect.fromRect({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + width: 0, + height: 0 + }) + }), + [] + ); + + // Keyboard continuity: the step content remounts on every step, so move + // focus back into the card when the step changes (and on open). Steps that + // invite page interaction (`spotlightClicks`) keep focus where it is. + const spotlightClicks = step?.spotlightClicks ?? false; + // biome-ignore lint/correctness/useExhaustiveDependencies: `index` is intentional — re-running on step change is how the card refocuses. + useEffect(() => { + if (!popoverOpen || spotlightClicks) return; + popupRef.current?.focus({ preventScroll: true }); + }, [popoverOpen, index, spotlightClicks]); + + const renderProps: TourRenderProps | null = step + ? { + step, + index, + totalSteps: steps.length, + isFirstStep: index <= 0, + isLastStep: index >= steps.length - 1, + status, + actions + } + : null; + + return ( + { + if (nextOpen) return; + // Tours are persistent: outside presses and focus moves (e.g. into a + // spotlighted input) must not dismiss the step. Escape still exits. + if (eventDetails.reason === 'escape-key') actions.stop(); + }} + > + + + + {showArrow && !detached && ( + + + + )} + {renderProps && ( +
+ {typeof children === 'function' + ? children(renderProps) + : (children ?? )} +
+ )} +
+
+
+
+ ); +} + +TourPopover.displayName = 'Tour.Popover'; diff --git a/packages/raystack/components/tour/tour-root.tsx b/packages/raystack/components/tour/tour-root.tsx new file mode 100644 index 000000000..d9b775769 --- /dev/null +++ b/packages/raystack/components/tour/tour-root.tsx @@ -0,0 +1,343 @@ +'use client'; + +import { useControlled } from '@base-ui/utils/useControlled'; +import { + type ReactNode, + type RefObject, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef +} from 'react'; +import { TourContext, type TourContextValue } from './tour-context'; +import { TourOverlay } from './tour-overlay'; +import { TourPopover } from './tour-popover'; +import type { + TourActions, + TourEndStatus, + TourEvent, + TourStatus, + TourStep +} from './types'; +import { resolveTourTarget, useTourTarget } from './use-tour-target'; + +/** + * Whether `el` is fully visible in the viewport *and* within every scrollable + * ancestor. The ancestor check matters: an element scrolled out of a nested + * `overflow: auto` container can still sit within the window's bounds, so a + * viewport-only test would wrongly skip scrolling it into view. + */ +function isElementInView(el: Element): boolean { + const rect = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== document.documentElement) { + const { overflowX, overflowY } = getComputedStyle(node); + const scrollable = /(auto|scroll|overlay)/.test(overflowX + overflowY); + if (scrollable) { + const bounds = node.getBoundingClientRect(); + if ( + rect.top < bounds.top || + rect.bottom > bounds.bottom || + rect.left < bounds.left || + rect.right > bounds.right + ) { + return false; + } + } + node = node.parentElement; + } + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + ); +} + +export interface TourRootProps { + /** Ordered list of steps that make up the tour. */ + steps: TourStep[]; + /** Whether the tour is currently open (controlled). */ + open?: boolean; + /** Whether the tour is initially open. @default false */ + defaultOpen?: boolean; + /** Called when the tour opens or closes. `status` is set when closing. */ + onOpenChange?: (open: boolean, details: { status?: TourEndStatus }) => void; + /** Active step index (controlled). */ + stepIndex?: number; + /** Initially active step when uncontrolled. @default 0 */ + defaultStepIndex?: number; + /** Called when the active step changes. */ + onStepChange?: (index: number, step: TourStep) => void; + /** Receives every tour lifecycle event. */ + onEvent?: (event: TourEvent) => void; + /** A ref populated with the imperative tour controls. */ + actionsRef?: RefObject; + /** + * How long to wait for a step target to appear in the DOM before giving + * up, in ms. @default 5000 + */ + targetTimeout?: number; + /** + * What to do when a step target cannot be found: skip to the next step or + * stop the tour. Emits `error:target-not-found` either way. + * @default 'skip' + */ + targetNotFound?: 'skip' | 'stop'; + /** + * Hide the dimmed overlay for the whole tour — only the popover is shown and + * the page stays fully interactive. Steps can override with + * `step.disableOverlay`. @default false + */ + disableOverlay?: boolean; + /** + * Tour UI. Defaults to `` plus `` with the + * standard card layout; compose the parts to customize. + */ + children?: ReactNode; +} + +export function TourRoot({ + steps, + open: openProp, + defaultOpen = false, + onOpenChange, + stepIndex: stepIndexProp, + defaultStepIndex = 0, + onStepChange, + onEvent, + actionsRef, + targetTimeout = 5000, + targetNotFound = 'skip', + disableOverlay = false, + children +}: TourRootProps) { + const [open, setOpenUnwrapped] = useControlled({ + controlled: openProp, + default: defaultOpen, + name: 'Tour', + state: 'open' + }); + const [indexUnclamped, setIndexUnwrapped] = useControlled({ + controlled: stepIndexProp, + default: defaultStepIndex, + name: 'Tour', + state: 'stepIndex' + }); + const index = Math.min( + Math.max(indexUnclamped, 0), + Math.max(steps.length - 1, 0) + ); + const step = open ? (steps[index] ?? null) : null; + + // Latest-value refs keep `actions` referentially stable across renders. + const stepsRef = useRef(steps); + stepsRef.current = steps; + const indexRef = useRef(index); + indexRef.current = index; + const openRef = useRef(open); + openRef.current = open; + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + const onStepChangeRef = useRef(onStepChange); + onStepChangeRef.current = onStepChange; + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + const targetNotFoundRef = useRef(targetNotFound); + targetNotFoundRef.current = targetNotFound; + const endStatusRef = useRef('closed'); + + const emit = useCallback( + (event: TourEvent) => onEventRef.current?.(event), + [] + ); + + const setOpen = useCallback( + (nextOpen: boolean, status?: TourEndStatus) => { + if (status) endStatusRef.current = status; + setOpenUnwrapped(nextOpen); + onOpenChangeRef.current?.(nextOpen, { + status: nextOpen ? undefined : endStatusRef.current + }); + }, + [setOpenUnwrapped] + ); + + const setIndex = useCallback( + (nextIndex: number) => { + setIndexUnwrapped(nextIndex); + onStepChangeRef.current?.(nextIndex, stepsRef.current[nextIndex]); + }, + [setIndexUnwrapped] + ); + + // Everything except `start` is a no-op while the tour is closed. + const actions = useMemo( + () => ({ + start: (at = 0) => { + const clamped = Math.min( + Math.max(at, 0), + Math.max(stepsRef.current.length - 1, 0) + ); + setIndex(clamped); + setOpen(true); + }, + stop: () => { + if (openRef.current) setOpen(false, 'closed'); + }, + skip: () => { + if (openRef.current) setOpen(false, 'skipped'); + }, + next: () => { + if (!openRef.current) return; + if (indexRef.current >= stepsRef.current.length - 1) { + setOpen(false, 'finished'); + } else { + setIndex(indexRef.current + 1); + } + }, + prev: () => { + if (openRef.current && indexRef.current > 0) { + setIndex(indexRef.current - 1); + } + }, + go: at => { + if (openRef.current && at >= 0 && at < stepsRef.current.length) { + setIndex(at); + } + } + }), + [setIndex, setOpen] + ); + + useImperativeHandle(actionsRef, () => actions, [actions]); + + const handleTargetNotFound = useCallback(() => { + const at = indexRef.current; + emit({ + type: 'error:target-not-found', + index: at, + step: stepsRef.current[at] + }); + if ( + targetNotFoundRef.current === 'stop' || + at >= stepsRef.current.length - 1 + ) { + setOpen(false, 'closed'); + } else { + setIndex(at + 1); + } + }, [emit, setIndex, setOpen]); + + const { element: anchor, state: targetState } = useTourTarget(step?.target, { + enabled: open && step != null, + timeout: targetTimeout, + onNotFound: handleTargetNotFound + }); + const { element: spotlightOverride } = useTourTarget(step?.spotlightTarget, { + enabled: open && step?.spotlightTarget != null, + timeout: targetTimeout + }); + const spotlightElement = spotlightOverride ?? anchor; + + const popoverOpen = open && step != null && targetState === 'found'; + const status: TourStatus = !open + ? 'idle' + : targetState === 'found' + ? 'running' + : 'waiting'; + + // Starts false (not `open`) so a tour mounted already-open still emits + // `tour:start`. + const prevOpenRef = useRef(false); + useEffect(() => { + if (prevOpenRef.current === open) return; + prevOpenRef.current = open; + if (open) { + emit({ + type: 'tour:start', + index: indexRef.current, + step: stepsRef.current[indexRef.current] + }); + } else { + emit({ + type: 'tour:end', + index: indexRef.current, + status: endStatusRef.current + }); + endStatusRef.current = 'closed'; + } + }, [open, emit]); + + const lastActiveIndexRef = useRef(-1); + useEffect(() => { + if (!open) { + lastActiveIndexRef.current = -1; + return; + } + if (!popoverOpen || !step || lastActiveIndexRef.current === index) return; + lastActiveIndexRef.current = index; + emit({ type: 'step:active', index, step }); + }, [open, popoverOpen, index, step, emit]); + + // Scroll the target into view once, when the step becomes active. + useEffect(() => { + if (!popoverOpen || !step || step.disableScroll) return; + const el = resolveTourTarget(step.scrollTarget) ?? anchor; + if (!el?.isConnected) return; + // `isElementInView` also checks scrollable ancestors, so a target clipped + // inside a nested scroll container counts as hidden and gets revealed. + if (isElementInView(el)) return; + const reduceMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + el.scrollIntoView({ + block: 'center', + inline: 'nearest', + behavior: reduceMotion ? 'auto' : 'smooth' + }); + // `anchor` is intentionally in the deps: a late-resolving target should + // scroll into view the moment it lands, not only on index change. + }, [popoverOpen, step, anchor]); + + const contextValue = useMemo( + () => ({ + steps, + index, + step, + open, + status, + anchor, + spotlightElement, + popoverOpen, + disableOverlay, + actions + }), + [ + steps, + index, + step, + open, + status, + anchor, + spotlightElement, + popoverOpen, + disableOverlay, + actions + ] + ); + + return ( + + {children ?? ( + <> + + + + )} + + ); +} + +TourRoot.displayName = 'Tour'; diff --git a/packages/raystack/components/tour/tour.module.css b/packages/raystack/components/tour/tour.module.css new file mode 100644 index 000000000..43bf6d772 --- /dev/null +++ b/packages/raystack/components/tour/tour.module.css @@ -0,0 +1,156 @@ +/* Tours sit above other portalled surfaces (dialogs, popovers) so steps can + spotlight elements inside them. */ +.overlay { + position: fixed; + inset: 0; + z-index: calc(var(--rs-z-index-portal) + 1); + overflow: hidden; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .overlay { + animation: tour-fade-in 250ms ease; + } +} + +@keyframes tour-fade-in { + from { + opacity: 0; + } +} + +/* The dimming layer is this element's box-shadow; the element itself is the + transparent cutout over the target, so moving it animates the cutout. */ +.spotlight { + position: absolute; + pointer-events: none; + box-shadow: 0 0 0 200vmax var(--rs-color-overlay-black-a5); +} + +@media (prefers-reduced-motion: no-preference) { + .spotlight { + transition: + left 300ms cubic-bezier(0.22, 1, 0.36, 1), + top 300ms cubic-bezier(0.22, 1, 0.36, 1), + width 300ms cubic-bezier(0.22, 1, 0.36, 1), + height 300ms cubic-bezier(0.22, 1, 0.36, 1), + border-radius 300ms cubic-bezier(0.22, 1, 0.36, 1); + } +} + +.overlayHit { + position: absolute; + pointer-events: auto; +} + +.positioner { + z-index: calc(var(--rs-z-index-portal) + 2); +} + +/* Glide between step targets, but not while the popup is mounting — otherwise + the first open would animate in from the viewport origin. */ +@media (prefers-reduced-motion: no-preference) { + .positioner:has(.popup:not([data-starting-style])) { + transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1); + } +} + +.popup { + box-sizing: border-box; + outline: 0; + width: max-content; + min-width: 16rem; + max-width: 20rem; + padding: var(--rs-space-5); + background-color: var(--rs-color-background-base-primary); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-3); + box-shadow: var(--rs-shadow-soft); + color: var(--rs-color-foreground-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + transform-origin: var(--transform-origin); + transition: + opacity 200ms ease, + scale 200ms ease; +} + +.popup[data-starting-style], +.popup[data-ending-style] { + opacity: 0; + scale: 0.96; +} + +.stepContent { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); +} + +@media (prefers-reduced-motion: no-preference) { + .stepContent { + animation: tour-step-in 250ms cubic-bezier(0.22, 1, 0.36, 1); + } +} + +@keyframes tour-step-in { + from { + opacity: 0; + translate: 0 var(--rs-space-1); + } +} + +.arrow { + display: flex; + filter: drop-shadow(0 1px 0 var(--rs-color-border-base-primary)) + drop-shadow(0 1px 1px var(--rs-color-border-base-primary)); +} + +.arrow svg { + display: block; + color: var(--rs-color-background-base-primary); +} + +.arrow[data-side="top"] { + bottom: -6px; +} + +.arrow[data-side="bottom"] { + top: 0; + transform: translateY(-100%) rotate(180deg); +} + +.arrow[data-side="left"], +.arrow[data-side="inline-start"] { + right: 0; + transform: translateY(-50%) translateX(100%) rotate(-90deg); +} + +.arrow[data-side="right"], +.arrow[data-side="inline-end"] { + left: 0; + transform: translateY(-50%) translateX(-100%) rotate(90deg); +} + +.title { + margin: 0; + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); + font-weight: var(--rs-font-weight-medium); + color: var(--rs-color-foreground-base-primary); +} + +.description { + margin: 0; + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-secondary); +} + +.footer { + margin-top: var(--rs-space-2); +} diff --git a/packages/raystack/components/tour/tour.tsx b/packages/raystack/components/tour/tour.tsx new file mode 100644 index 000000000..db775e70e --- /dev/null +++ b/packages/raystack/components/tour/tour.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { TourOverlay } from './tour-overlay'; +import { + TourClose, + TourDescription, + TourNext, + TourPrev, + TourProgress, + TourSkip, + TourTitle +} from './tour-parts'; +import { TourPopover } from './tour-popover'; +import { TourRoot } from './tour-root'; + +export const Tour = Object.assign(TourRoot, { + Overlay: TourOverlay, + Popover: TourPopover, + Title: TourTitle, + Description: TourDescription, + Progress: TourProgress, + Next: TourNext, + Prev: TourPrev, + Skip: TourSkip, + Close: TourClose +}); diff --git a/packages/raystack/components/tour/types.ts b/packages/raystack/components/tour/types.ts new file mode 100644 index 000000000..9b00a9bce --- /dev/null +++ b/packages/raystack/components/tour/types.ts @@ -0,0 +1,99 @@ +import type * as React from 'react'; + +/** + * Reference to an element on the page. Accepts a CSS selector, an element, + * a React ref or a function returning the element. Resolved lazily each time + * the step activates, so refs and selectors can point at elements that mount + * after the tour starts. + */ +export type TourTarget = + | string + | Element + | React.RefObject + | (() => Element | null); + +export type TourSide = 'top' | 'right' | 'bottom' | 'left'; +export type TourAlign = 'start' | 'center' | 'end'; + +export interface TourStep { + /** Stable identifier reported in events. Falls back to the step index. */ + id?: string; + /** + * Element the step is anchored to and spotlights. Omit to render the step + * centered in the viewport, detached from any element (welcome / finish + * steps). + */ + target?: TourTarget; + /** Rendered by `Tour.Title` and the default popover layout. */ + title?: React.ReactNode; + /** Rendered by `Tour.Description` and the default popover layout. */ + content?: React.ReactNode; + /** Side of the target to place the popover on. @default 'bottom' */ + side?: TourSide; + /** Alignment against the target. @default 'center' */ + align?: TourAlign; + /** Distance between the target and the popover in pixels. */ + sideOffset?: number; + /** Element to spotlight when it differs from the popover anchor. */ + spotlightTarget?: TourTarget; + /** Space between the target and the spotlight edge in pixels. */ + spotlightPadding?: number; + /** Spotlight corner radius in pixels. */ + spotlightRadius?: number; + /** Allow pointer interaction with the spotlighted element. */ + spotlightClicks?: boolean; + /** + * Hide the dimmed overlay on this step, overriding the tour-level + * `disableOverlay`. Without the overlay the whole page stays interactive. + */ + disableOverlay?: boolean; + /** Element to scroll into view when it differs from the target. */ + scrollTarget?: TourTarget; + /** Skip scrolling the target into view when the step activates. */ + disableScroll?: boolean; + /** Arbitrary data echoed back in events and render props. */ + data?: unknown; +} + +/** How a tour ended, reported on `tour:end` and `onOpenChange`. */ +export type TourEndStatus = 'finished' | 'skipped' | 'closed'; + +/** + * `idle` — the tour is closed. `waiting` — the active step's target is not in + * the DOM yet and the tour is observing for it to appear. `running` — the + * target is resolved and the step is showing. + */ +export type TourStatus = 'idle' | 'waiting' | 'running'; + +export type TourEvent = + | { type: 'tour:start'; index: number; step?: TourStep } + | { type: 'step:active'; index: number; step: TourStep } + | { type: 'error:target-not-found'; index: number; step: TourStep } + | { type: 'tour:end'; index: number; status: TourEndStatus }; + +/** Imperative tour controls, also available through `useTour`. */ +export interface TourActions { + /** Open the tour, optionally at a given step. The only action that runs while closed. */ + start: (index?: number) => void; + /** Advance to the next step; finishes the tour past the last step. */ + next: () => void; + /** Return to the previous step. */ + prev: () => void; + /** Jump to an arbitrary step. */ + go: (index: number) => void; + /** End the tour with the `skipped` status. */ + skip: () => void; + /** End the tour with the `closed` status. */ + stop: () => void; +} + +/** Passed to a `Tour.Popover` render function while a step is active. */ +export interface TourRenderProps { + step: TourStep; + index: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + status: TourStatus; + actions: TourActions; +} diff --git a/packages/raystack/components/tour/use-tour-target.ts b/packages/raystack/components/tour/use-tour-target.ts new file mode 100644 index 000000000..ec8c19e08 --- /dev/null +++ b/packages/raystack/components/tour/use-tour-target.ts @@ -0,0 +1,125 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { TourTarget } from './types'; + +/** + * Resolve a {@link TourTarget} to a live element. Returns `null` when the + * target is missing or (for element/ref targets) detached from the document — + * connectedness is the caller's contract, so this never hands back an orphaned + * node. + */ +export function resolveTourTarget( + target: TourTarget | null | undefined +): Element | null { + if (target == null || typeof document === 'undefined') return null; + if (typeof target === 'string') { + try { + return document.querySelector(target); + } catch { + // An invalid selector should degrade to "not found", not throw. + return null; + } + } + if (typeof target === 'function') return target(); + if (target instanceof Element) return target; + return target.current ?? null; +} + +export type TourTargetState = 'idle' | 'resolving' | 'found'; + +interface UseTourTargetOptions { + /** Whether the target should be resolved at all. */ + enabled: boolean; + /** How long to wait for a missing target before giving up, in ms. */ + timeout: number; + /** Called once when a target never appears within `timeout`. */ + onNotFound?: () => void; +} + +/** + * Resolves a step target to a connected element and keeps it accurate for the + * lifetime of the step. + * + * - Targets already in the DOM resolve synchronously (`found`). + * - Targets not mounted yet — lazy content, route transitions, dialog bodies — + * are awaited with a `MutationObserver` and resolve the moment they appear. + * - The observer stays live while `found`, so a target that later unmounts + * (its dialog closes, its route unmounts) flips back to `resolving` instead + * of leaving a stale, disconnected node behind. This is what prevents an + * orphaned spotlight/overlay when the element a step points at disappears. + * - If a target never appears within `timeout`, `onNotFound` fires once. + * + * A detached step (`target == null`) resolves to `{ element: null, state: 'found' }`. + */ +export function useTourTarget( + target: TourTarget | null | undefined, + { enabled, timeout, onNotFound }: UseTourTargetOptions +) { + const [element, setElement] = useState(null); + const [state, setState] = useState('idle'); + const onNotFoundRef = useRef(onNotFound); + onNotFoundRef.current = onNotFound; + + useEffect(() => { + if (!enabled) { + setElement(null); + setState('idle'); + return; + } + if (target == null) { + // Detached step: nothing to resolve, the popover renders centered. + setElement(null); + setState('found'); + return; + } + + let timer: ReturnType | undefined; + const clearTimer = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + const startTimer = () => { + if (timer !== undefined) return; + timer = setTimeout(() => { + timer = undefined; + onNotFoundRef.current?.(); + }, timeout); + }; + + // Runs on mount and on every batched DOM mutation. Functional setState + // keeps this free of stale closures and avoids no-op re-renders. + const reconcile = () => { + const found = resolveTourTarget(target); + if (found?.isConnected) { + clearTimer(); + setElement(prev => (prev === found ? prev : found)); + setState(prev => (prev === 'found' ? prev : 'found')); + } else { + setElement(prev => (prev === null ? prev : null)); + setState(prev => (prev === 'resolving' ? prev : 'resolving')); + startTimer(); + } + }; + + reconcile(); + + const observer = new MutationObserver(reconcile); + observer.observe(document.body, { + childList: true, + subtree: true, + // Attribute changes can newly satisfy a selector target; element/ref/fn + // targets only care about mount/unmount, so skip the extra churn. + attributes: typeof target === 'string' + }); + + return () => { + observer.disconnect(); + clearTimer(); + }; + }, [target, enabled, timeout]); + + return { element, state }; +} diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 4983b3608..8ba89f509 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -116,4 +116,4 @@ export { type TourStep, type TourTarget, useTour -} from './components/tour-beta'; +} from './components/tour'; From 85aa0e00c812ef459bd6e8bc4b990663de3e791f Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 2 Jul 2026 15:34:38 +0530 Subject: [PATCH 2/3] feat: tour component --- apps/www/src/app/examples/tour/page.tsx | 161 ++++++++++++++++- .../content/docs/components/tour/index.mdx | 40 +++- .../src/content/docs/components/tour/props.ts | 16 +- docs/rfcs/003-guided-tour-component.md | 14 +- .../components/tour/__tests__/tour.test.tsx | 88 ++++++++- packages/raystack/components/tour/index.tsx | 5 +- .../{tour-popover.tsx => tour-content.tsx} | 50 +++-- .../raystack/components/tour/tour-context.tsx | 18 +- .../raystack/components/tour/tour-overlay.tsx | 104 ++++++++++- .../raystack/components/tour/tour-parts.tsx | 4 +- .../raystack/components/tour/tour-root.tsx | 171 ++++++++++++++++-- .../raystack/components/tour/tour.module.css | 67 +++++-- packages/raystack/components/tour/tour.tsx | 4 +- packages/raystack/components/tour/types.ts | 14 +- .../tour/use-prefers-reduced-motion.ts | 29 +++ .../components/tour/use-tour-target.ts | 11 +- 16 files changed, 688 insertions(+), 108 deletions(-) rename packages/raystack/components/tour/{tour-popover.tsx => tour-content.tsx} (73%) create mode 100644 packages/raystack/components/tour/use-prefers-reduced-motion.ts diff --git a/apps/www/src/app/examples/tour/page.tsx b/apps/www/src/app/examples/tour/page.tsx index 48f77d223..9ef26fbb0 100644 --- a/apps/www/src/app/examples/tour/page.tsx +++ b/apps/www/src/app/examples/tour/page.tsx @@ -533,7 +533,7 @@ function ActionGatedDemo() { > - + {({ step, index, isLastStep, actions }) => { const gated = index === 0; return ( @@ -561,7 +561,132 @@ function ActionGatedDemo() { ); }} - + + + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Custom card: fully bespoke content, styling, and controls */ +/* -------------------------------------------------------------------------- */ + +function CustomCardDemo() { + const actionsRef = useRef(null); + const [showArrow, setShowArrow] = useState(true); + + return ( + + + + + + }> + showArrow + + + +
+ Anchor for the custom card. +
+ + + + {({ step, index, totalSteps, isLastStep, isFirstStep, actions }) => ( + <> +
+ + {step.title} + +
+ + + {step.content} + + + + {Array.from({ length: totalSteps }).map((_, i) => ( + + ))} + + + {!isFirstStep && ( + + )} + + + + + + )} +
); @@ -581,6 +706,7 @@ function ProductTour() { const [showLateCard, setShowLateCard] = useState(false); const [lastIndex, setLastIndex] = useState(0); const [resumable, setResumable] = useState(false); + const [transition, setTransition] = useState<'fade' | 'move'>('fade'); const searchRef = useRef(null); const actionsRef = useRef(null); @@ -786,11 +912,26 @@ function ProductTour() { late-mounting and scrolled targets. Press Escape any time to leave. Isolated edge-case demos are further down the page. - + + + + setTransition(c ? 'move' : 'fade')} + /> + }> + Glide the card (transition="{transition}") — the + spotlight always fades + + + - + {({ step, index }) => { const data = (step.data ?? {}) as StepData; return ( @@ -990,7 +1132,7 @@ function ProductTour() { ); }} - + ); @@ -1030,6 +1172,7 @@ function EdgeCaseLab() { +
); diff --git a/apps/www/src/content/docs/components/tour/index.mdx b/apps/www/src/content/docs/components/tour/index.mdx index 3b49b27cc..c4abb1765 100644 --- a/apps/www/src/content/docs/components/tour/index.mdx +++ b/apps/www/src/content/docs/components/tour/index.mdx @@ -31,13 +31,13 @@ To compose the card yourself, pass the parts as children: ```tsx - + - + ``` @@ -47,7 +47,7 @@ To compose the card yourself, pass the parts as children: The root. Holds the steps and the open/index state, resolves targets, emits events, and owns the actions. Renders a context provider — its children default -to `` and ``. +to `` and ``. @@ -57,12 +57,12 @@ Each entry in the `steps` array. -### Tour.Popover +### Tour.Content The card. Accepts positioning defaults and either static children, a render function, or the default layout. - + ### Tour.Overlay @@ -135,7 +135,7 @@ useful for action-gated steps (draw a shape, open a panel). ```tsx - + {({ step, index, totalSteps, isLastStep, actions }) => ( {step.title} @@ -148,7 +148,7 @@ useful for action-gated steps (draw a shape, open a panel). )} - + ; // advance the draw step once the app reports a shape on the map @@ -174,9 +174,28 @@ const steps = [ ]; ``` +### Motion between steps + +The dimmed backdrop stays put for the whole tour — it never flashes away +between steps. Only the spotlight cutout animates: it fades shut (dimming the +old target), repositions while covered, and fades back open once the next +target has scrolled into view and settled, so the hole never slides across the +screen or opens at a position it is about to leave. + +The `transition` prop controls only the **popover card**. By default +(`transition="fade"`) the card cross-fades with the spotlight. Set +`transition="move"` to glide the card smoothly from one target to the next, +which suits walkthroughs whose consecutive targets sit close together. + +```tsx + +``` + +Both collapse to instant swaps under `prefers-reduced-motion: reduce`. + ## Accessibility -- The card is a Base UI `Popover`, so `Tour.Title` and `Tour.Description` provide +- The card is built on a Base UI `Popover`, so `Tour.Title` and `Tour.Description` provide its accessible name and description and it is exposed as a `dialog`. - Focus moves into the card when a step opens and when the step changes, so keyboard users can reach the navigation buttons. Steps with `spotlightClicks` @@ -184,5 +203,6 @@ const steps = [ - Escape ends the tour. Outside clicks and focus moves do not, so a step can keep focus on the element it highlights. - The tour is non-modal (`modal={false}`); it does not trap focus or lock scroll. -- Motion — the spotlight glide, card entry, and scroll-into-view — is enabled only - when `prefers-reduced-motion: no-preference`. +- Motion — the spotlight cutout's fade, the card fade or glide, and scroll-into-view — + is enabled only when `prefers-reduced-motion: no-preference`; otherwise steps swap + instantly. diff --git a/apps/www/src/content/docs/components/tour/props.ts b/apps/www/src/content/docs/components/tour/props.ts index fbd4bee8d..a16a6b2a0 100644 --- a/apps/www/src/content/docs/components/tour/props.ts +++ b/apps/www/src/content/docs/components/tour/props.ts @@ -55,13 +55,21 @@ export interface TourProps { */ targetNotFound?: 'skip' | 'stop'; + /** + * How the popover card travels between steps. `fade` cross-fades it at each + * target; `move` glides it smoothly from one target to the next. The spotlight + * always cross-fades regardless — it never slides. + * @defaultValue 'fade' + */ + transition?: 'fade' | 'move'; + /** * Hide the dimmed overlay for the whole tour — only the popover shows and the page stays interactive. * @defaultValue false */ disableOverlay?: boolean; - /** Tour UI. Defaults to `` + ``. */ + /** Tour UI. Defaults to `` + ``. */ children?: React.ReactNode; } @@ -144,9 +152,9 @@ export interface TourEvent { status?: 'finished' | 'skipped' | 'closed'; } -export interface TourPopoverProps { +export interface TourContentProps { /** - * Default side of the target to place the popover on; steps override. + * Default side of the target to place the card on; steps override. * @defaultValue 'bottom' */ side?: 'top' | 'right' | 'bottom' | 'left'; @@ -165,7 +173,7 @@ export interface TourPopoverProps { /** * Whether to render the pointing arrow. - * @defaultValue true + * @defaultValue false */ showArrow?: boolean; diff --git a/docs/rfcs/003-guided-tour-component.md b/docs/rfcs/003-guided-tour-component.md index 6febc9951..bcd3505f9 100644 --- a/docs/rfcs/003-guided-tour-component.md +++ b/docs/rfcs/003-guided-tour-component.md @@ -64,11 +64,11 @@ Build our own. A tour sits on top of the whole app and has to feel like part of ```tsx // root: holds steps + open/index state, resolves targets, emits events, owns actions // dimmed backdrop with a spotlight hole over the target - // the card: static children, a render function, or the default layout + // the card: static children, a render function, or the default layout // fall back to step.title / step.content // "n of total", optional format() // run your onClick, then the action - + // useTour() exposes the same state and actions anywhere inside ``` @@ -122,7 +122,7 @@ interface TourRootProps { disableOverlay?: boolean; // hide the dimmed overlay for the whole tour - children?: ReactNode; // defaults to + + children?: ReactNode; // defaults to + } ``` @@ -165,7 +165,7 @@ const actionsRef = useRef(null); onStepChange={setIndex} disableOverlay > - + {({ step, index, totalSteps, isLastStep, actions }) => ( {step.title} @@ -178,7 +178,7 @@ const actionsRef = useRef(null); )} - + ``` @@ -188,7 +188,7 @@ const actionsRef = useRef(null); ```tsx export const Tour = Object.assign(TourRoot, { - Overlay, Popover, Title, Description, Progress, Next, Prev, Skip, Close, + Overlay, Content, Title, Description, Progress, Next, Prev, Skip, Close, }); ``` @@ -244,7 +244,7 @@ The transparent `.spotlight` div sits over the target, so its shadow dims everyt ### Positioning, Dismissal, and Z Index -`Tour.Popover` is a `Popover.Root` (`modal={false}`) wrapping `Portal`, `Positioner`, and `Popup`, and takes `side`, `align`, `sideOffset`, and `showArrow` defaults that a step's `side`/`align`/`sideOffset` override. It anchors to the target, or for a detached step to a virtual viewport center anchor. Only escape closes the tour; outside clicks and focus moves are ignored, so a step can keep focus on the element it highlights. The overlay and positioner sit one and two layers above `--rs-z-index-portal`, so a step can spotlight content inside a dialog. +`Tour.Content` is a `Popover.Root` (`modal={false}`) wrapping `Portal`, `Positioner`, and `Popup`, and takes `side`, `align`, `sideOffset`, and `showArrow` defaults that a step's `side`/`align`/`sideOffset` override. It anchors to the target, or for a detached step to a virtual viewport center anchor. Only escape closes the tour; outside clicks and focus moves are ignored, so a step can keep focus on the element it highlights. The overlay and positioner sit one and two layers above `--rs-z-index-portal`, so a step can spotlight content inside a dialog. ### Accessibility diff --git a/packages/raystack/components/tour/__tests__/tour.test.tsx b/packages/raystack/components/tour/__tests__/tour.test.tsx index 0f7653f31..51b548a99 100644 --- a/packages/raystack/components/tour/__tests__/tour.test.tsx +++ b/packages/raystack/components/tour/__tests__/tour.test.tsx @@ -340,6 +340,38 @@ describe('Tour', () => { expect(document.querySelector('[data-status]')).not.toBeInTheDocument(); }); + it('treats a throwing function target as not-found instead of crashing', async () => { + const onOpenChange = vi.fn(); + const events: TourEvent[] = []; + render( + { + throw new Error('resolver not ready'); + }, + title: 'Boom step' + } + ]} + defaultOpen + targetTimeout={50} + targetNotFound='stop' + onOpenChange={onOpenChange} + onEvent={event => events.push(event)} + /> + ); + await waitFor(() => + expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'closed' }) + ); + expect(events.some(event => event.type === 'error:target-not-found')).toBe( + true + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('drops the center hit-strip when spotlightClicks is set', async () => { const { rerender } = render(
@@ -480,20 +512,20 @@ describe('Tour', () => { ); }); - it('supports custom popover content via a render function', async () => { + it('supports custom card content via a render function', async () => { render(
- + {({ step, index, totalSteps }) => (
custom:{String(step.id)}:{index + 1}/{totalSteps}
)} -
+
); @@ -510,12 +542,12 @@ describe('Tour', () => { One - + - +
); @@ -534,9 +566,9 @@ describe('Tour', () => { One - + `${i + 1}/${total}`} /> - +
); @@ -582,6 +614,48 @@ describe('Tour', () => { await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus()); }); + it('defaults to the fade transition and reveals the spotlight in view', async () => { + render(); + const overlay = await waitFor(() => { + const el = document.querySelector('[data-status]'); + expect(el).toBeInTheDocument(); + return el as HTMLElement; + }); + // The spotlight always cross-fades — it never carries the transition flag, + // which is a popover-only concern. Once the target is in view and its rect + // settles, the dim enters and the cutout opens. + expect(overlay).not.toHaveAttribute('data-transition'); + await waitFor(() => + expect(overlay).toHaveAttribute('data-entered', 'true') + ); + expect(overlay).toHaveAttribute('data-hole-open', 'true'); + // The popover carries the transition mode and its reveal flag. + const popup = screen.getByRole('dialog'); + expect(popup).toHaveAttribute('data-transition', 'fade'); + await waitFor(() => expect(popup).toHaveAttribute('data-visible', 'true')); + }); + + it('applies the move transition to the popover only', async () => { + render(); + const popup = await waitFor(() => { + const el = screen.getByRole('dialog'); + return el; + }); + // Move affects the popover: it stays visible and glides. + expect(popup).toHaveAttribute('data-transition', 'move'); + expect(popup).toHaveAttribute('data-visible', 'true'); + // The spotlight still cross-fades regardless of the mode — no transition + // flag on the overlay, and its cutout opens once the target settles. + const overlay = document.querySelector('[data-status]'); + expect(overlay).not.toHaveAttribute('data-transition'); + await waitFor(() => + expect(document.querySelector('[data-status]')).toHaveAttribute( + 'data-hole-open', + 'true' + ) + ); + }); + it('throws when a part is used outside ', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); expect(() => render()).toThrow(/within /); diff --git a/packages/raystack/components/tour/index.tsx b/packages/raystack/components/tour/index.tsx index 94bbcad9e..16c5e769a 100644 --- a/packages/raystack/components/tour/index.tsx +++ b/packages/raystack/components/tour/index.tsx @@ -1,8 +1,8 @@ export { Tour } from './tour'; +export type { TourContentProps } from './tour-content'; export { type UseTourReturn, useTour } from './tour-context'; export type { TourOverlayProps } from './tour-overlay'; export type { TourProgressProps } from './tour-parts'; -export type { TourPopoverProps } from './tour-popover'; export type { TourRootProps } from './tour-root'; export type { TourActions, @@ -13,5 +13,6 @@ export type { TourSide, TourStatus, TourStep, - TourTarget + TourTarget, + TourTransition } from './types'; diff --git a/packages/raystack/components/tour/tour-popover.tsx b/packages/raystack/components/tour/tour-content.tsx similarity index 73% rename from packages/raystack/components/tour/tour-popover.tsx rename to packages/raystack/components/tour/tour-content.tsx index 366b48a2e..216b2595b 100644 --- a/packages/raystack/components/tour/tour-popover.tsx +++ b/packages/raystack/components/tour/tour-content.tsx @@ -14,9 +14,9 @@ import { useTourContext } from './tour-context'; import { TourDefaultLayout } from './tour-parts'; import type { TourAlign, TourRenderProps, TourSide } from './types'; -export interface TourPopoverProps { +export interface TourContentProps { /** - * Default side of the target to place the popover on; steps can override. + * Default side of the target to place the card on; steps can override. * @default 'bottom' */ side?: TourSide; @@ -24,32 +24,51 @@ export interface TourPopoverProps { align?: TourAlign; /** Default distance to the target in pixels; steps can override. @default 12 */ sideOffset?: number; - /** @default true */ + /** Whether to render the pointing arrow. @default false */ showArrow?: boolean; className?: string; style?: CSSProperties; /** - * Popover content: static nodes or a render function receiving the active + * Card content: static nodes or a render function receiving the active * step. Defaults to the standard layout built from `Tour.Title`, * `Tour.Description`, `Tour.Progress` and the navigation buttons. */ children?: ReactNode | ((props: TourRenderProps) => ReactNode); } -export function TourPopover({ +export function TourContent({ side = 'bottom', align = 'center', sideOffset = 12, - showArrow = true, + showArrow = false, className, style, children -}: TourPopoverProps) { - const { popoverOpen, anchor, step, steps, index, status, actions } = - useTourContext('Tour.Popover'); +}: TourContentProps) { + const { + popoverOpen, + anchor, + step, + steps, + index, + status, + actions, + transition, + revealed + } = useTourContext('Tour.Content'); + // Positioning-only: the card is "detached" (centered) whenever it has no + // anchor target, even if the step still spotlights something elsewhere via + // `spotlightTarget`. Deliberately looser than the root's `detachedStep` + // (which also requires no `spotlightTarget`) — a centered card can still + // drive a spotlight. const detached = step != null && step.target == null; const popupRef = useRef(null); + // In `fade` mode the card is hidden until its target settles (`revealed`), + // so a step that scrolls or mounts late doesn't flash the card at a stale + // position — it fades in, in place. `move` mode keeps it visible and glides. + const visible = transition !== 'fade' || revealed; + // Detached steps anchor to the viewport center and open upwards, which // optically centers the popup while keeping it positioner-driven (so it // still glides to and from regular steps). The rect is read lazily, so the @@ -68,14 +87,14 @@ export function TourPopover({ ); // Keyboard continuity: the step content remounts on every step, so move - // focus back into the card when the step changes (and on open). Steps that - // invite page interaction (`spotlightClicks`) keep focus where it is. + // focus back into the card when the step changes (once it is revealed). Steps + // that invite page interaction (`spotlightClicks`) keep focus where it is. const spotlightClicks = step?.spotlightClicks ?? false; // biome-ignore lint/correctness/useExhaustiveDependencies: `index` is intentional — re-running on step change is how the card refocuses. useEffect(() => { - if (!popoverOpen || spotlightClicks) return; + if (!popoverOpen || !visible || spotlightClicks) return; popupRef.current?.focus({ preventScroll: true }); - }, [popoverOpen, index, spotlightClicks]); + }, [popoverOpen, visible, index, spotlightClicks]); const renderProps: TourRenderProps | null = step ? { @@ -108,12 +127,15 @@ export function TourPopover({ sideOffset={step?.sideOffset ?? sideOffset} collisionPadding={12} className={styles.positioner} + data-transition={transition} > {showArrow && !detached && ( @@ -146,4 +168,4 @@ export function TourPopover({ ); } -TourPopover.displayName = 'Tour.Popover'; +TourContent.displayName = 'Tour.Content'; diff --git a/packages/raystack/components/tour/tour-context.tsx b/packages/raystack/components/tour/tour-context.tsx index 817b994a2..423d5e2f3 100644 --- a/packages/raystack/components/tour/tour-context.tsx +++ b/packages/raystack/components/tour/tour-context.tsx @@ -1,7 +1,12 @@ 'use client'; import { createContext, useContext } from 'react'; -import type { TourActions, TourStatus, TourStep } from './types'; +import type { + TourActions, + TourStatus, + TourStep, + TourTransition +} from './types'; export interface TourContextValue { steps: TourStep[]; @@ -17,6 +22,17 @@ export interface TourContextValue { popoverOpen: boolean; /** Tour-level default for hiding the dimmed overlay. */ disableOverlay: boolean; + /** How the spotlight and popover travel between steps. */ + transition: TourTransition; + /** + * Whether the active step's target is settled and in view. Drives the + * fade-in of the spotlight (in every mode) and of the popover (in `fade` + * mode) so they appear only once the target has stopped moving, e.g. after + * scrolling into view. Computed the same way regardless of transition; in + * `move` mode the popover ignores it and glides, but the spotlight still + * respects it. + */ + revealed: boolean; actions: TourActions; } diff --git a/packages/raystack/components/tour/tour-overlay.tsx b/packages/raystack/components/tour/tour-overlay.tsx index f5389bc31..896d857ef 100644 --- a/packages/raystack/components/tour/tour-overlay.tsx +++ b/packages/raystack/components/tour/tour-overlay.tsx @@ -5,6 +5,7 @@ import { type ComponentPropsWithoutRef, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import styles from './tour.module.css'; import { useTourContext } from './tour-context'; +import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; export interface TourOverlayProps extends ComponentPropsWithoutRef<'div'> { /** @@ -28,7 +29,11 @@ interface SpotlightRect { height: number; } -function rectsEqual(a: SpotlightRect, b: SpotlightRect) { +/** + * Whether two rects match within half a pixel on every side. Shared with the + * root's reveal loop, which feeds it `DOMRect`s (`x`/`y` mirror `left`/`top`). + */ +export function rectsEqual(a: SpotlightRect, b: SpotlightRect) { return ( Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && @@ -44,9 +49,10 @@ function rectsEqual(a: SpotlightRect, b: SpotlightRect) { * only when the rect actually changes. * * When the element changes the previous rect is intentionally kept until the - * next one is measured, so the hole glides between steps instead of blinking. - * A disconnected element is skipped (rect frozen at its last good value) rather - * than collapsed to `0,0`, which would flash the whole screen dim. + * next one is measured, so `move` mode glides between steps instead of blinking + * and `fade` mode never collapses the hole to `0,0` mid-swap. A disconnected + * element is skipped (rect frozen at its last good value) rather than collapsed, + * which would flash the whole screen dim. */ function useSpotlightRect(element: Element | null): SpotlightRect | null { const [rect, setRect] = useState(null); @@ -75,6 +81,39 @@ function useSpotlightRect(element: Element | null): SpotlightRect | null { return element ? rect : null; } +/** + * Cross-fade controller for the spotlight cutout. It holds the currently + * displayed element (so its hole stays put while it closes) and swaps to the + * next target only once the tour reports it `revealed`. Because `revealed` + * already waits out the close (see the root's reveal gate), the reposition lands + * while the cutout is covered by the dim and is never seen — the hole then fades + * back open on the exact frame the popover reveals. A vanished target + * (`target == null`) is dropped at once, so no orphaned hole lingers. + * + * `shown` is whether the cutout should currently be open (clear); when it is + * false the cover patch dims the hole closed while the surrounding dim persists. + */ +function useSpotlightFade( + target: Element | null, + { fade, revealed }: { fade: boolean; revealed: boolean } +) { + const [displayed, setDisplayed] = useState(target); + + useEffect(() => { + // Adopt the target when it is settled (revealed), when fade is off, when it + // has gone (null), or when nothing is shown yet (first step — it mounts + // hidden and then fades in). Otherwise keep the old hole to fade it out. + if (!fade || target == null || revealed || displayed == null) { + setDisplayed(target); + } + }, [target, revealed, fade, displayed]); + + const shown = fade + ? revealed && displayed === target && target != null + : true; + return { displayed: fade ? displayed : target, shown }; +} + export function TourOverlay({ spotlightPadding = 4, spotlightRadius = 6, @@ -87,25 +126,49 @@ export function TourOverlay({ step, status, spotlightElement, - disableOverlay: disableOverlayTour + disableOverlay: disableOverlayTour, + revealed } = useTourContext('Tour.Overlay'); const disabled = step?.disableOverlay ?? disableOverlayTour; + const reducedMotion = usePrefersReducedMotion(); + // The spotlight cutout always cross-fades between targets (never slides), + // whatever the tour's `transition` mode — that setting only affects the + // popover. The dim backdrop itself never disappears once the tour is running; + // only the cutout closes and reopens. + const fade = !reducedMotion; // A step is "targeted" when it points at or spotlights an element. Detached // steps (welcome/finish) dim the whole viewport with no cutout. const targeted = step != null && (step.target != null || step.spotlightTarget != null); - const rect = useSpotlightRect( - open && !disabled && targeted ? spotlightElement : null + + const { displayed, shown } = useSpotlightFade( + open && !disabled && targeted ? spotlightElement : null, + { fade, revealed } ); + const rect = useSpotlightRect(displayed); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); + // The dim fades in once, when the first step reveals, and then persists for + // the rest of the tour — between steps only the cutout closes and reopens, so + // the backdrop never flashes away. Reset on close so it fades in again on the + // next open. + const [entered, setEntered] = useState(false); + useEffect(() => { + if (!open || disabled) { + setEntered(false); + return; + } + if (revealed) setEntered(true); + }, [open, disabled, revealed]); + if (!open || !mounted || disabled || !step) return null; // A targeted step with no measured rect is still resolving (or its target - // just unmounted). Render nothing rather than a full-screen dim with no card. - if (targeted && (!spotlightElement || !rect)) return null; + // just unmounted). Requiring the *live* target (`spotlightElement`) to exist + // is what prevents an orphaned dim from lingering after a target disappears. + if (targeted && (!spotlightElement || !displayed || !rect)) return null; const padding = step.spotlightPadding ?? spotlightPadding; const radius = step.spotlightRadius ?? spotlightRadius; @@ -121,10 +184,17 @@ export function TourOverlay({ } : null; + // Whether the cutout should currently be open (clear). Between steps it + // closes — a cover patch dims the hole shut while the backdrop stays put — and + // reopens once the next target settles. Detached steps have no cutout. + const holeOpen = fade ? shown : true; + return createPortal(
@@ -143,6 +213,20 @@ export function TourOverlay({ { left: '50%', top: '50%', width: 0, height: 0 } } /> + {hole && ( + // Fills the cutout with the same dim so the hole can fade shut and back + // open without the surrounding backdrop ever disappearing. +
+ )} {hole ? ( <> {/* Hit strips block clicks around the hole. The strip over the hole @@ -173,7 +257,7 @@ export function TourOverlay({ className={styles.overlayHit} style={{ top: hole.y + hole.height, left: 0, right: 0, bottom: 0 }} /> - {!spotlightClicks && ( + {!(spotlightClicks && holeOpen) && (
diff --git a/packages/raystack/components/tour/tour-root.tsx b/packages/raystack/components/tour/tour-root.tsx index d9b775769..538613dd7 100644 --- a/packages/raystack/components/tour/tour-root.tsx +++ b/packages/raystack/components/tour/tour-root.tsx @@ -8,22 +8,40 @@ import { useEffect, useImperativeHandle, useMemo, - useRef + useRef, + useState } from 'react'; +import { TourContent } from './tour-content'; import { TourContext, type TourContextValue } from './tour-context'; -import { TourOverlay } from './tour-overlay'; -import { TourPopover } from './tour-popover'; +import { rectsEqual, TourOverlay } from './tour-overlay'; import type { TourActions, TourEndStatus, TourEvent, TourStatus, - TourStep + TourStep, + TourTransition } from './types'; +import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; import { resolveTourTarget, useTourTarget } from './use-tour-target'; /** - * Whether `el` is fully visible in the viewport *and* within every scrollable + * How long the previous step's cutout is given to fade out before the next + * step is revealed (fade mode). Matches the overlay's opacity transition in + * `tour.module.css`, so the reposition lands while the overlay is transparent. + */ +const FADE_OUT_MS = 160; + +/** + * Safety net for the reveal gate: if a step's target never settles into view — + * it may never mount (a spotlight-only step whose element is absent), be larger + * than the viewport, or animate continuously — reveal the step anyway after + * this long so the tour can never hard-hang waiting on it. + */ +const REVEAL_TIMEOUT_MS = 2000; + +/** + * Whether `el` is visible in the viewport *and* within every scrollable * ancestor. The ancestor check matters: an element scrolled out of a nested * `overflow: auto` container can still sit within the window's bounds, so a * viewport-only test would wrongly skip scrolling it into view. @@ -47,12 +65,19 @@ function isElementInView(el: Element): boolean { } node = node.parentElement; } - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth - ); + const vw = window.innerWidth; + const vh = window.innerHeight; + // Fully within the viewport, or — for a target larger than the viewport — + // currently spanning it (edges past both sides). Without the span case an + // oversized target (a full-height panel) could never read as "in view", so + // the reveal loop would spin until its timeout instead of settling promptly. + const inViewY = + (rect.top >= 0 && rect.bottom <= vh) || + (rect.height > vh && rect.top <= 0 && rect.bottom >= vh); + const inViewX = + (rect.left >= 0 && rect.right <= vw) || + (rect.width > vw && rect.left <= 0 && rect.right >= vw); + return inViewX && inViewY; } export interface TourRootProps { @@ -64,7 +89,12 @@ export interface TourRootProps { defaultOpen?: boolean; /** Called when the tour opens or closes. `status` is set when closing. */ onOpenChange?: (open: boolean, details: { status?: TourEndStatus }) => void; - /** Active step index (controlled). */ + /** + * Active step index (controlled). Keep it within `0..steps.length - 1`: the + * tour clamps out-of-range values internally for rendering but never writes + * the clamped value back, so a controlled parent must not hold one out of + * range (its `next`/`prev`/`go` operate on the clamped index). + */ stepIndex?: number; /** Initially active step when uncontrolled. @default 0 */ defaultStepIndex?: number; @@ -85,6 +115,12 @@ export interface TourRootProps { * @default 'skip' */ targetNotFound?: 'skip' | 'stop'; + /** + * How the popover card travels between steps. `fade` (default) cross-fades it + * at each target; `move` glides it smoothly from one target to the next. The + * spotlight always cross-fades regardless — it never slides. @default 'fade' + */ + transition?: TourTransition; /** * Hide the dimmed overlay for the whole tour — only the popover is shown and * the page stays fully interactive. Steps can override with @@ -92,7 +128,7 @@ export interface TourRootProps { */ disableOverlay?: boolean; /** - * Tour UI. Defaults to `` plus `` with the + * Tour UI. Defaults to `` plus `` with the * standard card layout; compose the parts to customize. */ children?: ReactNode; @@ -110,6 +146,7 @@ export function TourRoot({ actionsRef, targetTimeout = 5000, targetNotFound = 'skip', + transition = 'fade', disableOverlay = false, children }: TourRootProps) { @@ -248,6 +285,101 @@ export function TourRoot({ ? 'running' : 'waiting'; + // A detached step (welcome/finish) has no anchor and no spotlight; it is + // "revealed" as soon as it is running, since there is no target to wait for. + const detachedStep = + step != null && step.target == null && step.spotlightTarget == null; + + // `revealed` gates the fade-in of the spotlight (always) and of the popover + // in `fade` mode: they appear only once the active step's target has scrolled + // into view and its rect has stopped moving, so the spotlight never fades in + // at a position it is about to leave. (In `move` mode the popover ignores + // this and glides instead — but the spotlight still cross-fades on it.) + // + // On a step-to-step change it also holds off for a grace period so the + // previous step's cutout can finish fading out before the next one lands. The + // very first step after opening has nothing to fade out, so it skips the + // grace, as does reduced motion (no fade to wait for). + const reducedMotion = usePrefersReducedMotion(); + const [revealed, setRevealed] = useState(false); + const shownOnceRef = useRef(false); + + // Reset the reveal the instant the target changes — in render, not an effect + // — so the overlay never sees a stale `revealed` from the previous step for a + // frame (which would let it adopt the new target before the old hole has + // faded out, snapping the cutout across the screen). This is the standard + // "adjust state when a prop changes" pattern; the ref guards the re-render. + const revealedForRef = useRef(spotlightElement); + if (revealedForRef.current !== spotlightElement) { + revealedForRef.current = spotlightElement; + if (revealed) setRevealed(false); + } + + useEffect(() => { + if (!popoverOpen) { + setRevealed(false); + shownOnceRef.current = false; + return; + } + if (detachedStep) { + // No target to settle on — reveal at once (the dim has no cutout). + setRevealed(true); + shownOnceRef.current = true; + return; + } + // Poll with a frame loop until the target is in view, its rect settles for + // two consecutive frames, and the fade-out grace has elapsed; then reveal + // once and stop — no per-frame re-render after that. A max-wait fallback + // reveals anyway if the target never settles: it may never mount (a + // spotlight-only step whose element is absent — `el` stays null), be larger + // than the viewport, or animate continuously. Without it the tour would + // hard-hang here with nothing on screen and no way to advance. + setRevealed(false); + const el = spotlightElement; + const grace = shownOnceRef.current && !reducedMotion ? FADE_OUT_MS : 0; + const start = performance.now(); + let frame = 0; + let fallback: ReturnType; + let stable = 0; + let last: DOMRect | null = null; + let settled = false; + const reveal = () => { + if (settled) return; + settled = true; + cancelAnimationFrame(frame); + clearTimeout(fallback); + setRevealed(true); + shownOnceRef.current = true; + }; + const check = () => { + if (el?.isConnected && isElementInView(el)) { + const next = el.getBoundingClientRect(); + if (last && rectsEqual(last, next)) { + stable += 1; + } else { + stable = 0; + } + last = next; + if (stable >= 2 && performance.now() - start >= grace) { + reveal(); + return; + } + } else { + stable = 0; + } + frame = requestAnimationFrame(check); + }; + fallback = setTimeout(reveal, REVEAL_TIMEOUT_MS); + frame = requestAnimationFrame(check); + return () => { + cancelAnimationFrame(frame); + clearTimeout(fallback); + }; + // `spotlightElement` identity changes on every step change (and toggles when + // a target resolves/unmounts), so it — with `popoverOpen` — re-keys the loop + // per step; `step` is omitted since callers often rebuild the steps array. + }, [popoverOpen, detachedStep, spotlightElement, reducedMotion]); + // Starts false (not `open`) so a tour mounted already-open still emits // `tour:start`. const prevOpenRef = useRef(false); @@ -289,17 +421,14 @@ export function TourRoot({ // `isElementInView` also checks scrollable ancestors, so a target clipped // inside a nested scroll container counts as hidden and gets revealed. if (isElementInView(el)) return; - const reduceMotion = window.matchMedia( - '(prefers-reduced-motion: reduce)' - ).matches; el.scrollIntoView({ block: 'center', inline: 'nearest', - behavior: reduceMotion ? 'auto' : 'smooth' + behavior: reducedMotion ? 'auto' : 'smooth' }); // `anchor` is intentionally in the deps: a late-resolving target should // scroll into view the moment it lands, not only on index change. - }, [popoverOpen, step, anchor]); + }, [popoverOpen, step, anchor, reducedMotion]); const contextValue = useMemo( () => ({ @@ -312,6 +441,8 @@ export function TourRoot({ spotlightElement, popoverOpen, disableOverlay, + transition, + revealed, actions }), [ @@ -324,6 +455,8 @@ export function TourRoot({ spotlightElement, popoverOpen, disableOverlay, + transition, + revealed, actions ] ); @@ -333,7 +466,7 @@ export function TourRoot({ {children ?? ( <> - + )} diff --git a/packages/raystack/components/tour/tour.module.css b/packages/raystack/components/tour/tour.module.css index 43bf6d772..ffef1540e 100644 --- a/packages/raystack/components/tour/tour.module.css +++ b/packages/raystack/components/tour/tour.module.css @@ -8,34 +8,53 @@ pointer-events: none; } -@media (prefers-reduced-motion: no-preference) { - .overlay { - animation: tour-fade-in 250ms ease; - } +/* Before the first step reveals, the dim is transparent — its hit strips must + not swallow clicks while the page is still usable. */ +.overlay[data-entered="false"] .overlayHit { + pointer-events: none; } -@keyframes tour-fade-in { - from { - opacity: 0; +/* The dim backdrop fades in once, on the first step, and then persists for the + whole tour. Between steps it never disappears; only the cutout closes and + reopens (see .spotlightCover), so the backdrop never flashes away. */ +.overlay[data-entered="false"] { + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .overlay { + transition: opacity 200ms ease; } } /* The dimming layer is this element's box-shadow; the element itself is the - transparent cutout over the target, so moving it animates the cutout. */ + transparent cutout over the target. It never transitions position — it is + repositioned instantly while the cutout is covered (see .spotlightCover). */ .spotlight { position: absolute; pointer-events: none; box-shadow: 0 0 0 200vmax var(--rs-color-overlay-black-a5); } +/* Fills the cutout with the same dim as the backdrop so the hole can fade shut + and reopen without the surrounding dim ever changing. Open step: transparent + (clear hole); between steps: opaque (hole dimmed closed). The cutout is + repositioned to the next target while this cover is opaque, so the move is + never seen — a soft fade both ways, never a slide. */ +.spotlightCover { + position: absolute; + pointer-events: none; + background-color: var(--rs-color-overlay-black-a5); + opacity: 0; +} + +.overlay[data-hole-open="false"] .spotlightCover { + opacity: 1; +} + @media (prefers-reduced-motion: no-preference) { - .spotlight { - transition: - left 300ms cubic-bezier(0.22, 1, 0.36, 1), - top 300ms cubic-bezier(0.22, 1, 0.36, 1), - width 300ms cubic-bezier(0.22, 1, 0.36, 1), - height 300ms cubic-bezier(0.22, 1, 0.36, 1), - border-radius 300ms cubic-bezier(0.22, 1, 0.36, 1); + .spotlightCover { + transition: opacity 160ms ease; } } @@ -48,11 +67,12 @@ z-index: calc(var(--rs-z-index-portal) + 2); } -/* Glide between step targets, but not while the popup is mounting — otherwise - the first open would animate in from the viewport origin. */ +/* Glide between step targets in `move` mode only, and not while the popup is + mounting — otherwise the first open would animate in from the viewport + origin. `fade` mode repositions the card while it is hidden, so no glide. */ @media (prefers-reduced-motion: no-preference) { - .positioner:has(.popup:not([data-starting-style])) { - transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1); + .positioner[data-transition="move"]:has(.popup:not([data-starting-style])) { + transition: transform 460ms cubic-bezier(0.33, 0.7, 0, 1); } } @@ -83,6 +103,15 @@ scale: 0.96; } +/* Fade mode: the card is hidden until its target settles. Hiding is instant + (transition: none) so the reposition and content swap between steps are not + seen; revealing uses the .popup transition above for a soft fade-in in place. */ +.popup[data-transition="fade"][data-visible="false"] { + opacity: 0; + scale: 0.98; + transition: none; +} + .stepContent { display: flex; flex-direction: column; diff --git a/packages/raystack/components/tour/tour.tsx b/packages/raystack/components/tour/tour.tsx index db775e70e..9c3521992 100644 --- a/packages/raystack/components/tour/tour.tsx +++ b/packages/raystack/components/tour/tour.tsx @@ -1,5 +1,6 @@ 'use client'; +import { TourContent } from './tour-content'; import { TourOverlay } from './tour-overlay'; import { TourClose, @@ -10,12 +11,11 @@ import { TourSkip, TourTitle } from './tour-parts'; -import { TourPopover } from './tour-popover'; import { TourRoot } from './tour-root'; export const Tour = Object.assign(TourRoot, { Overlay: TourOverlay, - Popover: TourPopover, + Content: TourContent, Title: TourTitle, Description: TourDescription, Progress: TourProgress, diff --git a/packages/raystack/components/tour/types.ts b/packages/raystack/components/tour/types.ts index 9b00a9bce..e5045cce2 100644 --- a/packages/raystack/components/tour/types.ts +++ b/packages/raystack/components/tour/types.ts @@ -15,6 +15,18 @@ export type TourTarget = export type TourSide = 'top' | 'right' | 'bottom' | 'left'; export type TourAlign = 'start' | 'center' | 'end'; +/** + * How the popover card travels between steps. The spotlight always cross-fades + * (it never slides), regardless of this setting. + * - `fade` (default): the card cross-fades at each target — it fades out, moves + * while hidden, and fades in once the target is in view. Best for targets that + * are far apart or need scrolling, where a glide reads as jank. + * - `move`: the card glides smoothly from the previous target to the next, + * keeping a sense of spatial continuity. Best when consecutive targets sit + * close together. + */ +export type TourTransition = 'fade' | 'move'; + export interface TourStep { /** Stable identifier reported in events. Falls back to the step index. */ id?: string; @@ -87,7 +99,7 @@ export interface TourActions { stop: () => void; } -/** Passed to a `Tour.Popover` render function while a step is active. */ +/** Passed to a `Tour.Content` render function while a step is active. */ export interface TourRenderProps { step: TourStep; index: number; diff --git a/packages/raystack/components/tour/use-prefers-reduced-motion.ts b/packages/raystack/components/tour/use-prefers-reduced-motion.ts new file mode 100644 index 000000000..555c4acfb --- /dev/null +++ b/packages/raystack/components/tour/use-prefers-reduced-motion.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Tracks the user's `prefers-reduced-motion` setting, updating if it changes. + * Defaults to motion-allowed (`false`) on the server and where `matchMedia` + * is unavailable, so the tour's fade/glide logic degrades to instant swaps for + * reduced-motion users without any layout-affecting difference on the server. + */ +export function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + + useEffect(() => { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return; + } + const query = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => setReduced(query.matches); + update(); + query.addEventListener?.('change', update); + return () => query.removeEventListener?.('change', update); + }, []); + + return reduced; +} diff --git a/packages/raystack/components/tour/use-tour-target.ts b/packages/raystack/components/tour/use-tour-target.ts index ec8c19e08..099f17346 100644 --- a/packages/raystack/components/tour/use-tour-target.ts +++ b/packages/raystack/components/tour/use-tour-target.ts @@ -21,7 +21,16 @@ export function resolveTourTarget( return null; } } - if (typeof target === 'function') return target(); + if (typeof target === 'function') { + try { + return target(); + } catch { + // A throwing resolver (e.g. `() => editor.getNode('x')` before the editor + // is ready) should degrade to "not found", not crash the tour — matching + // how an invalid selector is handled above. + return null; + } + } if (target instanceof Element) return target; return target.current ?? null; } From 3f675561190145fa939f21f93e866d10e83b75f3 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 9 Jul 2026 16:07:20 +0530 Subject: [PATCH 3/3] feat: tour improvements --- .../components/playground/tour-examples.tsx | 2 +- .../raystack/components/tour/tour-content.tsx | 18 +--- .../raystack/components/tour/tour-context.tsx | 13 --- .../raystack/components/tour/tour-overlay.tsx | 77 ++-------------- .../raystack/components/tour/tour-parts.tsx | 4 - .../raystack/components/tour/tour-root.tsx | 88 ++++++------------- .../tour/use-prefers-reduced-motion.ts | 6 -- .../components/tour/use-tour-target.ts | 38 +------- packages/raystack/components/tour/utils.ts | 15 ++++ 9 files changed, 52 insertions(+), 209 deletions(-) create mode 100644 packages/raystack/components/tour/utils.ts diff --git a/apps/www/src/components/playground/tour-examples.tsx b/apps/www/src/components/playground/tour-examples.tsx index b7768160b..b4a42c6ad 100644 --- a/apps/www/src/components/playground/tour-examples.tsx +++ b/apps/www/src/components/playground/tour-examples.tsx @@ -30,7 +30,7 @@ const steps: TourStep[] = [ id: 'settings', target: '[data-pg-tour="settings"]', title: 'Settings', - content: 'Anchored on the right with a pill spotlight.', + content: 'Anchored below with a pill spotlight.', side: 'bottom', spotlightRadius: 999, spotlightPadding: 6 diff --git a/packages/raystack/components/tour/tour-content.tsx b/packages/raystack/components/tour/tour-content.tsx index 216b2595b..8907ab495 100644 --- a/packages/raystack/components/tour/tour-content.tsx +++ b/packages/raystack/components/tour/tour-content.tsx @@ -56,23 +56,11 @@ export function TourContent({ transition, revealed } = useTourContext('Tour.Content'); - // Positioning-only: the card is "detached" (centered) whenever it has no - // anchor target, even if the step still spotlights something elsewhere via - // `spotlightTarget`. Deliberately looser than the root's `detachedStep` - // (which also requires no `spotlightTarget`) — a centered card can still - // drive a spotlight. const detached = step != null && step.target == null; const popupRef = useRef(null); - // In `fade` mode the card is hidden until its target settles (`revealed`), - // so a step that scrolls or mounts late doesn't flash the card at a stale - // position — it fades in, in place. `move` mode keeps it visible and glides. const visible = transition !== 'fade' || revealed; - // Detached steps anchor to the viewport center and open upwards, which - // optically centers the popup while keeping it positioner-driven (so it - // still glides to and from regular steps). The rect is read lazily, so the - // positioner re-centers it on window resize. const centerAnchor = useMemo( () => ({ getBoundingClientRect: () => @@ -86,9 +74,6 @@ export function TourContent({ [] ); - // Keyboard continuity: the step content remounts on every step, so move - // focus back into the card when the step changes (once it is revealed). Steps - // that invite page interaction (`spotlightClicks`) keep focus where it is. const spotlightClicks = step?.spotlightClicks ?? false; // biome-ignore lint/correctness/useExhaustiveDependencies: `index` is intentional — re-running on step change is how the card refocuses. useEffect(() => { @@ -114,8 +99,7 @@ export function TourContent({ modal={false} onOpenChange={(nextOpen, eventDetails) => { if (nextOpen) return; - // Tours are persistent: outside presses and focus moves (e.g. into a - // spotlighted input) must not dismiss the step. Escape still exits. + // Tours are persistent — only Escape dismisses, not outside press/focus. if (eventDetails.reason === 'escape-key') actions.stop(); }} > diff --git a/packages/raystack/components/tour/tour-context.tsx b/packages/raystack/components/tour/tour-context.tsx index 423d5e2f3..08c11188d 100644 --- a/packages/raystack/components/tour/tour-context.tsx +++ b/packages/raystack/components/tour/tour-context.tsx @@ -14,24 +14,11 @@ export interface TourContextValue { step: TourStep | null; open: boolean; status: TourStatus; - /** Resolved anchor element for the active step; null for detached steps or while waiting. */ anchor: Element | null; - /** Resolved element to spotlight; usually the anchor. */ spotlightElement: Element | null; - /** Whether the popover should be visible (tour open and target resolved). */ popoverOpen: boolean; - /** Tour-level default for hiding the dimmed overlay. */ disableOverlay: boolean; - /** How the spotlight and popover travel between steps. */ transition: TourTransition; - /** - * Whether the active step's target is settled and in view. Drives the - * fade-in of the spotlight (in every mode) and of the popover (in `fade` - * mode) so they appear only once the target has stopped moving, e.g. after - * scrolling into view. Computed the same way regardless of transition; in - * `move` mode the popover ignores it and glides, but the spotlight still - * respects it. - */ revealed: boolean; actions: TourActions; } diff --git a/packages/raystack/components/tour/tour-overlay.tsx b/packages/raystack/components/tour/tour-overlay.tsx index 896d857ef..a235d6cb0 100644 --- a/packages/raystack/components/tour/tour-overlay.tsx +++ b/packages/raystack/components/tour/tour-overlay.tsx @@ -6,6 +6,7 @@ import { createPortal } from 'react-dom'; import styles from './tour.module.css'; import { useTourContext } from './tour-context'; import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; +import { rectsEqual, type SpotlightRect } from './utils'; export interface TourOverlayProps extends ComponentPropsWithoutRef<'div'> { /** @@ -22,38 +23,6 @@ export interface TourOverlayProps extends ComponentPropsWithoutRef<'div'> { spotlightClicks?: boolean; } -interface SpotlightRect { - x: number; - y: number; - width: number; - height: number; -} - -/** - * Whether two rects match within half a pixel on every side. Shared with the - * root's reveal loop, which feeds it `DOMRect`s (`x`/`y` mirror `left`/`top`). - */ -export function rectsEqual(a: SpotlightRect, b: SpotlightRect) { - return ( - Math.abs(a.x - b.x) < 0.5 && - Math.abs(a.y - b.y) < 0.5 && - Math.abs(a.width - b.width) < 0.5 && - Math.abs(a.height - b.height) < 0.5 - ); -} - -/** - * Tracks an element's viewport rect with a frame loop, so the spotlight - * follows through scrolling (any container), resizes and CSS animations — e.g. - * a dialog's entry transform, which fires no scroll/resize events. Re-renders - * only when the rect actually changes. - * - * When the element changes the previous rect is intentionally kept until the - * next one is measured, so `move` mode glides between steps instead of blinking - * and `fade` mode never collapses the hole to `0,0` mid-swap. A disconnected - * element is skipped (rect frozen at its last good value) rather than collapsed, - * which would flash the whole screen dim. - */ function useSpotlightRect(element: Element | null): SpotlightRect | null { const [rect, setRect] = useState(null); @@ -81,18 +50,6 @@ function useSpotlightRect(element: Element | null): SpotlightRect | null { return element ? rect : null; } -/** - * Cross-fade controller for the spotlight cutout. It holds the currently - * displayed element (so its hole stays put while it closes) and swaps to the - * next target only once the tour reports it `revealed`. Because `revealed` - * already waits out the close (see the root's reveal gate), the reposition lands - * while the cutout is covered by the dim and is never seen — the hole then fades - * back open on the exact frame the popover reveals. A vanished target - * (`target == null`) is dropped at once, so no orphaned hole lingers. - * - * `shown` is whether the cutout should currently be open (clear); when it is - * false the cover patch dims the hole closed while the surrounding dim persists. - */ function useSpotlightFade( target: Element | null, { fade, revealed }: { fade: boolean; revealed: boolean } @@ -100,9 +57,6 @@ function useSpotlightFade( const [displayed, setDisplayed] = useState(target); useEffect(() => { - // Adopt the target when it is settled (revealed), when fade is off, when it - // has gone (null), or when nothing is shown yet (first step — it mounts - // hidden and then fades in). Otherwise keep the old hole to fade it out. if (!fade || target == null || revealed || displayed == null) { setDisplayed(target); } @@ -131,14 +85,8 @@ export function TourOverlay({ } = useTourContext('Tour.Overlay'); const disabled = step?.disableOverlay ?? disableOverlayTour; const reducedMotion = usePrefersReducedMotion(); - // The spotlight cutout always cross-fades between targets (never slides), - // whatever the tour's `transition` mode — that setting only affects the - // popover. The dim backdrop itself never disappears once the tour is running; - // only the cutout closes and reopens. const fade = !reducedMotion; - // A step is "targeted" when it points at or spotlights an element. Detached - // steps (welcome/finish) dim the whole viewport with no cutout. const targeted = step != null && (step.target != null || step.spotlightTarget != null); @@ -151,10 +99,6 @@ export function TourOverlay({ const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); - // The dim fades in once, when the first step reveals, and then persists for - // the rest of the tour — between steps only the cutout closes and reopens, so - // the backdrop never flashes away. Reset on close so it fades in again on the - // next open. const [entered, setEntered] = useState(false); useEffect(() => { if (!open || disabled) { @@ -165,9 +109,7 @@ export function TourOverlay({ }, [open, disabled, revealed]); if (!open || !mounted || disabled || !step) return null; - // A targeted step with no measured rect is still resolving (or its target - // just unmounted). Requiring the *live* target (`spotlightElement`) to exist - // is what prevents an orphaned dim from lingering after a target disappears. + // Requiring the live `spotlightElement` prevents an orphaned dim after a target unmounts. if (targeted && (!spotlightElement || !displayed || !rect)) return null; const padding = step.spotlightPadding ?? spotlightPadding; @@ -184,19 +126,17 @@ export function TourOverlay({ } : null; - // Whether the cutout should currently be open (clear). Between steps it - // closes — a cover patch dims the hole shut while the backdrop stays put — and - // reopens once the next target settles. Detached steps have no cutout. const holeOpen = fade ? shown : true; return createPortal( + // `rest` spread first so consumers can't clobber the chrome attributes below.
{hole && ( - // Fills the cutout with the same dim so the hole can fade shut and back - // open without the surrounding backdrop ever disappearing.
- {/* Hit strips block clicks around the hole. The strip over the hole - is dropped when spotlightClicks lets clicks reach the target. */} + {/* Hit strips block clicks around the hole; the center strip is + dropped when spotlightClicks lets clicks through. */}
= 0 && rect.bottom <= vh) || (rect.height > vh && rect.top <= 0 && rect.bottom >= vh); @@ -168,7 +150,6 @@ export function TourRoot({ ); const step = open ? (steps[index] ?? null) : null; - // Latest-value refs keep `actions` referentially stable across renders. const stepsRef = useRef(steps); stepsRef.current = steps; const indexRef = useRef(index); @@ -209,7 +190,6 @@ export function TourRoot({ [setIndexUnwrapped] ); - // Everything except `start` is a no-op while the tour is closed. const actions = useMemo( () => ({ start: (at = 0) => { @@ -267,13 +247,24 @@ export function TourRoot({ } }, [emit, setIndex, setOpen]); - const { element: anchor, state: targetState } = useTourTarget(step?.target, { + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on open/index, not `step` — inline steps arrays give function targets a fresh identity each render. + const target = useMemo( + () => (open ? step?.target : undefined), + [open, index] + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on open/index, not `step`. + const spotlightTarget = useMemo( + () => (open ? step?.spotlightTarget : undefined), + [open, index] + ); + + const { element: anchor, state: targetState } = useTourTarget(target, { enabled: open && step != null, timeout: targetTimeout, onNotFound: handleTargetNotFound }); - const { element: spotlightOverride } = useTourTarget(step?.spotlightTarget, { - enabled: open && step?.spotlightTarget != null, + const { element: spotlightOverride } = useTourTarget(spotlightTarget, { + enabled: open && spotlightTarget != null, timeout: targetTimeout }); const spotlightElement = spotlightOverride ?? anchor; @@ -285,30 +276,15 @@ export function TourRoot({ ? 'running' : 'waiting'; - // A detached step (welcome/finish) has no anchor and no spotlight; it is - // "revealed" as soon as it is running, since there is no target to wait for. const detachedStep = step != null && step.target == null && step.spotlightTarget == null; - // `revealed` gates the fade-in of the spotlight (always) and of the popover - // in `fade` mode: they appear only once the active step's target has scrolled - // into view and its rect has stopped moving, so the spotlight never fades in - // at a position it is about to leave. (In `move` mode the popover ignores - // this and glides instead — but the spotlight still cross-fades on it.) - // - // On a step-to-step change it also holds off for a grace period so the - // previous step's cutout can finish fading out before the next one lands. The - // very first step after opening has nothing to fade out, so it skips the - // grace, as does reduced motion (no fade to wait for). const reducedMotion = usePrefersReducedMotion(); const [revealed, setRevealed] = useState(false); const shownOnceRef = useRef(false); - // Reset the reveal the instant the target changes — in render, not an effect - // — so the overlay never sees a stale `revealed` from the previous step for a - // frame (which would let it adopt the new target before the old hole has - // faded out, snapping the cutout across the screen). This is the standard - // "adjust state when a prop changes" pattern; the ref guards the re-render. + // Reset in render (not an effect) so the overlay never sees a stale `revealed` + // for a frame; the ref guards against a re-render loop. const revealedForRef = useRef(spotlightElement); if (revealedForRef.current !== spotlightElement) { revealedForRef.current = spotlightElement; @@ -322,18 +298,12 @@ export function TourRoot({ return; } if (detachedStep) { - // No target to settle on — reveal at once (the dim has no cutout). setRevealed(true); shownOnceRef.current = true; return; } - // Poll with a frame loop until the target is in view, its rect settles for - // two consecutive frames, and the fade-out grace has elapsed; then reveal - // once and stop — no per-frame re-render after that. A max-wait fallback - // reveals anyway if the target never settles: it may never mount (a - // spotlight-only step whose element is absent — `el` stays null), be larger - // than the viewport, or animate continuously. Without it the tour would - // hard-hang here with nothing on screen and no way to advance. + // The fallback reveal is a safety net so a target that never settles can't + // hang the tour. setRevealed(false); const el = spotlightElement; const grace = shownOnceRef.current && !reducedMotion ? FADE_OUT_MS : 0; @@ -375,13 +345,9 @@ export function TourRoot({ cancelAnimationFrame(frame); clearTimeout(fallback); }; - // `spotlightElement` identity changes on every step change (and toggles when - // a target resolves/unmounts), so it — with `popoverOpen` — re-keys the loop - // per step; `step` is omitted since callers often rebuild the steps array. }, [popoverOpen, detachedStep, spotlightElement, reducedMotion]); - // Starts false (not `open`) so a tour mounted already-open still emits - // `tour:start`. + // Starts false so a tour mounted already-open still emits `tour:start`. const prevOpenRef = useRef(false); useEffect(() => { if (prevOpenRef.current === open) return; @@ -413,22 +379,18 @@ export function TourRoot({ emit({ type: 'step:active', index, step }); }, [open, popoverOpen, index, step, emit]); - // Scroll the target into view once, when the step becomes active. + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on `index`, not `step` — the per-render `step` identity would re-fire this and fight the user's scroll. useEffect(() => { if (!popoverOpen || !step || step.disableScroll) return; const el = resolveTourTarget(step.scrollTarget) ?? anchor; if (!el?.isConnected) return; - // `isElementInView` also checks scrollable ancestors, so a target clipped - // inside a nested scroll container counts as hidden and gets revealed. if (isElementInView(el)) return; el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reducedMotion ? 'auto' : 'smooth' }); - // `anchor` is intentionally in the deps: a late-resolving target should - // scroll into view the moment it lands, not only on index change. - }, [popoverOpen, step, anchor, reducedMotion]); + }, [popoverOpen, index, anchor, reducedMotion]); const contextValue = useMemo( () => ({ diff --git a/packages/raystack/components/tour/use-prefers-reduced-motion.ts b/packages/raystack/components/tour/use-prefers-reduced-motion.ts index 555c4acfb..045495339 100644 --- a/packages/raystack/components/tour/use-prefers-reduced-motion.ts +++ b/packages/raystack/components/tour/use-prefers-reduced-motion.ts @@ -2,12 +2,6 @@ import { useEffect, useState } from 'react'; -/** - * Tracks the user's `prefers-reduced-motion` setting, updating if it changes. - * Defaults to motion-allowed (`false`) on the server and where `matchMedia` - * is unavailable, so the tour's fade/glide logic degrades to instant swaps for - * reduced-motion users without any layout-affecting difference on the server. - */ export function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = useState(false); diff --git a/packages/raystack/components/tour/use-tour-target.ts b/packages/raystack/components/tour/use-tour-target.ts index 099f17346..24bcb9fc5 100644 --- a/packages/raystack/components/tour/use-tour-target.ts +++ b/packages/raystack/components/tour/use-tour-target.ts @@ -3,12 +3,6 @@ import { useEffect, useRef, useState } from 'react'; import type { TourTarget } from './types'; -/** - * Resolve a {@link TourTarget} to a live element. Returns `null` when the - * target is missing or (for element/ref targets) detached from the document — - * connectedness is the caller's contract, so this never hands back an orphaned - * node. - */ export function resolveTourTarget( target: TourTarget | null | undefined ): Element | null { @@ -17,7 +11,6 @@ export function resolveTourTarget( try { return document.querySelector(target); } catch { - // An invalid selector should degrade to "not found", not throw. return null; } } @@ -25,42 +18,21 @@ export function resolveTourTarget( try { return target(); } catch { - // A throwing resolver (e.g. `() => editor.getNode('x')` before the editor - // is ready) should degrade to "not found", not crash the tour — matching - // how an invalid selector is handled above. return null; } } - if (target instanceof Element) return target; - return target.current ?? null; + if (target instanceof Element) return target.isConnected ? target : null; + return target.current?.isConnected ? target.current : null; } export type TourTargetState = 'idle' | 'resolving' | 'found'; interface UseTourTargetOptions { - /** Whether the target should be resolved at all. */ enabled: boolean; - /** How long to wait for a missing target before giving up, in ms. */ timeout: number; - /** Called once when a target never appears within `timeout`. */ onNotFound?: () => void; } -/** - * Resolves a step target to a connected element and keeps it accurate for the - * lifetime of the step. - * - * - Targets already in the DOM resolve synchronously (`found`). - * - Targets not mounted yet — lazy content, route transitions, dialog bodies — - * are awaited with a `MutationObserver` and resolve the moment they appear. - * - The observer stays live while `found`, so a target that later unmounts - * (its dialog closes, its route unmounts) flips back to `resolving` instead - * of leaving a stale, disconnected node behind. This is what prevents an - * orphaned spotlight/overlay when the element a step points at disappears. - * - If a target never appears within `timeout`, `onNotFound` fires once. - * - * A detached step (`target == null`) resolves to `{ element: null, state: 'found' }`. - */ export function useTourTarget( target: TourTarget | null | undefined, { enabled, timeout, onNotFound }: UseTourTargetOptions @@ -77,7 +49,6 @@ export function useTourTarget( return; } if (target == null) { - // Detached step: nothing to resolve, the popover renders centered. setElement(null); setState('found'); return; @@ -98,8 +69,6 @@ export function useTourTarget( }, timeout); }; - // Runs on mount and on every batched DOM mutation. Functional setState - // keeps this free of stale closures and avoids no-op re-renders. const reconcile = () => { const found = resolveTourTarget(target); if (found?.isConnected) { @@ -119,8 +88,7 @@ export function useTourTarget( observer.observe(document.body, { childList: true, subtree: true, - // Attribute changes can newly satisfy a selector target; element/ref/fn - // targets only care about mount/unmount, so skip the extra churn. + // Only selector targets can be newly satisfied by an attribute change. attributes: typeof target === 'string' }); diff --git a/packages/raystack/components/tour/utils.ts b/packages/raystack/components/tour/utils.ts new file mode 100644 index 000000000..6a6e42db7 --- /dev/null +++ b/packages/raystack/components/tour/utils.ts @@ -0,0 +1,15 @@ +export interface SpotlightRect { + x: number; + y: number; + width: number; + height: number; +} + +export function rectsEqual(a: SpotlightRect, b: SpotlightRect) { + return ( + Math.abs(a.x - b.x) < 0.5 && + Math.abs(a.y - b.y) < 0.5 && + Math.abs(a.width - b.width) < 0.5 && + Math.abs(a.height - b.height) < 0.5 + ); +}