diff --git a/src/components/Floater.tsx b/src/components/Floater.tsx index c2552e39c..2021a732d 100644 --- a/src/components/Floater.tsx +++ b/src/components/Floater.tsx @@ -217,21 +217,24 @@ export default function JoyrideFloater(props: FloaterProps) { beaconMiddlewareRef.current = beaconFloating.middlewareData; useEffect(() => { - const ref = tooltipFloating.refs.reference.current; - const floating = tooltipFloating.refs.floating.current; + const { floating, reference } = tooltipFloating.elements; - if (!ref || !floating || lifecycle !== LIFECYCLE.TOOLTIP) { + if (!reference || !floating || lifecycle !== LIFECYCLE.TOOLTIP) { return undefined; } - return autoUpdate(ref, floating, tooltipFloating.update, step.floatingOptions?.autoUpdate); + return autoUpdate( + reference, + floating, + tooltipFloating.update, + step.floatingOptions?.autoUpdate, + ); }, [ lifecycle, - tooltipFloating.refs.reference, - tooltipFloating.refs.floating, tooltipFloating.update, step.floatingOptions?.autoUpdate, step.target, + tooltipFloating.elements, ]); // Wire reference element to both floating instances diff --git a/src/components/Overlay.tsx b/src/components/Overlay.tsx index d59114f13..4989b850f 100644 --- a/src/components/Overlay.tsx +++ b/src/components/Overlay.tsx @@ -45,10 +45,11 @@ export default function JoyrideOverlay(props: OverlayProps) { spotlightPadding, scrolling || waiting, ); - const previousLifecycleRef = useRef(lifecycle); const overlayRef = useRef(null); - const [showSpotlight, setShowSpotlight] = useState(false); + const showSpotlight = + (lifecycle === LIFECYCLE.TOOLTIP || lifecycle === LIFECYCLE.TOOLTIP_BEFORE) && + placement !== 'center'; const [spotlightReady, setSpotlightReady] = useState(false); const container = portalElement ? (overlayRef.current?.offsetParent as HTMLElement | null) : null; @@ -67,23 +68,6 @@ export default function JoyrideOverlay(props: OverlayProps) { } as CSSProperties; }, [overlayHeight, styles.overlay]); - useEffect(() => { - const previousLifecycle = previousLifecycleRef.current; - - previousLifecycleRef.current = lifecycle; - - if ( - (lifecycle === LIFECYCLE.TOOLTIP || lifecycle === LIFECYCLE.TOOLTIP_BEFORE) && - previousLifecycle !== LIFECYCLE.TOOLTIP && - previousLifecycle !== LIFECYCLE.TOOLTIP_BEFORE && - placement !== 'center' - ) { - setShowSpotlight(true); - } else if (lifecycle === LIFECYCLE.COMPLETE && previousLifecycle !== LIFECYCLE.COMPLETE) { - setShowSpotlight(false); - } - }, [lifecycle, placement]); - const showCutout = showSpotlight && !scrolling && !waiting; useEffect(() => { diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index 57cceae11..af4da4349 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -22,7 +22,7 @@ export default function Tooltip(props: TooltipProps) { const handleClickBack = (event: MouseEvent) => { event.preventDefault(); - controls.prev(); + controls.prev(ORIGIN.BUTTON_BACK); }; const handleClickClose = (event: MouseEvent) => { @@ -44,7 +44,7 @@ export default function Tooltip(props: TooltipProps) { return; } - controls.next(); + controls.next(ORIGIN.BUTTON_PRIMARY); }; const handleClickSkip = (event: MouseEvent) => { diff --git a/src/components/TourRenderer.tsx b/src/components/TourRenderer.tsx index 2147ee2d8..fc5e407ee 100644 --- a/src/components/TourRenderer.tsx +++ b/src/components/TourRenderer.tsx @@ -72,7 +72,7 @@ export default function TourRenderer({ if (event.key === 'Escape' && step.dismissKeyAction) { if (step.dismissKeyAction === 'next') { - controls.next(); + controls.next(ORIGIN.KEYBOARD); } else { controls.close(ORIGIN.KEYBOARD); } @@ -90,7 +90,7 @@ export default function TourRenderer({ if (step?.overlayClickAction === 'close') { controls.close(ORIGIN.OVERLAY); } else if (step?.overlayClickAction === 'next') { - controls.next(); + controls.next(ORIGIN.OVERLAY); } }, [controls, step?.overlayClickAction]); diff --git a/src/hooks/useControls.ts b/src/hooks/useControls.ts index 4c41e6a20..59006871a 100644 --- a/src/hooks/useControls.ts +++ b/src/hooks/useControls.ts @@ -70,7 +70,7 @@ export default function useControls( const info = () => omit(store.current.getSnapshot(), 'positioned'); - const next = () => { + const next = (origin?: Origin | null) => { const { index, size, status } = getState(); if (status !== STATUS.RUNNING) { @@ -81,6 +81,7 @@ export default function useControls( action: ACTIONS.NEXT, index: getUpdatedIndex(index + 1, size), lifecycle: LIFECYCLE.COMPLETE, + origin, positioned: false, scrolling: false, waiting: false, @@ -103,7 +104,7 @@ export default function useControls( }); }; - const previous = () => { + const previous = (origin?: Origin | null) => { const { index, size, status } = getState(); if (status !== STATUS.RUNNING) { @@ -114,6 +115,7 @@ export default function useControls( action: ACTIONS.PREV, index: getUpdatedIndex(index - 1, size), lifecycle: LIFECYCLE.COMPLETE, + origin, positioned: false, scrolling: false, waiting: false, diff --git a/src/hooks/useLifecycleEffect.ts b/src/hooks/useLifecycleEffect.ts index 1faf6c851..96819416f 100644 --- a/src/hooks/useLifecycleEffect.ts +++ b/src/hooks/useLifecycleEffect.ts @@ -5,9 +5,9 @@ import type { EmitEvent } from '~/hooks/useEventEmitter'; import type { AddFailure, MergedProps } from '~/hooks/useTourEngine'; import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals'; import { treeChanges } from '~/modules/changes'; -import { getElement, isElementVisible } from '~/modules/dom'; -import { log, needsScrolling, shouldHideBeacon } from '~/modules/helpers'; -import { getMergedStep } from '~/modules/step'; +import { getElement, isElementVisible, needsScrolling } from '~/modules/dom'; +import { log } from '~/modules/helpers'; +import { getMergedStep, shouldHideBeacon } from '~/modules/step'; import createStore from '~/modules/store'; import type { StoreState } from '~/modules/store'; diff --git a/src/literals/index.ts b/src/literals/index.ts index 933d152dc..cc25fa796 100644 --- a/src/literals/index.ts +++ b/src/literals/index.ts @@ -39,9 +39,10 @@ export const LIFECYCLE = { } as const; export const ORIGIN = { + BUTTON_BACK: 'button_back', BUTTON_CLOSE: 'button_close', - BUTTON_SKIP: 'button_skip', BUTTON_PRIMARY: 'button_primary', + BUTTON_SKIP: 'button_skip', KEYBOARD: 'keyboard', OVERLAY: 'overlay', } as const; diff --git a/src/modules/dom.ts b/src/modules/dom.ts index 01a937b44..6797f4045 100644 --- a/src/modules/dom.ts +++ b/src/modules/dom.ts @@ -1,7 +1,17 @@ import scroll from 'scroll'; import scrollParent from 'scrollparent'; -import type { StepTarget } from '~/types'; +import { LIFECYCLE } from '~/literals'; + +import type { Lifecycle, StepMerged, StepTarget } from '~/types'; + +interface NeedsScrollingOptions { + isFirstStep: boolean; + scrollToFirstStep: boolean; + step: StepMerged; + target: HTMLElement | null; + targetLifecycle?: Lifecycle; +} export function canUseDOM() { return !!(typeof window !== 'undefined' && window.document?.createElement); @@ -294,6 +304,27 @@ export function isElementVisible(element: HTMLElement): boolean { return true; } +export function needsScrolling(options: NeedsScrollingOptions): boolean { + const { isFirstStep, scrollToFirstStep, step, target, targetLifecycle } = options; + + if ( + step.skipScroll || + (isFirstStep && !scrollToFirstStep && targetLifecycle !== LIFECYCLE.TOOLTIP) || + step.placement === 'center' + ) { + return false; + } + + const parent = (target?.isConnected ? getScrollParent(target) : scrollDocument()) as Element; + const isCustomScrollParent = parent ? !parent.isSameNode(scrollDocument()) : false; + + if ((step.isFixed || hasPosition(target)) && !isCustomScrollParent) { + return false; + } + + return parent.scrollHeight > parent.clientHeight; +} + export function scrollDocument(): Element | HTMLElement { return document.scrollingElement ?? document.documentElement; } diff --git a/src/modules/helpers.tsx b/src/modules/helpers.tsx index 74f0ddc5d..75b8a3b56 100644 --- a/src/modules/helpers.tsx +++ b/src/modules/helpers.tsx @@ -3,21 +3,7 @@ import innerText from 'react-innertext'; import deepmergeFactory from '@fastify/deepmerge'; import is from 'is-lite'; -import { ACTIONS, LIFECYCLE } from '~/literals'; - -import type { - Actions, - AnyObject, - Lifecycle, - NarrowPlainObject, - PlainObject, - Simplify, - State, - Step, - StepMerged, -} from '~/types'; - -import { getScrollParent, hasPosition, scrollDocument } from './dom'; +import type { AnyObject, NarrowPlainObject, PlainObject, Simplify } from '~/types'; type RemoveType = { [Key in keyof TObject as TObject[Key] extends TExclude ? never : Key]: TObject[Key]; @@ -29,14 +15,6 @@ interface GetReactNodeTextOptions { steps?: number; } -interface NeedsScrollingOptions { - isFirstStep: boolean; - scrollToFirstStep: boolean; - step: StepMerged; - target: HTMLElement | null; - targetLifecycle?: Lifecycle; -} - /** * Remove properties with undefined value from an object */ @@ -94,18 +72,6 @@ export function getReactNodeText(input: ReactNode, options: GetReactNodeTextOpti return text; } -/** - * Convert hex to RGB - */ -export function hexToRGB(hex: string): Array { - const shorthandRegex = /^#?([\da-f])([\da-f])([\da-f])$/i; - const properHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b); - - const result = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})/i.exec(properHex); - - return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : []; -} - /** * Log method calls if debug is enabled */ @@ -141,27 +107,6 @@ export function mergeProps, TProps extend >; } -export function needsScrolling(options: NeedsScrollingOptions): boolean { - const { isFirstStep, scrollToFirstStep, step, target, targetLifecycle } = options; - - if ( - step.skipScroll || - (isFirstStep && !scrollToFirstStep && targetLifecycle !== LIFECYCLE.TOOLTIP) || - step.placement === 'center' - ) { - return false; - } - - const parent = (target?.isConnected ? getScrollParent(target) : scrollDocument()) as Element; - const isCustomScrollParent = parent ? !parent.isSameNode(scrollDocument()) : false; - - if ((step.isFixed || hasPosition(target)) && !isCustomScrollParent) { - return false; - } - - return parent.scrollHeight > parent.clientHeight; -} - /** * A function that does nothing. */ @@ -275,17 +220,6 @@ export function replaceLocaleContent(input: ReactNode, step: number, steps: numb return input; } -/** - * Decide if the step shouldn't skip the beacon - */ -export function shouldHideBeacon(step: Step, state: State, continuous: boolean): boolean { - const { action } = state; - - const withContinuous = continuous && ([ACTIONS.PREV, ACTIONS.NEXT] as Actions[]).includes(action); - - return step.skipBeacon || step.placement === 'center' || withContinuous; -} - /** * Sort object keys */ diff --git a/src/modules/step.ts b/src/modules/step.ts index 50fbaf920..b98b41ab1 100644 --- a/src/modules/step.ts +++ b/src/modules/step.ts @@ -1,14 +1,17 @@ import is from 'is-lite'; import { defaultFloatingOptions, defaultLocale, defaultOptions, defaultStep } from '~/defaults'; +import { ACTIONS } from '~/literals'; import getStyles from '~/styles'; import type { + Actions, FloatingOptions, Locale, Options, Props, SpotlightPadding, + State, Step, StepMerged, } from '~/types'; @@ -109,6 +112,17 @@ export function normalizeSpotlightPadding( }; } +/** + * Decide if the step shouldn't skip the beacon + */ +export function shouldHideBeacon(step: Step, state: State, continuous: boolean): boolean { + const { action } = state; + + const withContinuous = continuous && ([ACTIONS.PREV, ACTIONS.NEXT] as Actions[]).includes(action); + + return step.skipBeacon || step.placement === 'center' || withContinuous; +} + /** * Validate if a step is valid */ diff --git a/src/styles.ts b/src/styles.ts index 9c975e806..b3d5bb35e 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,8 +1,20 @@ import { canUseDOM } from '~/modules/dom'; -import { deepMerge, hexToRGB } from '~/modules/helpers'; +import { deepMerge } from '~/modules/helpers'; import type { Props, StepMerged, Styles } from '~/types'; +/** + * Convert hex to RGB + */ +export function hexToRGB(hex: string): Array { + const shorthandRegex = /^#?([\da-f])([\da-f])([\da-f])$/i; + const properHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b); + + const result = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})/i.exec(properHex); + + return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : []; +} + const buttonReset = { backgroundColor: 'transparent', border: 0, diff --git a/src/types/state.ts b/src/types/state.ts index ef5fe9bb5..0e3288897 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -9,11 +9,11 @@ export type Controls = { /** Get the current tour state. */ info: () => State; /** Advance to the next step. */ - next: () => void; + next: (origin?: Origin | null) => void; /** Open the tooltip for the current step. */ open: () => void; /** Go back to the previous step. */ - prev: () => void; + prev: (origin?: Origin | null) => void; /** Reset the tour. Optionally restart from the beginning. */ reset: (restart?: boolean) => void; /** Skip the tour entirely. */ diff --git a/test/__fixtures__/CustomOptions.tsx b/test/__fixtures__/CustomOptions.tsx index 0c957f3e4..9871a33ea 100644 --- a/test/__fixtures__/CustomOptions.tsx +++ b/test/__fixtures__/CustomOptions.tsx @@ -1,5 +1,5 @@ import { Joyride, type Props, STATUS, type Step } from '~/index'; -import { hexToRGB } from '~/modules/helpers'; +import { hexToRGB } from '~/styles'; interface CustomOptionsProps extends Omit { finishedCallback: () => void; diff --git a/test/__setup__/tour-flow.tsx b/test/__setup__/tour-flow.tsx index cc3dec622..1f156cd3c 100644 --- a/test/__setup__/tour-flow.tsx +++ b/test/__setup__/tour-flow.tsx @@ -20,9 +20,9 @@ interface TourFlowOptions { export interface TourInteractions { close: (origin?: Origin | null) => void; - next: () => void; + next: (origin?: Origin | null) => void; openTooltip: () => void; - prev: () => void; + prev: (origin?: Origin | null) => void; skip: (origin?: Origin) => void; start: () => void; supportsUIGuards: boolean; @@ -108,6 +108,7 @@ export function registerTourFlowTests(options: TourFlowOptions): void { action: ACTIONS.NEXT, index: 0, lifecycle: LIFECYCLE.COMPLETE, + origin: interactions.supportsUIGuards ? 'button_primary' : null, type: EVENTS.STEP_AFTER, }), expectControls(), @@ -319,6 +320,7 @@ export function registerTourFlowTests(options: TourFlowOptions): void { action: ACTIONS.PREV, index: 2, lifecycle: LIFECYCLE.COMPLETE, + origin: interactions.supportsUIGuards ? 'button_back' : null, type: EVENTS.STEP_AFTER, }), expectControls(), @@ -332,6 +334,7 @@ export function registerTourFlowTests(options: TourFlowOptions): void { action: ACTIONS.PREV, index: 2, lifecycle: LIFECYCLE.COMPLETE, + origin: interactions.supportsUIGuards ? 'button_back' : null, type: EVENTS.STEP_AFTER_HOOK, }), expectControls(), @@ -413,7 +416,7 @@ export function registerTourFlowTests(options: TourFlowOptions): void { action: ACTIONS.NEXT, index: 1, lifecycle: LIFECYCLE.COMPLETE, - origin: null, + origin: interactions.supportsUIGuards ? 'overlay' : null, type: EVENTS.STEP_AFTER, }), expectControls(), diff --git a/test/components/Floater.spec.tsx b/test/components/Floater.spec.tsx index 276f40795..b0f32d596 100644 --- a/test/components/Floater.spec.tsx +++ b/test/components/Floater.spec.tsx @@ -39,6 +39,10 @@ function createFloatingReturn(overrides: Record = {}) { x: 100, y: 200, middlewareData: {}, + elements: { + floating: null, + reference: null, + }, refs: { setFloating: vi.fn(), setReference: vi.fn(), @@ -91,6 +95,41 @@ describe('Floater', () => { vi.clearAllMocks(); }); + describe('autoUpdate', () => { + it('should call autoUpdate when elements are available and lifecycle is TOOLTIP', async () => { + const { autoUpdate: autoUpdateMock } = await import('@floating-ui/react-dom'); + + const floatingEl = document.createElement('div'); + const referenceEl = document.createElement('div'); + const updateFn = vi.fn(); + + mockUseFloating + .mockReturnValueOnce( + createFloatingReturn({ + elements: { floating: floatingEl, reference: referenceEl }, + update: updateFn, + }), + ) + .mockReturnValueOnce(createFloatingReturn()); + + render(); + + expect(autoUpdateMock).toHaveBeenCalledWith(referenceEl, floatingEl, updateFn, undefined); + }); + + it('should not call autoUpdate when elements are null', async () => { + const { autoUpdate: autoUpdateMock } = await import('@floating-ui/react-dom'); + + mockUseFloating + .mockReturnValueOnce(createFloatingReturn()) + .mockReturnValueOnce(createFloatingReturn()); + + render(); + + expect(autoUpdateMock).not.toHaveBeenCalled(); + }); + }); + describe('lifecycle branching', () => { it('should render tooltip when lifecycle is TOOLTIP', () => { mockUseFloating diff --git a/test/components/Overlay.spec.tsx b/test/components/Overlay.spec.tsx index 279c91316..e831a828b 100644 --- a/test/components/Overlay.spec.tsx +++ b/test/components/Overlay.spec.tsx @@ -47,6 +47,33 @@ describe('Overlay', () => { expect(screen.getByTestId('overlay')).toMatchSnapshot(); }); + it('should show spotlight immediately on mount with lifecycle=TOOLTIP', () => { + const targetEl = document.createElement('div'); + + targetEl.className = 'target'; + targetEl.getBoundingClientRect = () => + ({ + x: 100, + y: 200, + width: 120, + height: 30, + top: 200, + left: 100, + right: 220, + bottom: 230, + }) as DOMRect; + document.body.appendChild(targetEl); + + render(); + + const paths = screen.getByTestId('spotlight').querySelectorAll('path'); + + // With spotlight visible, there should be a cutout path (more than just the overlay rect) + expect(paths.length).toBeGreaterThan(1); + + document.body.removeChild(targetEl); + }); + it('should return null for hidden lifecycles in continuous mode', () => { const { container } = render(); diff --git a/test/components/Tooltip.spec.tsx b/test/components/Tooltip.spec.tsx index a70757b4a..96c6197b2 100644 --- a/test/components/Tooltip.spec.tsx +++ b/test/components/Tooltip.spec.tsx @@ -74,7 +74,7 @@ describe('Tooltip', () => { fireEvent.click(screen.getByTestId('button-primary')); - expect(controls.next).toHaveBeenCalledTimes(1); + expect(controls.next).toHaveBeenCalledWith('button_primary'); }); it('should call controls.close on primary click in non-continuous mode', () => { @@ -107,7 +107,7 @@ describe('Tooltip', () => { fireEvent.click(screen.getByTestId('button-back')); - expect(controls.prev).toHaveBeenCalledTimes(1); + expect(controls.prev).toHaveBeenCalledWith('button_back'); }); it('should call controls.close on close button click', () => { diff --git a/test/hooks/useControls.spec.ts b/test/hooks/useControls.spec.ts index bd630a990..40f70d1eb 100644 --- a/test/hooks/useControls.spec.ts +++ b/test/hooks/useControls.spec.ts @@ -123,6 +123,22 @@ describe('useControls', () => { expect(state.index).toBe(1); }); + it('should set origin when provided', () => { + const { result, store } = setup({ index: 0 }); + + result.current.next('button_primary'); + + expect(store.getSnapshot().origin).toBe('button_primary'); + }); + + it('should default origin to null', () => { + const { result, store } = setup({ index: 0 }); + + result.current.next(); + + expect(store.getSnapshot().origin).toBeNull(); + }); + it('should clamp index to size', () => { const { result, store } = setup({ index: 2 }); @@ -173,6 +189,22 @@ describe('useControls', () => { expect(state.index).toBe(1); }); + it('should set origin when provided', () => { + const { result, store } = setup({ index: 2 }); + + result.current.prev('button_back'); + + expect(store.getSnapshot().origin).toBe('button_back'); + }); + + it('should default origin to null', () => { + const { result, store } = setup({ index: 2 }); + + result.current.prev(); + + expect(store.getSnapshot().origin).toBeNull(); + }); + it('should clamp index to 0', () => { const { result, store } = setup({ index: 0 }); diff --git a/test/hooks/useJoyride.spec.tsx b/test/hooks/useJoyride.spec.tsx index 83a59d525..e1ef9615c 100644 --- a/test/hooks/useJoyride.spec.tsx +++ b/test/hooks/useJoyride.spec.tsx @@ -17,16 +17,8 @@ vi.mock('~/modules/dom', async () => { getScrollParent: vi.fn(() => document.documentElement), getScrollTo: vi.fn(() => 0), hasCustomScrollParent: vi.fn(() => false), - scrollTo: vi.fn(() => ({ cancel: vi.fn(), promise: Promise.resolve() })), - }; -}); - -vi.mock('~/modules/helpers', async () => { - const actual = await vi.importActual('~/modules/helpers'); - - return { - ...actual, needsScrolling: vi.fn(() => false), + scrollTo: vi.fn(() => ({ cancel: vi.fn(), promise: Promise.resolve() })), }; }); diff --git a/test/hooks/useLifecycleEffect.spec.ts b/test/hooks/useLifecycleEffect.spec.ts index 8da5e0f9d..402afc573 100644 --- a/test/hooks/useLifecycleEffect.spec.ts +++ b/test/hooks/useLifecycleEffect.spec.ts @@ -3,8 +3,8 @@ import type { RefObject } from 'react'; import { defaultProps } from '~/defaults'; import useLifecycleEffect from '~/hooks/useLifecycleEffect'; import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals'; -import { getElement, isElementVisible } from '~/modules/dom'; -import { mergeProps, needsScrolling } from '~/modules/helpers'; +import { getElement, isElementVisible, needsScrolling } from '~/modules/dom'; +import { mergeProps } from '~/modules/helpers'; import createStore from '~/modules/store'; import type { StoreState } from '~/modules/store'; import { createStep, fromPartial, renderHook } from '~/test-utils'; @@ -23,16 +23,8 @@ vi.mock('~/modules/dom', async () => { getScrollParent: vi.fn(() => document.documentElement), getScrollTo: vi.fn(() => 0), hasCustomScrollParent: vi.fn(() => false), - scrollTo: vi.fn(() => ({ cancel: vi.fn(), promise: Promise.resolve() })), - }; -}); - -vi.mock('~/modules/helpers', async () => { - const actual = await vi.importActual('~/modules/helpers'); - - return { - ...actual, needsScrolling: vi.fn(() => false), + scrollTo: vi.fn(() => ({ cancel: vi.fn(), promise: Promise.resolve() })), }; }); diff --git a/test/hooks/useTourEngine.spec.ts b/test/hooks/useTourEngine.spec.ts index 437f9e64a..e93bae8c1 100644 --- a/test/hooks/useTourEngine.spec.ts +++ b/test/hooks/useTourEngine.spec.ts @@ -1,7 +1,6 @@ import useTourEngine from '~/hooks/useTourEngine'; import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals'; -import { getElement, getScrollTo, isElementVisible, scrollTo } from '~/modules/dom'; -import { needsScrolling } from '~/modules/helpers'; +import { getElement, getScrollTo, isElementVisible, needsScrolling, scrollTo } from '~/modules/dom'; import { act, eventResponseFactory, expectControls, renderHook, waitFor } from '~/test-utils'; import type { Props, Step } from '~/types'; @@ -18,16 +17,8 @@ vi.mock('~/modules/dom', async () => { getScrollParent: vi.fn(() => document.documentElement), getScrollTo: vi.fn(() => 0), hasCustomScrollParent: vi.fn(() => false), - scrollTo: vi.fn(() => ({ cancel: vi.fn(), promise: Promise.resolve() })), - }; -}); - -vi.mock('~/modules/helpers', async () => { - const actual = await vi.importActual('~/modules/helpers'); - - return { - ...actual, needsScrolling: vi.fn(() => false), + scrollTo: vi.fn(() => ({ cancel: vi.fn(), promise: Promise.resolve() })), }; }); diff --git a/test/modules/dom.spec.ts b/test/modules/dom.spec.ts new file mode 100644 index 000000000..ac014195c --- /dev/null +++ b/test/modules/dom.spec.ts @@ -0,0 +1,136 @@ +import { LIFECYCLE } from '~/literals'; +import { needsScrolling } from '~/modules/dom'; +import { fromPartial } from '~/test-utils'; + +import type { StepMerged } from '~/types'; + +describe('dom', () => { + describe('needsScrolling', () => { + const target = document.createElement('div'); + const baseStep = fromPartial({ + skipScroll: false, + isFixed: false, + placement: 'bottom', + }); + const baseOptions = { + isFirstStep: false, + scrollToFirstStep: false, + step: baseStep, + target, + targetLifecycle: LIFECYCLE.BEACON, + }; + + it.each([ + { ...baseOptions, label: 'base case', expected: true }, + { + ...baseOptions, + step: fromPartial({ ...baseStep, skipScroll: true }), + label: 'skipScroll', + expected: false, + }, + { + ...baseOptions, + isFirstStep: true, + label: 'first step without scrollToFirstStep', + expected: false, + }, + { + ...baseOptions, + isFirstStep: true, + scrollToFirstStep: true, + label: 'first step with scrollToFirstStep', + expected: true, + }, + { + ...baseOptions, + isFirstStep: true, + targetLifecycle: LIFECYCLE.TOOLTIP, + label: 'first step with tooltip lifecycle', + expected: true, + }, + { + ...baseOptions, + step: fromPartial({ ...baseStep, placement: 'center' }), + label: 'center placement', + expected: false, + }, + { + ...baseOptions, + target: null, + label: 'null target', + expected: true, + }, + ])('should return $expected for $label', ({ expected, label: _, ...options }) => { + expect(needsScrolling(options)).toBe(expected); + }); + + it('should return false for target with fixed ancestor', () => { + const fixedTarget = document.createElement('div'); + + fixedTarget.style.position = 'fixed'; + document.body.appendChild(fixedTarget); + + expect( + needsScrolling({ + ...baseOptions, + target: fixedTarget, + }), + ).toBe(false); + + document.body.removeChild(fixedTarget); + }); + + it('should return false for fixed step', () => { + document.body.appendChild(target); + + expect( + needsScrolling({ + ...baseOptions, + step: fromPartial({ ...baseStep, isFixed: true }), + }), + ).toBe(false); + + document.body.removeChild(target); + }); + + it('should return false for fixed step with fixed target', () => { + const fixedTarget = document.createElement('div'); + + fixedTarget.style.position = 'fixed'; + document.body.appendChild(fixedTarget); + + expect( + needsScrolling({ + ...baseOptions, + target: fixedTarget, + step: fromPartial({ ...baseStep, isFixed: true }), + }), + ).toBe(false); + + document.body.removeChild(fixedTarget); + }); + + it('should return true for fixed target with scrollable parent', () => { + const fixedContainer = document.createElement('div'); + + fixedContainer.style.position = 'fixed'; + fixedContainer.style.overflow = 'auto'; + Object.defineProperty(fixedContainer, 'scrollHeight', { value: 500, configurable: true }); + Object.defineProperty(fixedContainer, 'offsetHeight', { value: 200, configurable: true }); + + const childTarget = document.createElement('div'); + + fixedContainer.appendChild(childTarget); + document.body.appendChild(fixedContainer); + + expect( + needsScrolling({ + ...baseOptions, + target: childTarget, + }), + ).toBe(true); + + document.body.removeChild(fixedContainer); + }); + }); +}); diff --git a/test/modules/helpers.spec.tsx b/test/modules/helpers.spec.tsx index 86b4259b2..662666e58 100644 --- a/test/modules/helpers.spec.tsx +++ b/test/modules/helpers.spec.tsx @@ -1,27 +1,20 @@ /* eslint-disable react/jsx-curly-brace-presence */ import type { ReactNode } from 'react'; -import { ACTIONS, LIFECYCLE, STATUS } from '~/literals'; import { cleanUpObject, deepMerge, getObjectType, getReactNodeText, - hexToRGB, log, mergeProps, - needsScrolling, noop, objectKeys, omit, pick, replaceLocaleContent, - shouldHideBeacon, sortObjectKeys, } from '~/modules/helpers'; -import { fromPartial } from '~/test-utils'; - -import type { Step, StepMerged } from '~/types'; interface Props { name: string; @@ -81,74 +74,6 @@ describe('helpers', () => { }); }); - describe('hexToRGB', () => { - it('should convert properly', () => { - expect(hexToRGB('#ff0044')).toEqual([255, 0, 68]); - expect(hexToRGB('#0f4')).toEqual([0, 255, 68]); - }); - - it('should return an empty array with invalid strings', () => { - expect(hexToRGB('asa')).toEqual([]); - }); - }); - - describe('shouldHideBeacon', () => { - const baseState = { - action: ACTIONS.START, - controlled: true, - index: 0, - lifecycle: LIFECYCLE.INIT, - origin: null, - scrolling: false, - size: 5, - status: STATUS.RUNNING, - waiting: false, - }; - - const baseParameters = { step: { placement: 'auto' }, state: baseState, continuous: true }; - - it.each([ - { ...baseParameters, step: { skipBeacon: false }, expected: false }, - { ...baseParameters, step: { skipBeacon: true }, expected: true }, - { ...baseParameters, step: { placement: 'bottom' }, expected: false }, - { ...baseParameters, step: { placement: 'center' }, expected: true }, - { - ...baseParameters, - step: { placement: 'auto' }, - state: { ...baseState, action: ACTIONS.NEXT }, - expected: true, - }, - { - ...baseParameters, - step: { placement: 'auto' }, - state: { ...baseState, action: ACTIONS.PREV }, - expected: true, - }, - { - ...baseParameters, - state: { ...baseState, action: ACTIONS.UPDATE }, - expected: false, - }, - { - ...baseParameters, - state: { ...baseState, action: ACTIONS.STOP }, - expected: false, - }, - - { - ...baseParameters, - step: { placement: 'auto' }, - state: { ...baseState, action: ACTIONS.NEXT }, - continuous: false, - expected: false, - }, - ])('should return properly', ({ continuous, expected, state, step }) => { - expect(shouldHideBeacon(fromPartial(step as Partial), state, continuous)).toBe( - expected, - ); - }); - }); - describe('log', () => { const consoleLog = console.log; @@ -372,135 +297,6 @@ describe('helpers', () => { }); }); - describe('needsScrolling', () => { - const target = document.createElement('div'); - const baseStep = fromPartial({ - skipScroll: false, - isFixed: false, - placement: 'bottom', - }); - const baseOptions = { - isFirstStep: false, - scrollToFirstStep: false, - step: baseStep, - target, - targetLifecycle: LIFECYCLE.BEACON, - }; - - it.each([ - { ...baseOptions, label: 'base case', expected: true }, - { - ...baseOptions, - step: fromPartial({ ...baseStep, skipScroll: true }), - label: 'skipScroll', - expected: false, - }, - { - ...baseOptions, - isFirstStep: true, - label: 'first step without scrollToFirstStep', - expected: false, - }, - { - ...baseOptions, - isFirstStep: true, - scrollToFirstStep: true, - label: 'first step with scrollToFirstStep', - expected: true, - }, - { - ...baseOptions, - isFirstStep: true, - targetLifecycle: LIFECYCLE.TOOLTIP, - label: 'first step with tooltip lifecycle', - expected: true, - }, - { - ...baseOptions, - step: fromPartial({ ...baseStep, placement: 'center' }), - label: 'center placement', - expected: false, - }, - { - ...baseOptions, - target: null, - label: 'null target', - expected: true, - }, - ])('should return $expected for $label', ({ expected, label: _, ...options }) => { - expect(needsScrolling(options)).toBe(expected); - }); - - it('should return false for target with fixed ancestor', () => { - const fixedTarget = document.createElement('div'); - - fixedTarget.style.position = 'fixed'; - document.body.appendChild(fixedTarget); - - expect( - needsScrolling({ - ...baseOptions, - target: fixedTarget, - }), - ).toBe(false); - - document.body.removeChild(fixedTarget); - }); - - it('should return false for fixed step', () => { - document.body.appendChild(target); - - expect( - needsScrolling({ - ...baseOptions, - step: fromPartial({ ...baseStep, isFixed: true }), - }), - ).toBe(false); - - document.body.removeChild(target); - }); - - it('should return false for fixed step with fixed target', () => { - const fixedTarget = document.createElement('div'); - - fixedTarget.style.position = 'fixed'; - document.body.appendChild(fixedTarget); - - expect( - needsScrolling({ - ...baseOptions, - target: fixedTarget, - step: fromPartial({ ...baseStep, isFixed: true }), - }), - ).toBe(false); - - document.body.removeChild(fixedTarget); - }); - - it('should return true for fixed target with scrollable parent', () => { - const fixedContainer = document.createElement('div'); - - fixedContainer.style.position = 'fixed'; - fixedContainer.style.overflow = 'auto'; - Object.defineProperty(fixedContainer, 'scrollHeight', { value: 500, configurable: true }); - Object.defineProperty(fixedContainer, 'offsetHeight', { value: 200, configurable: true }); - - const childTarget = document.createElement('div'); - - fixedContainer.appendChild(childTarget); - document.body.appendChild(fixedContainer); - - expect( - needsScrolling({ - ...baseOptions, - target: childTarget, - }), - ).toBe(true); - - document.body.removeChild(fixedContainer); - }); - }); - describe('sortObjectKeys', () => { it('should return properly', () => { const value = sortObjectKeys({ diff --git a/test/modules/step.spec.ts b/test/modules/step.spec.ts index bac5e9e0e..9f35ff975 100644 --- a/test/modules/step.spec.ts +++ b/test/modules/step.spec.ts @@ -1,8 +1,9 @@ import { vi } from 'vitest'; import { defaultFloatingOptions, defaultOptions, defaultProps, defaultStep } from '~/defaults'; +import { ACTIONS, LIFECYCLE, STATUS } from '~/literals'; import { noop } from '~/modules/helpers'; -import { getMergedStep, validateStep, validateSteps } from '~/modules/step'; +import { getMergedStep, shouldHideBeacon, validateStep, validateSteps } from '~/modules/step'; import { fromPartial } from '~/test-utils'; import type { Props, Step } from '~/types'; @@ -216,4 +217,61 @@ describe('step', () => { expect(result?.id).toBe('my-step'); }); }); + + describe('shouldHideBeacon', () => { + const baseState = { + action: ACTIONS.START, + controlled: true, + index: 0, + lifecycle: LIFECYCLE.INIT, + origin: null, + scrolling: false, + size: 5, + status: STATUS.RUNNING, + waiting: false, + }; + + const baseParameters = { step: { placement: 'auto' }, state: baseState, continuous: true }; + + it.each([ + { ...baseParameters, step: { skipBeacon: false }, expected: false }, + { ...baseParameters, step: { skipBeacon: true }, expected: true }, + { ...baseParameters, step: { placement: 'bottom' }, expected: false }, + { ...baseParameters, step: { placement: 'center' }, expected: true }, + { + ...baseParameters, + step: { placement: 'auto' }, + state: { ...baseState, action: ACTIONS.NEXT }, + expected: true, + }, + { + ...baseParameters, + step: { placement: 'auto' }, + state: { ...baseState, action: ACTIONS.PREV }, + expected: true, + }, + { + ...baseParameters, + state: { ...baseState, action: ACTIONS.UPDATE }, + expected: false, + }, + { + ...baseParameters, + state: { ...baseState, action: ACTIONS.STOP }, + expected: false, + }, + + { + ...baseParameters, + step: { placement: 'auto' }, + state: { ...baseState, action: ACTIONS.NEXT }, + continuous: false, + expected: false, + }, + ])('should return properly', ({ continuous, expected, state, step }) => { + expect(shouldHideBeacon(fromPartial(step as Partial), state, continuous)).toBe( + expected, + ); + }); + }); }); diff --git a/test/styles.spec.ts b/test/styles.spec.ts index 0f085af74..850d7683e 100644 --- a/test/styles.spec.ts +++ b/test/styles.spec.ts @@ -1,5 +1,5 @@ import { getMergedStep } from '~/modules/step'; -import getStyles from '~/styles'; +import getStyles, { hexToRGB } from '~/styles'; import type { Props, Step } from '~/types'; @@ -81,4 +81,15 @@ describe('styles', () => { expect(getStyles(customStylesProps, getMergedStep(customStylesProps, step)!)).toMatchSnapshot(); }); + + describe('hexToRGB', () => { + it('should convert properly', () => { + expect(hexToRGB('#ff0044')).toEqual([255, 0, 68]); + expect(hexToRGB('#0f4')).toEqual([0, 255, 68]); + }); + + it('should return an empty array with invalid strings', () => { + expect(hexToRGB('asa')).toEqual([]); + }); + }); }); diff --git a/test/tours/hook.spec.tsx b/test/tours/hook.spec.tsx index a9fcadbc3..17b8a7e4c 100644 --- a/test/tours/hook.spec.tsx +++ b/test/tours/hook.spec.tsx @@ -13,14 +13,14 @@ let tourControls: Controls; const interactions: TourInteractions = { start: () => fireEvent.click(screen.getByTestId('start')), - next: () => { + next: origin => { act(() => { - tourControls.next(); + tourControls.next(origin); }); }, - prev: () => { + prev: origin => { act(() => { - tourControls.prev(); + tourControls.prev(origin); }); }, close: origin => { diff --git a/website/src/app/docs/events/events.mdx b/website/src/app/docs/events/events.mdx index dc2538214..96a8dc138 100644 --- a/website/src/app/docs/events/events.mdx +++ b/website/src/app/docs/events/events.mdx @@ -4,7 +4,7 @@ import Code from '~/components/Code'; # Events -Use the `onEvent` prop to listen to tour state changes. It receives an [`EventData`](#eventdata) object and a [`Controls`](./hook#controls) object on every transition. +Use the `onEvent` prop to listen to tour state changes. It receives an [EventData](#eventdata) object and a [Controls](./hook#controls) object on every transition. For the full event sequence, lifecycle phases, and status transitions, see [How It Works](./how-it-works). @@ -16,41 +16,41 @@ For the full event sequence, lifecycle phases, and status transitions, see [How | Constant | Value | Description | |----------|-------|-------------| -| `EVENTS.TOUR_START` | `'tour:start'` | The tour started | -| `EVENTS.STEP_BEFORE_HOOK` | `'step:before_hook'` | A step's `before` hook is executing | -| `EVENTS.STEP_BEFORE` | `'step:before'` | A step is about to display | -| `EVENTS.SCROLL_START` | `'scroll:start'` | Scrolling to the step target started | -| `EVENTS.SCROLL_END` | `'scroll:end'` | Scrolling to the step target finished | -| `EVENTS.BEACON` | `'beacon'` | The beacon is displayed | -| `EVENTS.TOOLTIP` | `'tooltip'` | The tooltip is displayed | -| `EVENTS.STEP_AFTER` | `'step:after'` | A step has ended | -| `EVENTS.STEP_AFTER_HOOK` | `'step:after_hook'` | A step's `after` hook has executed | -| `EVENTS.TOUR_END` | `'tour:end'` | The tour ended | -| `EVENTS.TOUR_STATUS` | `'tour:status'` | The tour status changed (stop/reset) | -| `EVENTS.TARGET_NOT_FOUND` | `'error:target_not_found'` | The step target was not found | -| `EVENTS.ERROR` | `'error'` | An error occurred (e.g., in a `before` hook) | +| **EVENTS.TOUR_START** | `'tour:start'` | The tour started | +| **EVENTS.STEP_BEFORE_HOOK** | `'step:before_hook'` | A step's `before` hook is executing | +| **EVENTS.STEP_BEFORE** | `'step:before'` | A step is about to display | +| **EVENTS.SCROLL_START** | `'scroll:start'` | Scrolling to the step target started | +| **EVENTS.SCROLL_END** | `'scroll:end'` | Scrolling to the step target finished | +| **EVENTS.BEACON** | `'beacon'` | The beacon is displayed | +| **EVENTS.TOOLTIP** | `'tooltip'` | The tooltip is displayed | +| **EVENTS.STEP_AFTER** | `'step:after'` | A step has ended | +| **EVENTS.STEP_AFTER_HOOK** | `'step:after_hook'` | A step's `after` hook has executed | +| **EVENTS.TOUR_END** | `'tour:end'` | The tour ended | +| **EVENTS.TOUR_STATUS** | `'tour:status'` | The tour status changed (stop/reset) | +| **EVENTS.TARGET_NOT_FOUND** | `'error:target_not_found'` | The step target was not found | +| **EVENTS.ERROR** | `'error'` | An error occurred (e.g., in a `before` hook) | See [Exports](./exports#events) for all constant values. ## EventData -The `onEvent` callback receives two arguments: an `EventData` object and a [`Controls`](./hook#controls) object with tour control methods. +The `onEvent` callback receives two arguments: an `EventData` object and a [Controls](./hook#controls) object with tour control methods. | Field | Description | Type | |-------|-------------|------| -| `action` | The action that triggered the state update | [`Actions`](./exports#literal-unions) | -| `controlled` | Whether the tour is in controlled mode | `boolean` | -| `error` | The error (populated on `error` events) | `Error \| null` | -| `index` | The current step index | `number` | -| `lifecycle` | The step's lifecycle phase | [`Lifecycle`](./exports#literal-unions) | -| `origin` | The UI element that triggered the action | [`Origin`](./exports#literal-unions) ` \| null` | -| `scroll` | Scroll info (on `scroll:start` / `scroll:end`) | [`ScrollData`](#scrolldata) ` \| null` | -| `scrolling` | Whether the tour is currently scrolling to a target | `boolean` | -| `size` | The total number of steps | `number` | -| `status` | The tour's current status | [`Status`](./exports#literal-unions) | -| `step` | The current step with all defaults applied | [`StepMerged`](./exports#core) | -| `type` | The event type | [`Events`](./exports#literal-unions) | -| `waiting` | Whether the tour is waiting for a `before` hook or target | `boolean` | +| **action** | The action that triggered the state update | [Actions](./exports#literal-unions) | +| **controlled** | Whether the tour is in controlled mode | `boolean` | +| **error** | The error (populated on `error` events) | `Error` \| `null` | +| **index** | The current step index | `number` | +| **lifecycle** | The step's lifecycle phase | [Lifecycle](./exports#literal-unions) | +| **origin** | The UI element that triggered the action,
Or `null` for programmatic calls (e.g., `controls.next()`) | [Origin](./exports#literal-unions) \| `null` | +| **scroll** | Scroll info (on `scroll:start` / `scroll:end`) | [ScrollData](#scrolldata) \| `null` | +| **scrolling** | Whether the tour is currently scrolling to a target | `boolean` | +| **size** | The total number of steps | `number` | +| **status** | The tour's current status | [Status](./exports#literal-unions) | +| **step** | The current step with all defaults applied | [StepMerged](./exports#core) | +| **type** | The event type | [Events](./exports#literal-unions) | +| **waiting** | Whether the tour is waiting for a `before` hook or target | `boolean` | ### ScrollData @@ -58,10 +58,10 @@ Populated in `scroll` when the event type is `scroll:start` or `scroll:end`: | Field | Description | Type | |-------|-------------|------| -| `duration` | The scroll duration in milliseconds | `number` | -| `element` | The element being scrolled | `Element` | -| `initial` | The scroll position before scrolling | `number` | -| `target` | The computed scroll destination | `number` | +| **duration** | The scroll duration in milliseconds | `number` | +| **element** | The element being scrolled | `Element` | +| **initial** | The scroll position before scrolling | `number` | +| **target** | The computed scroll destination | `number` | ### Waiting State diff --git a/website/src/app/docs/exports/exports.mdx b/website/src/app/docs/exports/exports.mdx index ffd303206..7fd07b298 100644 --- a/website/src/app/docs/exports/exports.mdx +++ b/website/src/app/docs/exports/exports.mdx @@ -1,4 +1,5 @@ import Callout from '~/components/Callout'; +import Code from '~/components/Code'; # Exports @@ -10,7 +11,7 @@ import { Joyride, ACTIONS, EVENTS, STATUS, useJoyride, type EventData } from 're ## Constants -Objects you can use to compare against values in [`EventData`](./events#eventdata). All values are `as const`. +Objects you can use to compare against values in [EventData](./events#eventdata). All values are typed `as const`. ### ACTIONS @@ -18,17 +19,17 @@ The action that triggered the state update. | Constant | Value | |----------|-------| -| `ACTIONS.INIT` | `'init'` | -| `ACTIONS.START` | `'start'` | -| `ACTIONS.STOP` | `'stop'` | -| `ACTIONS.RESET` | `'reset'` | -| `ACTIONS.PREV` | `'prev'` | -| `ACTIONS.NEXT` | `'next'` | -| `ACTIONS.GO` | `'go'` | -| `ACTIONS.CLOSE` | `'close'` | -| `ACTIONS.SKIP` | `'skip'` | -| `ACTIONS.UPDATE` | `'update'` | -| `ACTIONS.COMPLETE` | `'complete'` | +| **ACTIONS.INIT** | `'init'` | +| **ACTIONS.START** | `'start'` | +| **ACTIONS.STOP** | `'stop'` | +| **ACTIONS.RESET** | `'reset'` | +| **ACTIONS.PREV** | `'prev'` | +| **ACTIONS.NEXT** | `'next'` | +| **ACTIONS.GO** | `'go'` | +| **ACTIONS.CLOSE** | `'close'` | +| **ACTIONS.SKIP** | `'skip'` | +| **ACTIONS.UPDATE** | `'update'` | +| **ACTIONS.COMPLETE** | `'complete'` | ### EVENTS @@ -36,19 +37,19 @@ The event type fired via `onEvent`. | Constant | Value | |----------|-------| -| `EVENTS.TOUR_START` | `'tour:start'` | -| `EVENTS.STEP_BEFORE_HOOK` | `'step:before_hook'` | -| `EVENTS.STEP_BEFORE` | `'step:before'` | -| `EVENTS.SCROLL_START` | `'scroll:start'` | -| `EVENTS.SCROLL_END` | `'scroll:end'` | -| `EVENTS.BEACON` | `'beacon'` | -| `EVENTS.TOOLTIP` | `'tooltip'` | -| `EVENTS.STEP_AFTER` | `'step:after'` | -| `EVENTS.STEP_AFTER_HOOK` | `'step:after_hook'` | -| `EVENTS.TOUR_END` | `'tour:end'` | -| `EVENTS.TOUR_STATUS` | `'tour:status'` | -| `EVENTS.TARGET_NOT_FOUND` | `'error:target_not_found'` | -| `EVENTS.ERROR` | `'error'` | +| **EVENTS.TOUR_START** | `'tour:start'` | +| **EVENTS.STEP_BEFORE_HOOK** | `'step:before_hook'` | +| **EVENTS.STEP_BEFORE** | `'step:before'` | +| **EVENTS.SCROLL_START** | `'scroll:start'` | +| **EVENTS.SCROLL_END** | `'scroll:end'` | +| **EVENTS.BEACON** | `'beacon'` | +| **EVENTS.TOOLTIP** | `'tooltip'` | +| **EVENTS.STEP_AFTER** | `'step:after'` | +| **EVENTS.STEP_AFTER_HOOK** | `'step:after_hook'` | +| **EVENTS.TOUR_END** | `'tour:end'` | +| **EVENTS.TOUR_STATUS** | `'tour:status'` | +| **EVENTS.TARGET_NOT_FOUND** | `'error:target_not_found'` | +| **EVENTS.ERROR** | `'error'` | ### LIFECYCLE @@ -56,13 +57,13 @@ The step's rendering phase. See [How It Works](./how-it-works#step-lifecycle) fo | Constant | Value | |----------|-------| -| `LIFECYCLE.INIT` | `'init'` | -| `LIFECYCLE.READY` | `'ready'` | -| `LIFECYCLE.BEACON_BEFORE` | `'beacon_before'` | -| `LIFECYCLE.BEACON` | `'beacon'` | -| `LIFECYCLE.TOOLTIP_BEFORE` | `'tooltip_before'` | -| `LIFECYCLE.TOOLTIP` | `'tooltip'` | -| `LIFECYCLE.COMPLETE` | `'complete'` | +| **LIFECYCLE.INIT** | `'init'` | +| **LIFECYCLE.READY** | `'ready'` | +| **LIFECYCLE.BEACON_BEFORE** | `'beacon_before'` | +| **LIFECYCLE.BEACON** | `'beacon'` | +| **LIFECYCLE.TOOLTIP_BEFORE** | `'tooltip_before'` | +| **LIFECYCLE.TOOLTIP** | `'tooltip'` | +| **LIFECYCLE.COMPLETE** | `'complete'` | ### ORIGIN @@ -70,11 +71,14 @@ The UI element that triggered the action. | Constant | Value | |----------|-------| -| `ORIGIN.BUTTON_CLOSE` | `'button_close'` | -| `ORIGIN.BUTTON_SKIP` | `'button_skip'` | -| `ORIGIN.BUTTON_PRIMARY` | `'button_primary'` | -| `ORIGIN.KEYBOARD` | `'keyboard'` | -| `ORIGIN.OVERLAY` | `'overlay'` | +| **ORIGIN.BUTTON_BACK** | `'button_back'` | +| **ORIGIN.BUTTON_CLOSE** | `'button_close'` | +| **ORIGIN.BUTTON_PRIMARY** | `'button_primary'` | +| **ORIGIN.BUTTON_SKIP** | `'button_skip'` | +| **ORIGIN.KEYBOARD** | `'keyboard'` | +| **ORIGIN.OVERLAY** | `'overlay'` | + +The `origin` is `null` when the action was triggered programmatically via controls rather than by a built-in UI element. ### STATUS @@ -82,13 +86,13 @@ The tour's current status. See [How It Works](./how-it-works#tour-status) for de | Constant | Value | |----------|-------| -| `STATUS.IDLE` | `'idle'` | -| `STATUS.READY` | `'ready'` | -| `STATUS.WAITING` | `'waiting'` | -| `STATUS.RUNNING` | `'running'` | -| `STATUS.PAUSED` | `'paused'` | -| `STATUS.SKIPPED` | `'skipped'` | -| `STATUS.FINISHED` | `'finished'` | +| **STATUS.IDLE** | `'idle'` | +| **STATUS.READY** | `'ready'` | +| **STATUS.WAITING** | `'waiting'` | +| **STATUS.RUNNING** | `'running'` | +| **STATUS.PAUSED** | `'paused'` | +| **STATUS.SKIPPED** | `'skipped'` | +| **STATUS.FINISHED** | `'finished'` | ## Defaults @@ -100,11 +104,11 @@ import { defaultLocale, defaultOptions } from 'react-joyride'; ### defaultOptions -An object containing the default values for all [`Options`](./props/options) fields (colors, sizes, behavior flags, timing, etc.). +An object containing the default values for all [Options](./props/options) fields (colors, sizes, behavior flags, timing, etc.). ### defaultLocale -An object containing the default [`Locale`](./props#locale) strings (button labels and aria-labels). +An object containing the default [Locale](./props#locale) strings (button labels and aria-labels). ## Types diff --git a/website/src/app/docs/hook/ControlsTable.tsx b/website/src/app/docs/hook/ControlsTable.tsx index 8a0438a38..039f4d141 100644 --- a/website/src/app/docs/hook/ControlsTable.tsx +++ b/website/src/app/docs/hook/ControlsTable.tsx @@ -44,7 +44,7 @@ export default function ControlsTable() {

Advance to the next step. Only works when the tour is running.

- {'() => void'} + {'(origin?: Origin | null) => void'} @@ -53,7 +53,7 @@ export default function ControlsTable() {

Go back to the previous step. Only works when the tour is running.

- {'() => void'} + {'(origin?: Origin | null) => void'} diff --git a/website/src/app/docs/hook/hook.mdx b/website/src/app/docs/hook/hook.mdx index a0431c261..950902b77 100644 --- a/website/src/app/docs/hook/hook.mdx +++ b/website/src/app/docs/hook/hook.mdx @@ -100,7 +100,7 @@ function App() { ``` -

The handler receives [`EventData`](./events#eventdata) and [`Controls`](#controls) as arguments — the same as `onEvent`. Since `data` contains the full tour state (`index`, `action`, `status`, etc.), you rarely need external state in the handler.

+

The handler receives [EventData](./events#eventdata) and [Controls](#controls) as arguments — the same as `onEvent`. Since `data` contains the full tour state (`index`, `action`, `status`, etc.), you rarely need external state in the handler.

@@ -115,7 +115,7 @@ function App() { | `controlled` | Whether the tour is in controlled mode (using `stepIndex`) | `boolean` | | `index` | The current step index | `number` | | `lifecycle` | The step's rendering phase | `Lifecycle` | -| `origin` | The UI element that triggered the last action | `Origin \| null` | +| `origin` | The UI element that triggered the last action, or `null` for programmatic calls | `Origin \| null` | | `scrolling` | Whether the tour is currently scrolling to a target | `boolean` | | `size` | The total number of steps | `number` | | `status` | The tour's current status | `Status` | diff --git a/website/src/app/docs/how-it-works/how-it-works.mdx b/website/src/app/docs/how-it-works/how-it-works.mdx index d90d7f149..8c4998ad6 100644 --- a/website/src/app/docs/how-it-works/how-it-works.mdx +++ b/website/src/app/docs/how-it-works/how-it-works.mdx @@ -97,7 +97,7 @@ See the [Events](./events) page for how to handle these in your code. ### Uncontrolled (default) -Joyride manages the step index internally. The built-in buttons (Next, Back, Skip, Close) handle navigation. You can also use [`controls`](./hook#controls) for programmatic navigation. +Joyride manages the step index internally. The built-in buttons (Next, Back, Skip, Close) handle navigation. You can also use [controls](./hook#controls) for programmatic navigation. ```tsx useJoyride()](./hook) hook is the core API. It returns tour controls, state, the current step, step failures, and event subscriptions as explicit values — making it easy to coordinate the tour with your app's UI and state.

The {''} component is a convenience wrapper around the hook. Both accept the same props.

@@ -22,7 +22,7 @@ The [`useJoyride()`](./hook) hook is the core API. It returns tour controls, sta ### Event System -The [`onEvent`](./events) callback receives discriminated event types (`tour:start`, `step:before`, `tooltip`, etc.) and a [`controls`](./hook#controls) object as the second argument. Subscribe to specific events with the [`on()`](./hook#event-subscriptions) method instead of filtering in a callback. +The [onEvent](./events) callback receives discriminated event types (`tour:start`, `step:before`, `tooltip`, etc.) and a [controls](./hook#controls) object as the second argument. Subscribe to specific events with the [on()](./hook#event-subscriptions) method instead of filtering in a callback. ### Step Hooks @@ -34,23 +34,23 @@ Run async logic before a step with [before](./props ### Separate Scroll & Spotlight Targets -[`scrollTarget`](./step) scrolls to a different element than the tooltip target, and [`spotlightTarget`](./step) highlights a different element. The tooltip still anchors to `target`. Useful for highlighting a container while pointing at a specific child, or scrolling a parent into view. +[scrollTarget](./step) scrolls to a different element than the tooltip target, and [spotlightTarget](./step) highlights a different element. The tooltip still anchors to `target`. Useful for highlighting a container while pointing at a specific child, or scrolling a parent into view. ### Floating UI -Tooltip positioning uses [`@floating-ui/react-dom`](./props/floating-options) directly, replacing Popper.js/react-floater. Configure middleware, flip/shift behavior, positioning strategy, and beacon offset via `floatingOptions`. +Tooltip positioning uses [@floating-ui/react-dom](./props/floating-options) directly, replacing Popper.js/react-floater. Configure middleware, flip/shift behavior, positioning strategy, and beacon offset via `floatingOptions`. ### Custom Components -[`arrowComponent`](./custom-components#arrowcomponent) and [`loaderComponent`](./custom-components#loadercomponent) join the existing beacon and tooltip overrides. Set `loaderComponent` to `null` to disable the loader entirely. +[arrowComponent](./custom-components#arrowcomponent) and [loaderComponent](./custom-components#loadercomponent) join the existing beacon and tooltip overrides. Set `loaderComponent` to `null` to disable the loader entirely. ### Portal Rendering -All tour UI renders via a React portal. Use the [`portalElement`](./props) prop to target a custom container instead of `document.body`. +All tour UI renders via a React portal. Use the [portalElement](./props) prop to target a custom container instead of `document.body`. ### initialStepIndex -Start uncontrolled tours at any step with [`initialStepIndex`](./props). Previously, tours always started at step 0. +Start uncontrolled tours at any step with [initialStepIndex](./props). Previously, tours always started at step 0. ### React 19 Support @@ -64,33 +64,33 @@ Full compatibility with React 16.8 through React 19. Overlay clicks, ESC key, and the close button used to be on/off only. Now they support actions: -- [`dismissKeyAction`](./props/options#interactions): `'close'`, `'next'`, or `false` -- [`overlayClickAction`](./props/options#interactions): `'close'`, `'next'`, or `false` -- [`closeButtonAction`](./props/options#interactions): `'close'` or `'skip'` +- [dismissKeyAction](./props/options#interactions): `'close'`, `'next'`, or `false` +- [overlayClickAction](./props/options#interactions): `'close'`, `'next'`, or `false` +- [closeButtonAction](./props/options#interactions): `'close'` or `'skip'` ### Focus Trap -Now a proper hook with per-step control via [`disableFocusTrap`](./accessibility#focus-trap). Focus is restored to the previously active element when the tooltip closes. +Now a proper hook with per-step control via [disableFocusTrap](./accessibility#focus-trap). Focus is restored to the previously active element when the tooltip closes. ### Target Resolution -[`target`](./step) now accepts `RefObject` and `() => HTMLElement | null` in addition to CSS selectors and elements. The tour polls for missing targets up to `targetWaitTimeout` ms before advancing. +[target](./step) now accepts `RefObject` and `() => HTMLElement | null` in addition to CSS selectors and elements. The tour polls for missing targets up to `targetWaitTimeout` ms before advancing. ### Options System -All options are now flat fields that can be set globally via the [`options`](./props/options) prop or overridden directly on any step. Merge priority: defaults → options prop → step fields. +All options are now flat fields that can be set globally via the [options](./props/options) prop or overridden directly on any step. Merge priority: defaults → options prop → step fields. ### SVG Overlay -The [overlay](./props/options#overlay-spotlight) uses an SVG path cutout instead of CSS box-shadow, providing precise spotlight rendering with configurable `spotlightRadius`. +The [overlay](./props/options#overlay-spotlight) uses an SVG path cutout instead of CSS box-shadow, providing precise spotlight rendering with configurable `spotlightRadius`. ### Spotlight Padding -[`spotlightPadding`](./props/options#overlay-spotlight) now accepts a `{ top, right, bottom, left }` object for per-side control, in addition to a single number. +[spotlightPadding](./props/options#overlay-spotlight) now accepts a `{ top, right, bottom, left }` object for per-side control, in addition to a single number. ### Buttons -Individual `hideBackButton`, `hideCloseButton`, and `showSkipButton` props are replaced by a single [`buttons`](./props/options#appearance) array: `['back', 'close', 'primary', 'skip']`. +Individual `hideBackButton`, `hideCloseButton`, and `showSkipButton` props are replaced by a single [buttons](./props/options#appearance) array: `['back', 'close', 'primary', 'skip']`. ### Smaller Bundle