diff --git a/apps/www/src/app/examples/tour/page.tsx b/apps/www/src/app/examples/tour/page.tsx new file mode 100644 index 000000000..751c20d1a --- /dev/null +++ b/apps/www/src/app/examples/tour/page.tsx @@ -0,0 +1,474 @@ +'use client'; + +import { + BarChartIcon, + BellIcon, + GearIcon, + HomeIcon, + PersonIcon, + RocketIcon +} from '@radix-ui/react-icons'; +import { + Badge, + Button, + Callout, + Dialog, + Flex, + IconButton, + Input, + Navbar, + Search, + Sidebar, + Text, + Tour, + type TourActions, + type TourEvent, + type TourStep +} from '@raystack/apsara'; +import { useEffect, useRef, useState } from 'react'; + +const card: React.CSSProperties = { + padding: 'var(--rs-space-6)', + border: '1px solid var(--rs-color-border-base-primary)', + borderRadius: 'var(--rs-radius-4)', + 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 Page = () => { + 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). + useEffect(() => { + if (!tourOpen) return; + const timer = setTimeout(() => setShowLateCard(true), 1500); + return () => clearTimeout(timer); + }, [tourOpen]); + + 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.' + }, + { + 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.', + side: 'right', + align: 'start' + }, + { + id: 'search', + 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.', + side: 'bottom', + spotlightClicks: true + }, + { + 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.', + side: 'left', + spotlightPadding: 8, + spotlightRadius: 999 + }, + { + id: 'invite-open', + target: '[data-tour="invite"]', + title: 'Invite your team', + 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.', + side: 'top', + spotlightClicks: true, + data: { + requiresAction: 'Click the button to continue' + } satisfies StepData + }, + { + id: 'invite-email', + 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.', + side: 'bottom', + spotlightClicks: true, + data: { hidePrev: true } satisfies StepData + }, + { + id: 'invite-send', + target: '[data-tour="invite-send"]', + title: 'Send it', + content: 'Click “Send invite” to close the dialog and wrap up this part.', + side: 'top', + spotlightClicks: true, + data: { + requiresAction: 'Click Send invite to continue', + hidePrev: true + } satisfies StepData + }, + { + id: 'late-card', + 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.', + side: 'top', + data: { hidePrev: true } satisfies StepData + }, + { + 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.', + side: 'top' + }, + { + id: 'no-overlay', + 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.', + side: 'right', + disableOverlay: true + }, + { + 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.', + data: { hidePrev: true } satisfies StepData + } + ]; + + const logEvent = (event: TourEvent) => { + const detail = + event.type === 'tour:end' + ? `status=${event.status}` + : (event.step?.id ?? ''); + 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); + } + }; + + return ( + + + + + + + + + Raystack + + + + + }> + Dashboard + + }> + Analytics + + }> + Members + + }> + Settings + + + + + + + + + Guided tour example + + + +
+ +
+ + setNotifications(0)} + > + + + + {notifications > 0 && ( + {notifications} + )} +
+
+ + + + Take the guided tour + + + Ten steps across the sidebar, search, a dialog, plus late-mounting + and scrolled targets. Press Escape any time to leave. + + + + + + }> + 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. + + + + + Event log + + + Every tour lifecycle event lands in onEvent: + + + {events.length === 0 ? ( + + No events yet. + + ) : ( + events.map((entry, i) => ( + + {entry} + + )) + )} + + + + + + Team + + + The tour asks you to open this dialog yourself, then steps into + it. + + + + + + + {showLateCard && ( + + + Reports + + + This card mounted 1.5 seconds after the tour started. + + + )} + + + + Workspace activity + + {Array.from({ length: 14 }, (_, i) => ( + + Deploy #{214 - i} finished — all checks passed. + + ))} + + + + + Billing + + + Your plan renews on the 1st. This card lives below the fold so the + tour has to scroll 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. */} + { + if (!next && tourOpen) return; + setInviteOpen(next); + }} + > + + + Invite a teammate + + + + + They will get an email with a link to join this workspace. + + + + + + + + + + + + { + setTourOpen(nextOpen); + // Ending the tour mid-dialog shouldn't strand the dialog open. + if (!nextOpen) setInviteOpen(false); + }} + onStepChange={index => setLastIndex(index)} + onEvent={logEvent} + actionsRef={actionsRef} + > + + + {({ step, index }) => { + const data = (step.data ?? {}) as StepData; + return ( + <> + + + + + + + + {data.requiresAction ? ( + + {data.requiresAction} + + ) : ( + + {index > 0 && !data.hidePrev && } + + + )} + + + ); + }} + + +
+ ); +}; + +export default Page; diff --git a/docs/rfcs/002-unified-dataview-component.md b/docs/rfcs/002-unified-dataview-component.md index c4710bb80..1c0db3856 100644 --- a/docs/rfcs/002-unified-dataview-component.md +++ b/docs/rfcs/002-unified-dataview-component.md @@ -1,7 +1,7 @@ --- ID: RFC 002 Created: April 23, 2026 -Status: Draft +Status: Completed RFC PR: https://github.com/raystack/apsara/pull/752 --- diff --git a/docs/rfcs/003-guided-tour-component.md b/docs/rfcs/003-guided-tour-component.md new file mode 100644 index 000000000..6febc9951 --- /dev/null +++ b/docs/rfcs/003-guided-tour-component.md @@ -0,0 +1,288 @@ +--- +ID: RFC 003 +Created: June 19, 2026 +Status: Draft +RFC PR: https://github.com/raystack/apsara/pull/839 +--- + +# Guided Tour Component + +This RFC proposes `Tour`, a component for building guided product walkthroughs: onboarding flows, feature coach marks, and step by step tutorials. It is built on Apsara's Base UI `Popover`, so it inherits our positioning engine, tokens, and composition style instead of pulling in an outside library. This describes the full intended component; a working prototype already validates the core (positioning, spotlight, controlled state, target waiting). + +## Table of Contents + +- [Guided Tour Component](#guided-tour-component) + - [Table of Contents](#table-of-contents) + - [Background](#background) + - [Choosing an Approach: Library or Build Our Own](#choosing-an-approach-library-or-build-our-own) + - [Adopt an Existing Library](#adopt-an-existing-library) + - [Build Our Own on Base UI Popover](#build-our-own-on-base-ui-popover) + - [Decision](#decision) + - [Proposal](#proposal) + - [Behavior and Features](#behavior-and-features) + - [Parts and Context](#parts-and-context) + - [State and Actions](#state-and-actions) + - [Target Resolution](#target-resolution) + - [Advancing Steps](#advancing-steps) + - [Overlay and Spotlight](#overlay-and-spotlight) + - [Positioning, Dismissal, and Z Index](#positioning-dismissal-and-z-index) + - [Accessibility](#accessibility) + - [Events](#events) + - [Impact](#impact) + - [Helpful Links](#helpful-links) + +## Background + +Guided tours are a common product need: onboarding a new user, introducing a feature, or walking through a multi step task all want the same UI, a sequence of small cards that point at real elements, dim or highlight their surroundings, and let the user move on, go back, or leave. Apsara has no tour primitive today, so the aim here is to solve it once, as a themeable and composable component. + +## Choosing an Approach: Library or Build Our Own + +The first decision is whether to wrap an existing tour library or build the primitive ourselves. + +### Adopt an Existing Library + +Wrapping a mature library (driver.js, Shepherd, React JoyRide and others) is the fastest path. + +- **Pros:** works on day one, battle tested across many apps, and usually ships extras like beacons and keyboard handling. +- **Cons:** its own DOM and CSS that never match our tokens; a second positioning engine running next to Base UI's; another dependency to maintain; and little control over the behavior we need + +### Build Our Own on Base UI Popover + +A tour is really a popover that hops between anchors, plus a dimmed backdrop and a small step machine. Base UI's `Popover` already does the floating element logic, so the work left is the step machine, the spotlight, and the tour specific behavior. + +- **Pros:** native tokens and components, one positioning engine, our usual composition idioms, full control of dismissal, focus, and no new dependency. +- **Cons:** we own the maintenance and it is more upfront work. + +### Decision + +Build our own. A tour sits on top of the whole app and has to feel like part of the product, which is exactly the design system's job. The recurring cost of overriding a library's styling and reconciling its positioning, focus, and stacking with ours outweighs the one time cost of building a focused component on a primitive we already maintain. + +## Proposal + +`Tour` is one compound component, built on Base UI `Popover` and exported from the package root. The parts: + +```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 + // 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 +``` + +A tour is data first: describe each step as an object and pass an array. + +```ts +type TourTarget = string | Element | RefObject | (() => Element | null); + +interface TourStep { + id?: string; + target?: TourTarget; // what to anchor and spotlight; omit for a centered step + title?: ReactNode; + content?: ReactNode; + + side?: 'top' | 'right' | 'bottom' | 'left'; // default 'bottom' + align?: 'start' | 'center' | 'end'; // default 'center' + sideOffset?: number; + + spotlightTarget?: TourTarget; // spotlight something other than the anchor + spotlightPadding?: number; + spotlightRadius?: number; + spotlightClicks?: boolean; // let clicks reach the highlighted element + disableOverlay?: boolean; // override the tour-level overlay for this step + + scrollTarget?: TourTarget; // scroll a different element into view + disableScroll?: boolean; // skip scrolling the target into view + + data?: unknown; // anything; echoed back to the render function and events +} +``` + +The root carries the tour wide options: + +```ts +interface TourRootProps { + steps: TourStep[]; + + open?: boolean; // controlled; or defaultOpen for uncontrolled + defaultOpen?: boolean; + onOpenChange?: (open: boolean, details: { status?: TourEndStatus }) => void; + stepIndex?: number; // controlled; or defaultStepIndex + defaultStepIndex?: number; + onStepChange?: (index: number, step: TourStep) => void; + + onEvent?: (event: TourEvent) => void; + actionsRef?: RefObject; + + targetTimeout?: number; // wait for a missing target, default 5000ms + targetNotFound?: 'skip' | 'stop'; // default 'skip' + + disableOverlay?: boolean; // hide the dimmed overlay for the whole tour + + children?: ReactNode; // defaults to + +} +``` + +The simplest tour is one array and the default card: + +```tsx +const steps: TourStep[] = [ + { target: '[data-test-id="search"]', title: 'Search', content: 'Find anything here.' }, + { target: filtersRef, title: 'Filter', content: 'Narrow results.', side: 'right' }, + { title: "You're all set!", content: 'Explore at your own pace.' }, // no target, centered +]; + +// Renders the overlay + the standard card (Title + Close, Description, Progress, Back, Next/Finish). + +``` + +For tours that react to the app, control `open` and `stepIndex`, pass an `actionsRef`, and render your own card. The render function gets the active step plus `actions`: + +```tsx +interface TourRenderProps { + step: TourStep; + index: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + status: 'idle' | 'waiting' | 'running'; + actions: TourActions; // start, next, prev, go, skip, stop +} + +const actionsRef = useRef(null); + + + + { setOpen(open); if (!open) track('tour_end', status); }} + onStepChange={setIndex} + disableOverlay +> + + {({ step, index, totalSteps, isLastStep, actions }) => ( + + {step.title} + {step.content} + + {index + 1} of {totalSteps} + + + + )} + + +``` + +## Behavior and Features + +### Parts and Context + +```tsx +export const Tour = Object.assign(TourRoot, { + Overlay, Popover, Title, Description, Progress, Next, Prev, Skip, Close, +}); +``` + +The root provides a context; each part reads `step`, `anchor`, `status`, and `actions` from it with `useTourContext` (and throws if used outside ``). Because parts take their data from context rather than props, a consumer can reorder, drop, or replace any of them. + +### State and Actions + +`open` and `stepIndex` each run through Base UI's `useControlled`, so either can be controlled or uncontrolled independently. The index is clamped to the step range, and `actions` keeps a stable identity across renders, so it is safe in `actionsRef` and effect dependencies. + +```ts +interface TourActions { + start: (index?: number) => void; // the only action that runs while closed + next: () => void; // finishes the tour past the last step + prev: () => void; + go: (index: number) => void; + skip: () => void; // ends with status 'skipped' + stop: () => void; // ends with status 'closed' +} +``` + +### Target Resolution + +```ts +target: '#search' // CSS selector +target: searchRef // React ref +target: () => editor.getNode('aoi') // function returning an element +target: document.body // an element +// omit target → a detached step, centered in the viewport +``` + +`useTourTarget` resolves immediately when the element is connected. Otherwise it watches `document.body` with a `MutationObserver` and resolves the moment the target appears, giving up after `targetTimeout` and applying `targetNotFound`. While it waits, `status` is `'waiting'`. + +### Advancing Steps + +The default card's Next button calls `actions.next()`. For a step that should advance when the user does something on the page (draw a shape, open a panel), render your own card with a render function, leave out Next, and call `actions.next()` or `actions.go()` from app code when the relevant state changes. + +```tsx +// advance the draw step once the app reports a shape on the map +useEffect(() => { + if (open && stepIndex === 2 && store.hasShape) actionsRef.current?.go(3); +}, [open, stepIndex, store.hasShape]); +``` + +### Overlay and Spotlight + +The dimming is the box shadow of the spotlight element, not a separate scrim: + +```css +.spotlight { box-shadow: 0 0 0 200vmax var(--rs-color-overlay-black-a5); } +``` + +The transparent `.spotlight` div sits over the target, so its shadow dims everything else and moving it animates the hole. Hit strips block clicks around the hole (the strip over the hole is dropped when `spotlightClicks` is set), and a `requestAnimationFrame` loop keeps the hole on the target through scroll, resize, and transforms. + +### 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. + +### Accessibility + +The card is a Base UI `Popover`, so `Tour.Title` and `Tour.Description` give it an accessible name and description and focus is handled by the popover. Escape ends the tour, and animations respect reduced motion. + +### Events + +```tsx + { + if (e.type === 'error:target-not-found') console.warn('missing target', e.index); + if (e.type === 'tour:end') analytics.track('tour_end', { status: e.status }); + }} +/> +``` + +```ts +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: 'finished' | 'skipped' | 'closed' }; +``` + +The `tour:end` status says how it closed: `next()` past the last step is `finished`, `skip()` is `skipped`, and `stop()`, escape, or a missing target under the `stop` policy is `closed`. The same status reaches `onOpenChange`. + +## Impact + +- New exports: `Tour`, `useTour`, and the types `TourActions`, `TourEndStatus`, `TourEvent`, `TourRenderProps`, `TourStep`, `TourTarget`. Nothing else changes and nothing is deprecated. +- Consumers get onboarding and coach marks from a single `steps` array, with a clear path to more control through composed parts and the render function. +- It keeps bundle size, theming, and stacking under our control instead of an outside library's. +- A prototype validates the core (positioning, spotlight, controlled state, target waiting, an overlay free action gated tour driven through `actions.go()`); this RFC specifies the complete component. + +## Helpful Links + +- [Base UI, Popover](https://base-ui.com/react/components/popover). The primitive the tour is built on. +- [driver.js](https://driverjs.com/) and [Shepherd](https://shepherdjs.dev/). External tour libraries weighed under Choosing an Approach. +- [WAI-ARIA Authoring Practices, Dialog (Modal) Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). Informs focus and labeling for the card. +- Internal: `packages/raystack/components/tour/ANALYSIS.md`, the prototype analysis backing this RFC. +- Internal: `apps/www/src/app/examples/tour/page.tsx`, the prototype example. diff --git a/packages/raystack/components/tour-beta/__tests__/tour.test.tsx b/packages/raystack/components/tour-beta/__tests__/tour.test.tsx new file mode 100644 index 000000000..7639988d6 --- /dev/null +++ b/packages/raystack/components/tour-beta/__tests__/tour.test.tsx @@ -0,0 +1,339 @@ +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 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(); + }); + + 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(screen.getByText('Step two')).toBeInTheDocument() + ); + 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 without a target', async () => { + render(); + await waitFor(() => + expect(screen.getByText('Step three')).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('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', async () => { + const LateMount = () => { + const [show, setShow] = useState(false); + return ( +
+ + {show &&
Late content
} + +
+ ); + }; + render(); + expect(screen.queryByRole('dialog')).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('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('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' }); + }); +}); diff --git a/packages/raystack/components/tour-beta/index.tsx b/packages/raystack/components/tour-beta/index.tsx new file mode 100644 index 000000000..2da5b26ed --- /dev/null +++ b/packages/raystack/components/tour-beta/index.tsx @@ -0,0 +1,17 @@ +export { Tour } from './tour'; +export { type UseTourReturn, useTour } from './tour-context'; +export type { TourProgressProps } from './tour-misc'; +export type { TourOverlayProps } from './tour-overlay'; +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-beta/tour-backdrop.tsx b/packages/raystack/components/tour-beta/tour-backdrop.tsx new file mode 100644 index 000000000..3a54a7736 --- /dev/null +++ b/packages/raystack/components/tour-beta/tour-backdrop.tsx @@ -0,0 +1,6 @@ +// Superseded by tour-overlay.tsx (the part is named Tour.Overlay now that +// the API sticks to the "overlay" term). Safe to delete this file. +export { + TourOverlay as TourBackdrop, + type TourOverlayProps as TourBackdropProps +} from './tour-overlay'; diff --git a/packages/raystack/components/tour-beta/tour-context.tsx b/packages/raystack/components/tour-beta/tour-context.tsx new file mode 100644 index 000000000..6fd84ce63 --- /dev/null +++ b/packages/raystack/components/tour-beta/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. */ + 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 control 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-beta/tour-misc.tsx b/packages/raystack/components/tour-beta/tour-misc.tsx new file mode 100644 index 000000000..e2e366b60 --- /dev/null +++ b/packages/raystack/components/tour-beta/tour-misc.tsx @@ -0,0 +1,151 @@ +'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 { 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'; diff --git a/packages/raystack/components/tour-beta/tour-overlay.tsx b/packages/raystack/components/tour-beta/tour-overlay.tsx new file mode 100644 index 000000000..9880197b5 --- /dev/null +++ b/packages/raystack/components/tour-beta/tour-overlay.tsx @@ -0,0 +1,176 @@ +'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. + */ +function useSpotlightRect(element: Element | null): SpotlightRect | null { + const [rect, setRect] = useState(null); + + useEffect(() => { + if (!element) { + setRect(null); + return; + } + let frame = 0; + const track = () => { + 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; + const rect = useSpotlightRect(open && !disabled ? spotlightElement : null); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!open || !mounted || disabled) return null; + + const padding = step?.spotlightPadding ?? spotlightPadding; + const radius = step?.spotlightRadius ?? spotlightRadius; + const spotlightClicks = step?.spotlightClicks ?? spotlightClicksProp; + + const hole = rect + ? { + x: rect.x - padding, + y: rect.y - padding, + width: rect.width + padding * 2, + height: rect.height + padding * 2 + } + : null; + + return createPortal( +
+
+ {hole ? ( + <> +
+
+
+
+ {!spotlightClicks && ( +
+ )} + + ) : ( +
+ )} +
, + document.body + ); +} + +TourOverlay.displayName = 'Tour.Overlay'; diff --git a/packages/raystack/components/tour-beta/tour-popover.tsx b/packages/raystack/components/tour-beta/tour-popover.tsx new file mode 100644 index 000000000..1b36f7678 --- /dev/null +++ b/packages/raystack/components/tour-beta/tour-popover.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, useMemo } from 'react'; +import { Flex } from '../flex'; +import styles from './tour.module.css'; +import { useTourContext } from './tour-context'; +import { + TourClose, + TourDescription, + TourNext, + TourPrev, + TourProgress, + TourTitle +} from './tour-misc'; +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); +} + +function TourDefaultLayout() { + const { index } = useTourContext('Tour.Popover'); + return ( + <> + + + + + + + + + {index > 0 && } + + + + + ); +} + +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; + + // 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). + const centerAnchor = useMemo( + () => ({ + getBoundingClientRect: () => { + const x = window.innerWidth / 2; + const y = window.innerHeight / 2; + return DOMRect.fromRect({ x, y, width: 0, height: 0 }); + } + }), + [] + ); + + 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-beta/tour-root.tsx b/packages/raystack/components/tour-beta/tour-root.tsx new file mode 100644 index 000000000..0fa988525 --- /dev/null +++ b/packages/raystack/components/tour-beta/tour-root.tsx @@ -0,0 +1,307 @@ +'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'; + +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; + onStepChange?: (index: number, step: TourStep) => void; + /** Receives every tour lifecycle event. */ + onEvent?: (event: TourEvent) => void; + /** A ref to 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 a spotlight backdrop and a popover with the + * standard layout; compose `Tour.Backdrop` and `Tour.Popover` 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) => { + setIndex(at); + 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 === 'resolving' + ? 'waiting' + : 'running'; + + // 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]); + + useEffect(() => { + if (!popoverOpen || !step || step.disableScroll) return; + const el = resolveTourTarget(step.scrollTarget) ?? anchor; + if (!el) return; + const rect = el.getBoundingClientRect(); + const fullyVisible = + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth; + if (fullyVisible) return; + const reduceMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + el.scrollIntoView({ + block: 'center', + inline: 'nearest', + behavior: reduceMotion ? 'auto' : 'smooth' + }); + }, [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-beta/tour.module.css b/packages/raystack/components/tour-beta/tour.module.css new file mode 100644 index 000000000..aae323601 --- /dev/null +++ b/packages/raystack/components/tour-beta/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 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-beta/tour.tsx b/packages/raystack/components/tour-beta/tour.tsx new file mode 100644 index 000000000..063ae044e --- /dev/null +++ b/packages/raystack/components/tour-beta/tour.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { + TourClose, + TourDescription, + TourNext, + TourPrev, + TourProgress, + TourSkip, + TourTitle +} from './tour-misc'; +import { TourOverlay } from './tour-overlay'; +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-beta/types.ts b/packages/raystack/components/tour-beta/types.ts new file mode 100644 index 000000000..55ef8fc36 --- /dev/null +++ b/packages/raystack/components/tour-beta/types.ts @@ -0,0 +1,95 @@ +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. + */ +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. 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'; + +/** + * `waiting` means the active step's target is not in the DOM yet and the + * tour is observing for it to appear. + */ +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. */ + start: (index?: number) => void; + /** Advance to the next step; finishes the tour on 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-beta/use-tour-target.ts b/packages/raystack/components/tour-beta/use-tour-target.ts new file mode 100644 index 000000000..92dd50eea --- /dev/null +++ b/packages/raystack/components/tour-beta/use-tour-target.ts @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { TourTarget } from './types'; + +export function resolveTourTarget( + target: TourTarget | null | undefined +): Element | null { + if (!target || typeof document === 'undefined') return null; + if (typeof target === 'string') return document.querySelector(target); + if (typeof target === 'function') return target(); + if (target instanceof Element) return target; + return target.current; +} + +export type TourTargetState = 'idle' | 'resolving' | 'found' | 'not-found'; + +interface UseTourTargetOptions { + enabled: boolean; + /** How long to wait for a missing target before giving up, in ms. */ + timeout: number; + onNotFound?: () => void; +} + +/** + * Resolves a step target to a live element. Targets not in the DOM yet + * (lazy content, route transitions) are awaited with a MutationObserver + * until `timeout` elapses. + */ +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) { + // Detached step: nothing to resolve, the popover renders centered. + setElement(null); + setState('found'); + return; + } + const found = resolveTourTarget(target); + if (found?.isConnected) { + setElement(found); + setState('found'); + return; + } + setElement(null); + setState('resolving'); + const observer = new MutationObserver(() => { + const el = resolveTourTarget(target); + if (el?.isConnected) { + observer.disconnect(); + clearTimeout(timer); + setElement(el); + setState('found'); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true + }); + const timer = setTimeout(() => { + observer.disconnect(); + setState('not-found'); + onNotFoundRef.current?.(); + }, timeout); + return () => { + observer.disconnect(); + clearTimeout(timer); + }; + }, [target, enabled, timeout]); + + return { element, state }; +} diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 05f8ad6c6..4983b3608 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -107,3 +107,13 @@ export { Toast, toastManager, useToastManager } from './components/toast'; export { Toggle } from './components/toggle'; export { Toolbar } from './components/toolbar'; export { Tooltip } from './components/tooltip'; +export { + Tour, + type TourActions, + type TourEndStatus, + type TourEvent, + type TourRenderProps, + type TourStep, + type TourTarget, + useTour +} from './components/tour-beta';