From 57d3d9be7d3db3afd9aed016743dee3e2ae4bafb Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:24:32 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Fix=20tour-?= =?UTF-8?q?beta=20target=20and=20overlay=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tour-beta/__tests__/tour.test.tsx | 339 ------------------ .../raystack/components/tour-beta/index.tsx | 17 - .../components/tour-beta/tour-backdrop.tsx | 6 - .../components/tour-beta/tour-context.tsx | 62 ---- .../components/tour-beta/tour-misc.tsx | 151 -------- .../components/tour-beta/tour-overlay.tsx | 176 --------- .../components/tour-beta/tour-popover.tsx | 155 -------- .../components/tour-beta/tour-root.tsx | 307 ---------------- .../components/tour-beta/tour.module.css | 156 -------- .../raystack/components/tour-beta/tour.tsx | 26 -- .../raystack/components/tour-beta/types.ts | 95 ----- .../components/tour-beta/use-tour-target.ts | 85 ----- 12 files changed, 1575 deletions(-) delete mode 100644 packages/raystack/components/tour-beta/__tests__/tour.test.tsx delete mode 100644 packages/raystack/components/tour-beta/index.tsx delete mode 100644 packages/raystack/components/tour-beta/tour-backdrop.tsx delete mode 100644 packages/raystack/components/tour-beta/tour-context.tsx delete mode 100644 packages/raystack/components/tour-beta/tour-misc.tsx delete mode 100644 packages/raystack/components/tour-beta/tour-overlay.tsx delete mode 100644 packages/raystack/components/tour-beta/tour-popover.tsx delete mode 100644 packages/raystack/components/tour-beta/tour-root.tsx delete mode 100644 packages/raystack/components/tour-beta/tour.module.css delete mode 100644 packages/raystack/components/tour-beta/tour.tsx delete mode 100644 packages/raystack/components/tour-beta/types.ts delete mode 100644 packages/raystack/components/tour-beta/use-tour-target.ts diff --git a/packages/raystack/components/tour-beta/__tests__/tour.test.tsx b/packages/raystack/components/tour-beta/__tests__/tour.test.tsx deleted file mode 100644 index 7639988d6..000000000 --- a/packages/raystack/components/tour-beta/__tests__/tour.test.tsx +++ /dev/null @@ -1,339 +0,0 @@ -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 deleted file mode 100644 index 2da5b26ed..000000000 --- a/packages/raystack/components/tour-beta/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 3a54a7736..000000000 --- a/packages/raystack/components/tour-beta/tour-backdrop.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// 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 deleted file mode 100644 index 6fd84ce63..000000000 --- a/packages/raystack/components/tour-beta/tour-context.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'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 deleted file mode 100644 index e2e366b60..000000000 --- a/packages/raystack/components/tour-beta/tour-misc.tsx +++ /dev/null @@ -1,151 +0,0 @@ -'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 deleted file mode 100644 index 9880197b5..000000000 --- a/packages/raystack/components/tour-beta/tour-overlay.tsx +++ /dev/null @@ -1,176 +0,0 @@ -'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 deleted file mode 100644 index 1b36f7678..000000000 --- a/packages/raystack/components/tour-beta/tour-popover.tsx +++ /dev/null @@ -1,155 +0,0 @@ -'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 deleted file mode 100644 index 0fa988525..000000000 --- a/packages/raystack/components/tour-beta/tour-root.tsx +++ /dev/null @@ -1,307 +0,0 @@ -'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 deleted file mode 100644 index aae323601..000000000 --- a/packages/raystack/components/tour-beta/tour.module.css +++ /dev/null @@ -1,156 +0,0 @@ -/* 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 deleted file mode 100644 index 063ae044e..000000000 --- a/packages/raystack/components/tour-beta/tour.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'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 deleted file mode 100644 index 55ef8fc36..000000000 --- a/packages/raystack/components/tour-beta/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 92dd50eea..000000000 --- a/packages/raystack/components/tour-beta/use-tour-target.ts +++ /dev/null @@ -1,85 +0,0 @@ -'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 }; -}