diff --git a/apps/www/src/app/examples/tour/page.tsx b/apps/www/src/app/examples/tour/page.tsx index 751c20d1a..9ef26fbb0 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,681 @@ 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… + + ) : ( + + )} + + + ); + }} + + +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 && ( + + )} + + + + + + )} +
+
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 [transition, setTransition] = useState<'fade' | 'move'>('fade'); 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 +720,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 +737,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 +745,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 +753,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 +767,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 +789,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 +797,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 +805,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 +813,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 +826,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 +894,9 @@ const Page = () => { @@ -258,16 +906,32 @@ 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. - + + + + setTransition(c ? 'move' : 'fade')} + /> + }> + Glide the card (transition="{transition}") — the + spotlight always fades + + + { }} > }> - Click “Start tour”. Step 5 only advances when you click the invite - button, step 6 targets a field inside the dialog it opens, and the - later steps cover late-mounting and scrolled targets. + Step 5 only advances when you click the invite button, step 6 + targets a field inside the dialog it opens, and later steps cover + late-mounting and scrolled targets. @@ -329,7 +993,6 @@ const Page = () => { color='neutral' onClick={() => { setInviteOpen(true); - // Advance the tour when the user performs the asked action. actionsRef.current?.next(); }} > @@ -371,16 +1034,17 @@ const Page = () => { Your plan renews on the 1st. This card lives below the fold so the - tour has to scroll to it. + tour scrolls to it. + + - {/* Non-modal so the tour popover (outside the dialog) stays clickable; - while the tour runs, light dismissal is ignored — the tour decides - when the dialog closes. */} + {/* Non-modal so the tour popover stays clickable; while the tour runs + light dismissal is ignored — the tour decides when the dialog closes. */} { { setTourOpen(nextOpen); - // Ending the tour mid-dialog shouldn't strand the dialog open. if (!nextOpen) setInviteOpen(false); }} onStepChange={index => setLastIndex(index)} @@ -439,7 +1106,7 @@ const Page = () => { actionsRef={actionsRef} > - + {({ step, index }) => { const data = (step.data ?? {}) as StepData; return ( @@ -465,10 +1132,52 @@ const Page = () => { ); }} - + ); -}; +} + +/* -------------------------------------------------------------------------- */ +/* Edge-case lab */ +/* -------------------------------------------------------------------------- */ + +function EdgeCaseLab() { + return ( + + + + Edge-case lab + + + Each card is a self-contained tour that isolates one tricky lifecycle + scenario. Trigger them independently. + + +
+ + + + + + + + +
+
+ ); +} -export default Page; +export default function Page() { + return ; +} diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 349f6ec7f..f8ebad332 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -58,6 +58,7 @@ import { import ChipInputDemo from '../inputfield-chip-demo'; import LinearMenuDemo from '../linear-dropdown-demo'; import PopoverColorPicker from '../popover-color-picker'; +import TourDemo from '../tour-demo'; import DemoPlayground from './demo-playground'; import DemoPreview from './demo-preview'; import { DemoProps } from './types'; @@ -90,6 +91,7 @@ export default function Demo(props: DemoProps) { DataTableSelectionDemo, LinearMenuDemo, PopoverColorPicker, + TourDemo, Info, X, Home, diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 5b8d48e08..c587daa8e 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -55,3 +55,4 @@ export * from './text-examples'; export * from './toast-examples'; export * from './toggle-examples'; export * from './tooltip-examples'; +export * from './tour-examples'; diff --git a/apps/www/src/components/playground/tour-examples.tsx b/apps/www/src/components/playground/tour-examples.tsx new file mode 100644 index 000000000..b4a42c6ad --- /dev/null +++ b/apps/www/src/components/playground/tour-examples.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { BellIcon, GearIcon, HomeIcon } from '@radix-ui/react-icons'; +import { + Button, + Flex, + IconButton, + Text, + Tour, + type TourActions, + type TourStep +} from '@raystack/apsara'; +import { useRef } from 'react'; +import PlaygroundLayout from './playground-layout'; + +const steps: TourStep[] = [ + { + id: 'welcome', + title: 'Welcome', + content: 'A centered, detached step to open the tour.' + }, + { + id: 'home', + target: '[data-pg-tour="home"]', + title: 'Home', + content: 'Anchored to the home button, popover below.', + side: 'bottom' + }, + { + id: 'settings', + target: '[data-pg-tour="settings"]', + title: 'Settings', + content: 'Anchored below with a pill spotlight.', + side: 'bottom', + spotlightRadius: 999, + spotlightPadding: 6 + }, + { + id: 'alerts', + target: '[data-pg-tour="alerts"]', + title: 'Alerts', + content: 'Last step — Finish closes the tour.', + side: 'left' + } +]; + +export function TourExamples() { + const actionsRef = useRef(null); + + return ( + + + + + + + + + + + + + + + + Default card, four steps: + + + + + + + ); +} 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..c4abb1765 --- /dev/null +++ b/apps/www/src/content/docs/components/tour/index.mdx @@ -0,0 +1,208 @@ +--- +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.Content + +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" }, +]; +``` + +### 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 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` + 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 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 new file mode 100644 index 000000000..a16a6b2a0 --- /dev/null +++ b/apps/www/src/content/docs/components/tour/props.ts @@ -0,0 +1,219 @@ +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'; + + /** + * 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 `` + ``. */ + 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 TourContentProps { + /** + * Default side of the target to place the card 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 false + */ + 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/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 new file mode 100644 index 000000000..51b548a99 --- /dev/null +++ b/packages/raystack/components/tour/__tests__/tour.test.tsx @@ -0,0 +1,664 @@ +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('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( +
+ + +
+ ); + 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 card 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('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 /); + spy.mockRestore(); + }); +}); diff --git a/packages/raystack/components/tour/index.tsx b/packages/raystack/components/tour/index.tsx new file mode 100644 index 000000000..16c5e769a --- /dev/null +++ b/packages/raystack/components/tour/index.tsx @@ -0,0 +1,18 @@ +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 { TourRootProps } from './tour-root'; +export type { + TourActions, + TourAlign, + TourEndStatus, + TourEvent, + TourRenderProps, + TourSide, + TourStatus, + TourStep, + TourTarget, + TourTransition +} from './types'; diff --git a/packages/raystack/components/tour/tour-content.tsx b/packages/raystack/components/tour/tour-content.tsx new file mode 100644 index 000000000..8907ab495 --- /dev/null +++ b/packages/raystack/components/tour/tour-content.tsx @@ -0,0 +1,155 @@ +'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 TourContentProps { + /** + * Default side of the target to place the card 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; + /** Whether to render the pointing arrow. @default false */ + showArrow?: boolean; + className?: string; + style?: CSSProperties; + /** + * 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 TourContent({ + side = 'bottom', + align = 'center', + sideOffset = 12, + showArrow = false, + className, + style, + children +}: TourContentProps) { + const { + popoverOpen, + anchor, + step, + steps, + index, + status, + actions, + transition, + revealed + } = useTourContext('Tour.Content'); + const detached = step != null && step.target == null; + const popupRef = useRef(null); + + const visible = transition !== 'fade' || revealed; + + const centerAnchor = useMemo( + () => ({ + getBoundingClientRect: () => + DOMRect.fromRect({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + width: 0, + height: 0 + }) + }), + [] + ); + + 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 || !visible || spotlightClicks) return; + popupRef.current?.focus({ preventScroll: true }); + }, [popoverOpen, visible, 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 — only Escape dismisses, not outside press/focus. + if (eventDetails.reason === 'escape-key') actions.stop(); + }} + > + + + + {showArrow && !detached && ( + + + + )} + {renderProps && ( +
+ {typeof children === 'function' + ? children(renderProps) + : (children ?? )} +
+ )} +
+
+
+
+ ); +} + +TourContent.displayName = 'Tour.Content'; diff --git a/packages/raystack/components/tour/tour-context.tsx b/packages/raystack/components/tour/tour-context.tsx new file mode 100644 index 000000000..08c11188d --- /dev/null +++ b/packages/raystack/components/tour/tour-context.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { + TourActions, + TourStatus, + TourStep, + TourTransition +} from './types'; + +export interface TourContextValue { + steps: TourStep[]; + index: number; + step: TourStep | null; + open: boolean; + status: TourStatus; + anchor: Element | null; + spotlightElement: Element | null; + popoverOpen: boolean; + disableOverlay: boolean; + transition: TourTransition; + revealed: 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..a235d6cb0 --- /dev/null +++ b/packages/raystack/components/tour/tour-overlay.tsx @@ -0,0 +1,217 @@ +'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'; +import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; +import { rectsEqual, type SpotlightRect } from './utils'; + +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; +} + +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; +} + +function useSpotlightFade( + target: Element | null, + { fade, revealed }: { fade: boolean; revealed: boolean } +) { + const [displayed, setDisplayed] = useState(target); + + useEffect(() => { + 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, + spotlightClicks: spotlightClicksProp = false, + className, + ...rest +}: TourOverlayProps) { + const { + open, + step, + status, + spotlightElement, + disableOverlay: disableOverlayTour, + revealed + } = useTourContext('Tour.Overlay'); + const disabled = step?.disableOverlay ?? disableOverlayTour; + const reducedMotion = usePrefersReducedMotion(); + const fade = !reducedMotion; + + const targeted = + step != null && (step.target != null || step.spotlightTarget != null); + + const { displayed, shown } = useSpotlightFade( + open && !disabled && targeted ? spotlightElement : null, + { fade, revealed } + ); + const rect = useSpotlightRect(displayed); + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + 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; + // Requiring the live `spotlightElement` prevents an orphaned dim after a target unmounts. + if (targeted && (!spotlightElement || !displayed || !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; + + const holeOpen = fade ? shown : true; + + return createPortal( + // `rest` spread first so consumers can't clobber the chrome attributes below. +
+
+ {hole && ( +
+ )} + {hole ? ( + <> + {/* Hit strips block clicks around the hole; the center strip is + dropped when spotlightClicks lets clicks through. */} +
+
+
+
+ {!(spotlightClicks && holeOpen) && ( +
+ )} + + ) : ( +
+ )} +
, + 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..f8140023b --- /dev/null +++ b/packages/raystack/components/tour/tour-parts.tsx @@ -0,0 +1,173 @@ +'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'; + +export function TourDefaultLayout() { + const { index } = useTourContext('Tour.Content'); + return ( + <> + + + + + + + + + {index > 0 && } + + + + + ); +} +TourDefaultLayout.displayName = 'Tour.DefaultLayout'; diff --git a/packages/raystack/components/tour/tour-root.tsx b/packages/raystack/components/tour/tour-root.tsx new file mode 100644 index 000000000..08d2a6c9b --- /dev/null +++ b/packages/raystack/components/tour/tour-root.tsx @@ -0,0 +1,438 @@ +'use client'; + +import { useControlled } from '@base-ui/utils/useControlled'; +import { + type ReactNode, + type RefObject, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react'; +import { TourContent } from './tour-content'; +import { TourContext, type TourContextValue } from './tour-context'; +import { TourOverlay } from './tour-overlay'; +import type { + TourActions, + TourEndStatus, + TourEvent, + TourStatus, + TourStep, + TourTransition +} from './types'; +import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; +import { resolveTourTarget, useTourTarget } from './use-tour-target'; +import { rectsEqual } from './utils'; + +// Must match the overlay's opacity transition in `tour.module.css`. +const FADE_OUT_MS = 160; + +const REVEAL_TIMEOUT_MS = 2000; + +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; + } + const vw = window.innerWidth; + const vh = window.innerHeight; + // The second clause handles an oversized target spanning the viewport. + 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 { + /** 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). 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; + /** 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'; + /** + * 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 + * `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', + transition = 'fade', + 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; + + 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] + ); + + 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]); + + // 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(spotlightTarget, { + enabled: open && spotlightTarget != null, + timeout: targetTimeout + }); + const spotlightElement = spotlightOverride ?? anchor; + + const popoverOpen = open && step != null && targetState === 'found'; + const status: TourStatus = !open + ? 'idle' + : targetState === 'found' + ? 'running' + : 'waiting'; + + const detachedStep = + step != null && step.target == null && step.spotlightTarget == null; + + const reducedMotion = usePrefersReducedMotion(); + const [revealed, setRevealed] = useState(false); + const shownOnceRef = useRef(false); + + // 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; + if (revealed) setRevealed(false); + } + + useEffect(() => { + if (!popoverOpen) { + setRevealed(false); + shownOnceRef.current = false; + return; + } + if (detachedStep) { + setRevealed(true); + shownOnceRef.current = true; + return; + } + // 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; + 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); + }; + }, [popoverOpen, detachedStep, spotlightElement, reducedMotion]); + + // Starts false 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]); + + // 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; + if (isElementInView(el)) return; + el.scrollIntoView({ + block: 'center', + inline: 'nearest', + behavior: reducedMotion ? 'auto' : 'smooth' + }); + }, [popoverOpen, index, anchor, reducedMotion]); + + const contextValue = useMemo( + () => ({ + steps, + index, + step, + open, + status, + anchor, + spotlightElement, + popoverOpen, + disableOverlay, + transition, + revealed, + actions + }), + [ + steps, + index, + step, + open, + status, + anchor, + spotlightElement, + popoverOpen, + disableOverlay, + transition, + revealed, + 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..ffef1540e --- /dev/null +++ b/packages/raystack/components/tour/tour.module.css @@ -0,0 +1,185 @@ +/* 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; +} + +/* 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; +} + +/* 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. 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) { + .spotlightCover { + transition: opacity 160ms ease; + } +} + +.overlayHit { + position: absolute; + pointer-events: auto; +} + +.positioner { + z-index: calc(var(--rs-z-index-portal) + 2); +} + +/* 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[data-transition="move"]:has(.popup:not([data-starting-style])) { + transition: transform 460ms cubic-bezier(0.33, 0.7, 0, 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; +} + +/* 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; + 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..9c3521992 --- /dev/null +++ b/packages/raystack/components/tour/tour.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { TourContent } from './tour-content'; +import { TourOverlay } from './tour-overlay'; +import { + TourClose, + TourDescription, + TourNext, + TourPrev, + TourProgress, + TourSkip, + TourTitle +} from './tour-parts'; +import { TourRoot } from './tour-root'; + +export const Tour = Object.assign(TourRoot, { + Overlay: TourOverlay, + Content: TourContent, + 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..e5045cce2 --- /dev/null +++ b/packages/raystack/components/tour/types.ts @@ -0,0 +1,111 @@ +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'; + +/** + * 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; + /** + * 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.Content` 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-prefers-reduced-motion.ts b/packages/raystack/components/tour/use-prefers-reduced-motion.ts new file mode 100644 index 000000000..045495339 --- /dev/null +++ b/packages/raystack/components/tour/use-prefers-reduced-motion.ts @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +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 new file mode 100644 index 000000000..24bcb9fc5 --- /dev/null +++ b/packages/raystack/components/tour/use-tour-target.ts @@ -0,0 +1,102 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { TourTarget } from './types'; + +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 { + return null; + } + } + if (typeof target === 'function') { + try { + return target(); + } catch { + return 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 { + enabled: boolean; + timeout: number; + onNotFound?: () => void; +} + +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) { + 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); + }; + + 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, + // Only selector targets can be newly satisfied by an attribute change. + attributes: typeof target === 'string' + }); + + return () => { + observer.disconnect(); + clearTimer(); + }; + }, [target, enabled, timeout]); + + return { element, state }; +} 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 + ); +} 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';