diff --git a/packages/driver/src/click.ts b/packages/driver/src/click.ts new file mode 100644 index 0000000..fcd32e3 --- /dev/null +++ b/packages/driver/src/click.ts @@ -0,0 +1,56 @@ +const DRIVER_CLICK_EVENTS = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"] as const; + +const driverClickHandlers = new WeakMap void>(); + +/** + * Attaches click handler to the elements created by driver.js. It makes + * sure to give the listener the first chance to handle the event, and + * prevents all other pointer-events to make sure no external-library + * ever knows the click happened. + */ +export function onDriverClick( + element: Element, + listener: (pointer: MouseEvent | PointerEvent) => void, + shouldPreventDefault?: (target: HTMLElement) => boolean +) { + destroyDriverClick(element); + + const handler = (e: MouseEvent | PointerEvent) => { + const target = e.target as HTMLElement; + if (!element.contains(target)) { + return; + } + + if (!shouldPreventDefault || shouldPreventDefault(target)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + + if (e.type === "click") { + listener?.(e); + } + }; + + // We want to be the absolute first one to hear about the event + const useCapture = true; + + for (const type of DRIVER_CLICK_EVENTS) { + document.addEventListener(type, handler, useCapture); + } + + driverClickHandlers.set(element, handler); +} + +export function destroyDriverClick(element: Element) { + const handler = driverClickHandlers.get(element); + if (!handler) { + return; + } + + for (const type of DRIVER_CLICK_EVENTS) { + document.removeEventListener(type, handler, true); + } + + driverClickHandlers.delete(element); +} diff --git a/packages/driver/src/config.ts b/packages/driver/src/config.ts deleted file mode 100644 index 0d0d0ad..0000000 --- a/packages/driver/src/config.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Driver, DriveStep } from "./driver"; -import { AllowedButtons, PopoverDOM } from "./popover"; -import { State } from "./state"; - -export type HookOpts = { - config: Config; - state: State; - driver: Driver; - index: number | undefined; -}; - -export type DriverHook = (element: Element | undefined, step: DriveStep, opts: HookOpts) => void; - -export type Config = { - steps?: DriveStep[]; - - animate?: boolean; - duration?: number; - overlayColor?: string; - overlayOpacity?: number; - smoothScroll?: boolean; - allowClose?: boolean; - allowScroll?: boolean; - overlayClickBehavior?: "close" | "nextStep" | DriverHook; - stagePadding?: number; - stageRadius?: number; - - disableActiveInteraction?: boolean; - - // Skip a step whose target element is specified but missing from the DOM. - // Element-less steps are intentional centered steps and never skipped. (default: false) - skipMissingElement?: boolean; - - allowKeyboardControl?: boolean; - - // Popover specific configuration - popoverClass?: string; - popoverOffset?: number; - showButtons?: AllowedButtons[]; - disableButtons?: AllowedButtons[]; - showProgress?: boolean; - - // Button texts - progressText?: string; - nextBtnText?: string; - prevBtnText?: string; - doneBtnText?: string; - - // Called after the popover is rendered - onPopoverRender?: (popover: PopoverDOM, opts: HookOpts) => void; - - // State based callbacks, called upon state changes - onHighlightStarted?: DriverHook; - onHighlighted?: DriverHook; - onDeselected?: DriverHook; - onDestroyStarted?: DriverHook; - onDestroyed?: DriverHook; - - // Event based callbacks, called upon events - onNextClick?: DriverHook; - onPrevClick?: DriverHook; - onCloseClick?: DriverHook; - onDoneClick?: DriverHook; -}; - -export interface GetConfig { - (): Config; - (key: K): Config[K]; -} - -export type ConfigStore = { - getConfig: GetConfig; - configure: (config?: Config) => void; -}; - -export function createConfigStore(): ConfigStore { - let currentConfig: Config = {}; - - function configure(config: Config = {}) { - currentConfig = { - animate: true, - duration: 400, - allowClose: true, - allowScroll: true, - overlayClickBehavior: "close", - overlayOpacity: 0.7, - smoothScroll: false, - disableActiveInteraction: false, - skipMissingElement: false, - showProgress: false, - stagePadding: 10, - stageRadius: 5, - popoverOffset: 10, - showButtons: ["next", "previous", "close"], - disableButtons: [], - overlayColor: "#000", - ...config, - }; - } - - const getConfig: GetConfig = ((key?: K) => { - return key ? currentConfig[key] : currentConfig; - }) as GetConfig; - - configure(); - - return { getConfig, configure }; -} diff --git a/packages/driver/src/context.ts b/packages/driver/src/context.ts index 76890b3..90d6195 100644 --- a/packages/driver/src/context.ts +++ b/packages/driver/src/context.ts @@ -1,10 +1,195 @@ -import { Config, createConfigStore, GetConfig, HookOpts } from "./config"; -import { createStateStore, GetState, SetState, State } from "./state"; -import { createEmitter, Emitter } from "./emitter"; -import type { Driver } from "./driver"; +import type { Driver, DriveStep } from "./driver"; +import type { AllowedButtons, PopoverDOM } from "./popover"; +import type { StageDefinition } from "./stage"; // Per-instance config, state and emitter, threaded through the helpers so // nothing falls back to shared module-level state. + +export type HookOpts = { + config: Config; + state: State; + driver: Driver; + index: number | undefined; +}; + +export type DriverHook = (element: Element | undefined, step: DriveStep, opts: HookOpts) => void; + +export type Config = { + steps?: DriveStep[]; + + animate?: boolean; + duration?: number; + overlayColor?: string; + overlayOpacity?: number; + smoothScroll?: boolean; + allowClose?: boolean; + allowScroll?: boolean; + overlayClickBehavior?: "close" | "nextStep" | DriverHook; + stagePadding?: number; + stageRadius?: number; + + disableActiveInteraction?: boolean; + + // Skip a step whose target element is specified but missing from the DOM. + // Element-less steps are intentional centered steps and never skipped. (default: false) + skipMissingElement?: boolean; + + allowKeyboardControl?: boolean; + + // Popover specific configuration + popoverClass?: string; + popoverOffset?: number; + showButtons?: AllowedButtons[]; + disableButtons?: AllowedButtons[]; + showProgress?: boolean; + + // Button texts + progressText?: string; + nextBtnText?: string; + prevBtnText?: string; + doneBtnText?: string; + + // Called after the popover is rendered + onPopoverRender?: (popover: PopoverDOM, opts: HookOpts) => void; + + // State based callbacks, called upon state changes + onHighlightStarted?: DriverHook; + onHighlighted?: DriverHook; + onDeselected?: DriverHook; + onDestroyStarted?: DriverHook; + onDestroyed?: DriverHook; + + // Event based callbacks, called upon events + onNextClick?: DriverHook; + onPrevClick?: DriverHook; + onCloseClick?: DriverHook; + onDoneClick?: DriverHook; +}; + +export interface GetConfig { + (): Config; + (key: K): Config[K]; +} + +function createConfigStore() { + let currentConfig: Config = {}; + + function configure(config: Config = {}) { + currentConfig = { + animate: true, + duration: 400, + allowClose: true, + allowScroll: true, + overlayClickBehavior: "close", + overlayOpacity: 0.7, + smoothScroll: false, + disableActiveInteraction: false, + skipMissingElement: false, + showProgress: false, + stagePadding: 10, + stageRadius: 5, + popoverOffset: 10, + showButtons: ["next", "previous", "close"], + disableButtons: [], + overlayColor: "#000", + ...config, + }; + } + + const getConfig: GetConfig = ((key?: K) => { + return key ? currentConfig[key] : currentConfig; + }) as GetConfig; + + configure(); + + return { getConfig, configure }; +} + +export type State = { + isInitialized?: boolean; + + activeIndex?: number; + activeElement?: Element; + activeStep?: DriveStep; + previousElement?: Element; + previousStep?: DriveStep; + + popover?: PopoverDOM; + + // actual values considering the animation + // and delays. These are used to determine + // the positions etc. + __previousElement?: Element; + __activeElement?: Element; + __previousStep?: DriveStep; + __activeStep?: DriveStep; + + __activeOnDestroyed?: Element; + __resizeTimeout?: number; + __transitionCallback?: () => void; + __activeStagePosition?: StageDefinition; + __overlaySvg?: SVGSVGElement; + + __events?: { + onKeyup: (e: KeyboardEvent) => void; + onKeydown: (e: KeyboardEvent) => void; + onResize: () => void; + onScroll: () => void; + }; +}; + +export interface GetState { + (): State; + (key: K): State[K]; +} + +export type SetState = (key: K, value: State[K]) => void; + +function createStateStore() { + let currentState: State = {}; + + const getState: GetState = ((key?: K) => { + return key ? currentState[key] : currentState; + }) as GetState; + + const setState: SetState = (key, value) => { + currentState[key] = value; + }; + + function resetState() { + currentState = {}; + } + + return { getState, setState, resetState }; +} + +type allowedEvents = + | "overlayClick" + | "escapePress" + | "nextClick" + | "prevClick" + | "closeClick" + | "arrowRightPress" + | "arrowLeftPress"; + +function createEmitter() { + let registeredListeners: Partial<{ [key in allowedEvents]: () => void }> = {}; + + function listen(hook: allowedEvents, callback: () => void) { + registeredListeners[hook] = callback; + } + + function emit(hook: allowedEvents) { + registeredListeners[hook]?.(); + } + + function reset() { + registeredListeners = {}; + } + + return { listen, emit, reset }; +} + export type Context = { getConfig: GetConfig; setConfig: (config?: Config) => void; @@ -13,8 +198,8 @@ export type Context = { setState: SetState; resetState: () => void; - listen: Emitter["listen"]; - emit: Emitter["emit"]; + listen: (hook: allowedEvents, callback: () => void) => void; + emit: (hook: allowedEvents) => void; resetEmitter: () => void; getDriver: () => Driver; diff --git a/packages/driver/src/driver.ts b/packages/driver/src/driver.ts index 77d1f44..de26c6d 100644 --- a/packages/driver/src/driver.ts +++ b/packages/driver/src/driver.ts @@ -1,16 +1,15 @@ -import { AllowedButtons, destroyPopover, Popover } from "./popover"; +import { destroyPopover, Popover } from "./popover"; import { destroyOverlay } from "./overlay"; import { destroyEvents, initEvents, requireRefresh } from "./events"; -import { Config, DriverHook } from "./config"; -import { createContext } from "./context"; +import { Config, createContext, DriverHook } from "./context"; import { destroyHighlight, highlight } from "./highlight"; +import { resolveNextHook, resolvePrevHook, resolveTourStep } from "./step"; import { resolveElement } from "./utils"; import "./driver.css"; // Re-export the public types so they remain part of the package's type surface. -export type { Config, DriverHook } from "./config"; -export type { State } from "./state"; -export type { StageDefinition } from "./overlay"; +export type { Config, DriverHook, State } from "./context"; +export type { StageDefinition } from "./stage"; export type { Popover, PopoverDOM, Side, Alignment, AllowedButtons } from "./popover"; export type DriveStep = { @@ -81,15 +80,7 @@ export function driver(options: Config = {}): Driver { const activeStep = ctx.getState("activeStep"); const activeElement = ctx.getState("activeElement"); - const steps = ctx.getConfig("steps") || []; - const isLastStep = ctx.getState("activeIndex") === steps.length - 1; - const onDoneClick = activeStep?.popover?.onDoneClick || ctx.getConfig("onDoneClick"); - if (isLastStep && onDoneClick) { - onDoneClick(activeElement, activeStep!, ctx.getHookOpts()); - return; - } - - const onNextClick = activeStep?.popover?.onNextClick || ctx.getConfig("onNextClick"); + const onNextClick = resolveNextHook(ctx, activeStep); if (onNextClick) { onNextClick(activeElement, activeStep!, ctx.getHookOpts()); return; @@ -157,7 +148,7 @@ export function driver(options: Config = {}): Driver { return; } - const onPrevClick = activeStep.popover?.onPrevClick || ctx.getConfig("onPrevClick"); + const onPrevClick = resolvePrevHook(ctx, activeStep); if (onPrevClick) { return onPrevClick(activeElement, activeStep, ctx.getHookOpts()); } @@ -178,14 +169,7 @@ export function driver(options: Config = {}): Driver { return; } - const steps = ctx.getConfig("steps") || []; - const isLastStep = activeIndex === steps.length - 1; - const onDoneClick = activeStep.popover?.onDoneClick || ctx.getConfig("onDoneClick"); - if (isLastStep && onDoneClick) { - return onDoneClick(activeElement, activeStep, ctx.getHookOpts()); - } - - const onNextClick = activeStep.popover?.onNextClick || ctx.getConfig("onNextClick"); + const onNextClick = resolveNextHook(ctx, activeStep); if (onNextClick) { return onNextClick(activeElement, activeStep, ctx.getHookOpts()); } @@ -256,63 +240,25 @@ export function driver(options: Config = {}): Driver { ctx.setState("activeIndex", stepIndex); const hasNextStep = steps[stepIndex + 1]; - const hasPreviousStep = steps[stepIndex - 1]; - const doneBtnText = currentStep.popover?.doneBtnText || ctx.getConfig("doneBtnText") || "Done"; - const allowsClosing = ctx.getConfig("allowClose"); - const showProgress = - typeof currentStep.popover?.showProgress !== "undefined" - ? currentStep.popover?.showProgress - : ctx.getConfig("showProgress"); - const progressText = - currentStep.popover?.progressText || ctx.getConfig("progressText") || "{{current}} of {{total}}"; - const progressTextReplaced = progressText - .replace("{{current}}", `${stepIndex + 1}`) - .replace("{{total}}", `${steps.length}`); - - const configuredButtons = currentStep.popover?.showButtons || ctx.getConfig("showButtons"); - const calculatedButtons: AllowedButtons[] = [ - "next", - "previous", - ...(allowsClosing ? ["close" as AllowedButtons] : []), - ].filter(b => { - return !configuredButtons?.length || configuredButtons.includes(b as AllowedButtons); - }) as AllowedButtons[]; - - const onNextClick = currentStep.popover?.onNextClick || ctx.getConfig("onNextClick"); - const onPrevClick = currentStep.popover?.onPrevClick || ctx.getConfig("onPrevClick"); - const onCloseClick = currentStep.popover?.onCloseClick || ctx.getConfig("onCloseClick"); - - highlight(ctx, { - ...currentStep, - popover: { - showButtons: calculatedButtons, - nextBtnText: !hasNextStep ? doneBtnText : undefined, - disableButtons: [...(!hasPreviousStep ? ["previous" as AllowedButtons] : [])], - showProgress: showProgress, - onNextClick: onNextClick - ? onNextClick - : () => { - if (!hasNextStep) { - destroy(); - } else { - drive(stepIndex + 1); - } - }, - onPrevClick: onPrevClick - ? onPrevClick - : () => { - drive(stepIndex - 1); - }, - onCloseClick: onCloseClick - ? onCloseClick - : () => { - destroy(); - }, - ...(currentStep?.popover || {}), - progressText: progressTextReplaced, - }, - }); + highlight( + ctx, + resolveTourStep(ctx, stepIndex, { + onNextClick: () => { + if (!hasNextStep) { + destroy(); + } else { + drive(stepIndex + 1); + } + }, + onPrevClick: () => { + drive(stepIndex - 1); + }, + onCloseClick: () => { + destroy(); + }, + }) + ); } function destroy(withOnDestroyStartedHook = true) { diff --git a/packages/driver/src/emitter.ts b/packages/driver/src/emitter.ts deleted file mode 100644 index 8994818..0000000 --- a/packages/driver/src/emitter.ts +++ /dev/null @@ -1,32 +0,0 @@ -type allowedEvents = - | "overlayClick" - | "escapePress" - | "nextClick" - | "prevClick" - | "closeClick" - | "arrowRightPress" - | "arrowLeftPress"; - -export type Emitter = { - listen: (hook: allowedEvents, callback: () => void) => void; - emit: (hook: allowedEvents) => void; - reset: () => void; -}; - -export function createEmitter(): Emitter { - let registeredListeners: Partial<{ [key in allowedEvents]: () => void }> = {}; - - function listen(hook: allowedEvents, callback: () => void) { - registeredListeners[hook] = callback; - } - - function emit(hook: allowedEvents) { - registeredListeners[hook]?.(); - } - - function reset() { - registeredListeners = {}; - } - - return { listen, emit, reset }; -} diff --git a/packages/driver/src/events.ts b/packages/driver/src/events.ts index 8c1b15f..18a4f64 100644 --- a/packages/driver/src/events.ts +++ b/packages/driver/src/events.ts @@ -65,67 +65,6 @@ function onKeyup(ctx: Context, e: KeyboardEvent) { } } -const DRIVER_CLICK_EVENTS = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"] as const; - -const driverClickHandlers = new WeakMap void>(); - -/** - * Attaches click handler to the elements created by driver.js. It makes - * sure to give the listener the first chance to handle the event, and - * prevents all other pointer-events to make sure no external-library - * ever knows the click happened. - * - * @param {Element} element Element to listen for click events - * @param {(pointer: MouseEvent | PointerEvent) => void} listener Click handler - * @param {(target: HTMLElement) => boolean} shouldPreventDefault Whether to prevent default action i.e. link clicks etc - */ -export function onDriverClick( - element: Element, - listener: (pointer: MouseEvent | PointerEvent) => void, - shouldPreventDefault?: (target: HTMLElement) => boolean -) { - destroyDriverClick(element); - - const handler = (e: MouseEvent | PointerEvent) => { - const target = e.target as HTMLElement; - if (!element.contains(target)) { - return; - } - - if (!shouldPreventDefault || shouldPreventDefault(target)) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - - if (e.type === "click") { - listener?.(e); - } - }; - - // We want to be the absolute first one to hear about the event - const useCapture = true; - - for (const type of DRIVER_CLICK_EVENTS) { - document.addEventListener(type, handler, useCapture); - } - - driverClickHandlers.set(element, handler); -} - -export function destroyDriverClick(element: Element) { - const handler = driverClickHandlers.get(element); - if (!handler) { - return; - } - - for (const type of DRIVER_CLICK_EVENTS) { - document.removeEventListener(type, handler, true); - } - - driverClickHandlers.delete(element); -} - export function initEvents(ctx: Context) { // Stashed in state so destroyEvents can detach these exact references. const onWindowKeyup = (e: KeyboardEvent) => onKeyup(ctx, e); diff --git a/packages/driver/src/highlight.ts b/packages/driver/src/highlight.ts index 89ad5c8..d643763 100644 --- a/packages/driver/src/highlight.ts +++ b/packages/driver/src/highlight.ts @@ -2,7 +2,7 @@ import { Context } from "./context"; import { DriveStep } from "./driver"; import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay"; import { hidePopover } from "./popover"; -import { renderStepPopover, repositionStepPopover } from "./step-popover"; +import { renderStepPopover, repositionStepPopover } from "./step"; import { bringInView, isScrollable, resolveElement } from "./utils"; function mountDummyElement(): Element { diff --git a/packages/driver/src/hints.ts b/packages/driver/src/hints.ts index 73b5a69..9efd6e6 100644 --- a/packages/driver/src/hints.ts +++ b/packages/driver/src/hints.ts @@ -7,7 +7,7 @@ import { Side, hidePopover, } from "./popover"; -import { generateStageSvgPathString } from "./overlay"; +import { generateStageSvgPathString } from "./stage"; import { ARROW_CORNER_INSET, PositionOptions, repositionPopover } from "./position"; import { resolveElement } from "./utils"; import "./hints.css"; diff --git a/packages/driver/src/overlay.ts b/packages/driver/src/overlay.ts index f498587..7bcefcb 100644 --- a/packages/driver/src/overlay.ts +++ b/packages/driver/src/overlay.ts @@ -1,13 +1,7 @@ import { easeInOutQuad } from "./utils"; -import { destroyDriverClick, onDriverClick } from "./events"; +import { destroyDriverClick, onDriverClick } from "./click"; import { Context } from "./context"; - -export type StageDefinition = { - x: number; - y: number; - width: number; - height: number; -}; +import { generateStageSvgPathString, StageDefinition } from "./stage"; // This method calculates the animated new position of the // stage (called for each frame by requestAnimationFrame) @@ -150,33 +144,6 @@ function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement { return svg; } -// The full-screen dim with a rounded cutout, as a single evenodd path. Pure: -// the tour passes its stagePadding/stageRadius, the hints overlay its own. -export function generateStageSvgPathString(stage: StageDefinition, options: { padding: number; radius: number }) { - const windowX = window.innerWidth; - const windowY = window.innerHeight; - - const stagePadding = options.padding; - const stageRadius = options.radius; - - const stageWidth = stage.width + stagePadding * 2; - const stageHeight = stage.height + stagePadding * 2; - - // prevent glitches when stage is too small for radius - const limitedRadius = Math.min(stageRadius, stageWidth / 2, stageHeight / 2); - - // no value below 0 allowed + round down - const normalizedRadius = Math.floor(Math.max(limitedRadius, 0)); - - const highlightBoxX = stage.x - stagePadding + normalizedRadius; - const highlightBoxY = stage.y - stagePadding; - const highlightBoxWidth = stageWidth - normalizedRadius * 2; - const highlightBoxHeight = stageHeight - normalizedRadius * 2; - - return `M${windowX},0L0,0L0,${windowY}L${windowX},${windowY}L${windowX},0Z - M${highlightBoxX},${highlightBoxY} h${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},${normalizedRadius} v${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},${normalizedRadius} h-${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},-${normalizedRadius} v-${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},-${normalizedRadius} z`; -} - export function destroyOverlay(ctx: Context) { const overlaySvg = ctx.getState("__overlaySvg"); if (overlaySvg) { diff --git a/packages/driver/src/popover.ts b/packages/driver/src/popover.ts index 06487c7..4aef6b1 100644 --- a/packages/driver/src/popover.ts +++ b/packages/driver/src/popover.ts @@ -1,5 +1,5 @@ -import type { DriverHook, HookOpts } from "./config"; -import { destroyDriverClick, onDriverClick } from "./events"; +import type { DriverHook, HookOpts } from "./context"; +import { destroyDriverClick, onDriverClick } from "./click"; import { PositionOptions, repositionPopover } from "./position"; import { bringInView, getFocusableElements } from "./utils"; diff --git a/packages/driver/src/stage.ts b/packages/driver/src/stage.ts new file mode 100644 index 0000000..a28a48c --- /dev/null +++ b/packages/driver/src/stage.ts @@ -0,0 +1,40 @@ +// Pure geometry of the stage, the cutout in the full-screen dim that keeps +// the highlighted element visible. Shared by the tour and hints overlays, +// each passing its own padding and radius. + +export type StageDefinition = { + x: number; + y: number; + width: number; + height: number; +}; + +export type StageOptions = { + padding: number; + radius: number; +}; + +// The full-screen dim with a rounded cutout, as a single evenodd path. +export function generateStageSvgPathString(stage: StageDefinition, options: StageOptions) { + const windowX = window.innerWidth; + const windowY = window.innerHeight; + + const stagePadding = options.padding; + const stageRadius = options.radius; + + const stageWidth = stage.width + stagePadding * 2; + const stageHeight = stage.height + stagePadding * 2; + + // prevent glitches when stage is too small for radius + const limitedRadius = Math.min(stageRadius, stageWidth / 2, stageHeight / 2); + + const normalizedRadius = Math.floor(Math.max(limitedRadius, 0)); + + const highlightBoxX = stage.x - stagePadding + normalizedRadius; + const highlightBoxY = stage.y - stagePadding; + const highlightBoxWidth = stageWidth - normalizedRadius * 2; + const highlightBoxHeight = stageHeight - normalizedRadius * 2; + + return `M${windowX},0L0,0L0,${windowY}L${windowX},${windowY}L${windowX},0Z + M${highlightBoxX},${highlightBoxY} h${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},${normalizedRadius} v${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},${normalizedRadius} h-${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},-${normalizedRadius} v-${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},-${normalizedRadius} z`; +} diff --git a/packages/driver/src/state.ts b/packages/driver/src/state.ts deleted file mode 100644 index 061e14b..0000000 --- a/packages/driver/src/state.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { StageDefinition } from "./overlay"; -import { PopoverDOM } from "./popover"; -import { DriveStep } from "./driver"; - -export type State = { - isInitialized?: boolean; - - activeIndex?: number; - activeElement?: Element; - activeStep?: DriveStep; - previousElement?: Element; - previousStep?: DriveStep; - - popover?: PopoverDOM; - - // actual values considering the animation - // and delays. These are used to determine - // the positions etc. - __previousElement?: Element; - __activeElement?: Element; - __previousStep?: DriveStep; - __activeStep?: DriveStep; - - __activeOnDestroyed?: Element; - __resizeTimeout?: number; - __transitionCallback?: () => void; - __activeStagePosition?: StageDefinition; - __overlaySvg?: SVGSVGElement; - - __events?: { - onKeyup: (e: KeyboardEvent) => void; - onKeydown: (e: KeyboardEvent) => void; - onResize: () => void; - onScroll: () => void; - }; -}; - -export interface GetState { - (): State; - (key: K): State[K]; -} - -export type SetState = (key: K, value: State[K]) => void; - -export type StateStore = { - getState: GetState; - setState: SetState; - resetState: () => void; -}; - -export function createStateStore(): StateStore { - let currentState: State = {}; - - const getState: GetState = ((key?: K) => { - return key ? currentState[key] : currentState; - }) as GetState; - - const setState: SetState = (key, value) => { - currentState[key] = value; - }; - - function resetState() { - currentState = {}; - } - - return { getState, setState, resetState }; -} diff --git a/packages/driver/src/step-popover.ts b/packages/driver/src/step-popover.ts deleted file mode 100644 index 4ad089f..0000000 --- a/packages/driver/src/step-popover.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Context } from "./context"; -import type { DriveStep } from "./driver"; -import { destroyPopover, PopoverRenderOptions, renderPopover } from "./popover"; -import { PositionOptions, repositionPopover } from "./position"; - -// Bridges the tour and the popover primitive: resolves a step's popover -// against the instance config (step value first, then the global default, then -// the built-in fallback) and adapts the tour's hooks onto the primitive's -// plain callbacks. The primitive itself never reads config or state. - -function resolveStepPosition(ctx: Context, element: Element, step: DriveStep): PositionOptions { - const stagePadding = ctx.getConfig("stagePadding") || 0; - - return { - side: step.popover?.side || "bottom", - align: step.popover?.align || "start", - // The popover clears the highlight cutout (stagePadding) plus the - // configured gap between the two. - offset: stagePadding + (ctx.getConfig("popoverOffset") || 0), - padding: stagePadding, - // Without a real element the tour highlights a dummy element at the center - // of the screen, and the popover is centered over it like a modal. - centered: element.id === "driver-dummy-element", - }; -} - -function resolveStepPopover(ctx: Context, element: Element, step: DriveStep): PopoverRenderOptions { - const popover = step.popover || {}; - - const steps = ctx.getConfig("steps") || []; - const activeIndex = ctx.getState("activeIndex"); - const isDoneStep = activeIndex !== undefined && activeIndex === steps.length - 1; - - return { - title: popover.title, - description: popover.description, - - showButtons: popover.showButtons || ctx.getConfig("showButtons")!, - disableButtons: popover.disableButtons || ctx.getConfig("disableButtons")! || [], - showProgress: popover.showProgress || ctx.getConfig("showProgress") || false, - - progressText: popover.progressText ?? (ctx.getConfig("progressText") || "{current} of {total}"), - nextBtnText: popover.nextBtnText ?? (ctx.getConfig("nextBtnText") || "Next"), - prevBtnText: popover.prevBtnText ?? (ctx.getConfig("prevBtnText") || "Previous"), - - doneButton: isDoneStep, - - popoverClass: popover.popoverClass || ctx.getConfig("popoverClass") || "", - smoothScroll: ctx.getConfig("smoothScroll"), - - // The hooks are resolved when the button is clicked rather than up front, - // so a setConfig() between render and click is still picked up. - onNextClick: () => { - // On the final step the next button acts as the done button, so a - // dedicated onDoneClick takes precedence over onNextClick when provided. - const onDoneClick = popover.onDoneClick || ctx.getConfig("onDoneClick"); - if (isDoneStep && onDoneClick) { - return onDoneClick(element, step, ctx.getHookOpts()); - } - - // If the user has provided a custom callback, call it - // otherwise, emit the event. - const onNextClick = popover.onNextClick || ctx.getConfig("onNextClick"); - if (onNextClick) { - return onNextClick(element, step, ctx.getHookOpts()); - } - - return ctx.emit("nextClick"); - }, - - onPrevClick: () => { - const onPrevClick = popover.onPrevClick || ctx.getConfig("onPrevClick"); - if (onPrevClick) { - return onPrevClick(element, step, ctx.getHookOpts()); - } - - return ctx.emit("prevClick"); - }, - - onCloseClick: () => { - const onCloseClick = popover.onCloseClick || ctx.getConfig("onCloseClick"); - if (onCloseClick) { - return onCloseClick(element, step, ctx.getHookOpts()); - } - - return ctx.emit("closeClick"); - }, - - onRender: popoverDom => { - // Commit the popover to state before the user hook runs; the hook's - // opts.state.popover has always pointed at the freshly rendered popover. - ctx.setState("popover", popoverDom); - - const onPopoverRender = popover.onPopoverRender || ctx.getConfig("onPopoverRender"); - onPopoverRender?.(popoverDom, ctx.getHookOpts()); - }, - - position: resolveStepPosition(ctx, element, step), - }; -} - -export function renderStepPopover(ctx: Context, element: Element, step: DriveStep) { - destroyPopover(ctx.getState("popover")); - - renderPopover(element, resolveStepPopover(ctx, element, step)); -} - -export function repositionStepPopover(ctx: Context, element: Element, step: DriveStep) { - const popover = ctx.getState("popover"); - if (!popover) { - return; - } - - repositionPopover(popover, element, resolveStepPosition(ctx, element, step)); -} diff --git a/packages/driver/src/step.ts b/packages/driver/src/step.ts new file mode 100644 index 0000000..9b9532e --- /dev/null +++ b/packages/driver/src/step.ts @@ -0,0 +1,186 @@ +import type { Context, DriverHook } from "./context"; +import type { DriveStep } from "./driver"; +import { AllowedButtons, destroyPopover, PopoverRenderOptions, renderPopover } from "./popover"; +import { PositionOptions, repositionPopover } from "./position"; + +// Bridges the tour and the popover primitive: a step's popover resolves here +// against the instance config (step value first, then the global default, then +// the built-in fallback). The primitive itself never reads config or state. + +const DEFAULT_PROGRESS_TEXT = "{{current}} of {{total}}"; + +// On the final step the next button acts as the done button, so a dedicated +// onDoneClick takes precedence over onNextClick when provided. +export function resolveNextHook(ctx: Context, step?: DriveStep): DriverHook | undefined { + const steps = ctx.getConfig("steps") || []; + const activeIndex = ctx.getState("activeIndex"); + const isLastStep = activeIndex !== undefined && activeIndex === steps.length - 1; + + const onDoneClick = step?.popover?.onDoneClick || ctx.getConfig("onDoneClick"); + if (isLastStep && onDoneClick) { + return onDoneClick; + } + + return step?.popover?.onNextClick || ctx.getConfig("onNextClick"); +} + +export function resolvePrevHook(ctx: Context, step?: DriveStep): DriverHook | undefined { + return step?.popover?.onPrevClick || ctx.getConfig("onPrevClick"); +} + +export function resolveCloseHook(ctx: Context, step?: DriveStep): DriverHook | undefined { + return step?.popover?.onCloseClick || ctx.getConfig("onCloseClick"); +} + +// Default button actions passed in by the tour, which alone knows how to +// navigate and destroy; a hook from the step or the config wins over them. +export type TourStepDefaults = { + onNextClick: DriverHook; + onPrevClick: DriverHook; + onCloseClick: DriverHook; +}; + +// The resolved step is what ends up in state and what the lifecycle hooks +// receive, not just what gets rendered. +export function resolveTourStep(ctx: Context, stepIndex: number, defaults: TourStepDefaults): DriveStep { + const steps = ctx.getConfig("steps") || []; + const step = steps[stepIndex]; + const popover = step.popover || {}; + + const hasNextStep = !!steps[stepIndex + 1]; + const hasPreviousStep = !!steps[stepIndex - 1]; + + const doneBtnText = popover.doneBtnText || ctx.getConfig("doneBtnText") || "Done"; + const allowsClosing = ctx.getConfig("allowClose"); + const showProgress = + typeof popover.showProgress !== "undefined" ? popover.showProgress : ctx.getConfig("showProgress"); + const progressText = popover.progressText || ctx.getConfig("progressText") || DEFAULT_PROGRESS_TEXT; + const progressTextReplaced = progressText + .replace("{{current}}", `${stepIndex + 1}`) + .replace("{{total}}", `${steps.length}`); + + const configuredButtons = popover.showButtons || ctx.getConfig("showButtons"); + const calculatedButtons: AllowedButtons[] = [ + "next", + "previous", + ...(allowsClosing ? ["close" as AllowedButtons] : []), + ].filter(b => { + return !configuredButtons?.length || configuredButtons.includes(b as AllowedButtons); + }) as AllowedButtons[]; + + const onNextClick = popover.onNextClick || ctx.getConfig("onNextClick"); + const onPrevClick = popover.onPrevClick || ctx.getConfig("onPrevClick"); + const onCloseClick = popover.onCloseClick || ctx.getConfig("onCloseClick"); + + return { + ...step, + popover: { + showButtons: calculatedButtons, + nextBtnText: !hasNextStep ? doneBtnText : undefined, + disableButtons: [...(!hasPreviousStep ? ["previous" as AllowedButtons] : [])], + showProgress, + onNextClick: onNextClick ? onNextClick : defaults.onNextClick, + onPrevClick: onPrevClick ? onPrevClick : defaults.onPrevClick, + onCloseClick: onCloseClick ? onCloseClick : defaults.onCloseClick, + ...popover, + progressText: progressTextReplaced, + }, + }; +} + +function resolveStepPosition(ctx: Context, element: Element, step: DriveStep): PositionOptions { + const stagePadding = ctx.getConfig("stagePadding") || 0; + + return { + side: step.popover?.side || "bottom", + align: step.popover?.align || "start", + // The popover clears the highlight cutout (stagePadding) plus the + // configured gap between the two. + offset: stagePadding + (ctx.getConfig("popoverOffset") || 0), + padding: stagePadding, + // Without a real element the tour highlights a dummy element at the center + // of the screen, and the popover is centered over it like a modal. + centered: element.id === "driver-dummy-element", + }; +} + +function resolveStepPopover(ctx: Context, element: Element, step: DriveStep): PopoverRenderOptions { + const popover = step.popover || {}; + + const steps = ctx.getConfig("steps") || []; + const activeIndex = ctx.getState("activeIndex"); + const isDoneStep = activeIndex !== undefined && activeIndex === steps.length - 1; + + return { + title: popover.title, + description: popover.description, + + showButtons: popover.showButtons || ctx.getConfig("showButtons")!, + disableButtons: popover.disableButtons || ctx.getConfig("disableButtons")! || [], + showProgress: popover.showProgress || ctx.getConfig("showProgress") || false, + + progressText: popover.progressText ?? (ctx.getConfig("progressText") || DEFAULT_PROGRESS_TEXT), + nextBtnText: popover.nextBtnText ?? (ctx.getConfig("nextBtnText") || "Next"), + prevBtnText: popover.prevBtnText ?? (ctx.getConfig("prevBtnText") || "Previous"), + + doneButton: isDoneStep, + + popoverClass: popover.popoverClass || ctx.getConfig("popoverClass") || "", + smoothScroll: ctx.getConfig("smoothScroll"), + + // The hooks are resolved when the button is clicked rather than up front, + // so a setConfig() between render and click is still picked up. + onNextClick: () => { + const onNextClick = resolveNextHook(ctx, step); + if (onNextClick) { + return onNextClick(element, step, ctx.getHookOpts()); + } + + return ctx.emit("nextClick"); + }, + + onPrevClick: () => { + const onPrevClick = resolvePrevHook(ctx, step); + if (onPrevClick) { + return onPrevClick(element, step, ctx.getHookOpts()); + } + + return ctx.emit("prevClick"); + }, + + onCloseClick: () => { + const onCloseClick = resolveCloseHook(ctx, step); + if (onCloseClick) { + return onCloseClick(element, step, ctx.getHookOpts()); + } + + return ctx.emit("closeClick"); + }, + + onRender: popoverDom => { + // Commit the popover to state before the user hook runs; the hook's + // opts.state.popover has always pointed at the freshly rendered popover. + ctx.setState("popover", popoverDom); + + const onPopoverRender = popover.onPopoverRender || ctx.getConfig("onPopoverRender"); + onPopoverRender?.(popoverDom, ctx.getHookOpts()); + }, + + position: resolveStepPosition(ctx, element, step), + }; +} + +export function renderStepPopover(ctx: Context, element: Element, step: DriveStep) { + destroyPopover(ctx.getState("popover")); + + renderPopover(element, resolveStepPopover(ctx, element, step)); +} + +export function repositionStepPopover(ctx: Context, element: Element, step: DriveStep) { + const popover = ctx.getState("popover"); + if (!popover) { + return; + } + + repositionPopover(popover, element, resolveStepPosition(ctx, element, step)); +} diff --git a/packages/driver/tests/click.test.ts b/packages/driver/tests/click.test.ts new file mode 100644 index 0000000..f145e4a --- /dev/null +++ b/packages/driver/tests/click.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { destroyDriverClick, onDriverClick } from "../src/click"; + +// Covers the capture-phase click primitive directly; popover and overlay +// button behaviour on top of it lives in interactions.test.ts. + +let host: HTMLElement; +let inner: HTMLButtonElement; +let outside: HTMLButtonElement; + +beforeEach(() => { + document.body.innerHTML = ` +
+ + `; + host = document.getElementById("host")!; + inner = document.getElementById("inner") as HTMLButtonElement; + outside = document.getElementById("outside") as HTMLButtonElement; +}); + +afterEach(() => { + destroyDriverClick(host); + document.body.innerHTML = ""; +}); + +function fire(target: Element, type = "click"): boolean { + return target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); +} + +describe("onDriverClick", () => { + it("runs the listener when a click lands inside the element", () => { + const listener = vi.fn(); + onDriverClick(host, listener); + + fire(inner); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("ignores clicks outside the element", () => { + const listener = vi.fn(); + onDriverClick(host, listener); + + fire(outside); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("only runs the listener for the click event, not the preceding pointer events", () => { + const listener = vi.fn(); + onDriverClick(host, listener); + + fire(inner, "pointerdown"); + fire(inner, "mousedown"); + fire(inner, "pointerup"); + fire(inner, "mouseup"); + + expect(listener).not.toHaveBeenCalled(); + + fire(inner, "click"); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("prevents the default action on intercepted events", () => { + onDriverClick(host, vi.fn()); + + expect(fire(inner)).toBe(false); + }); + + it("stops the event before any other document listener sees it", () => { + const bystander = vi.fn(); + document.addEventListener("click", bystander); + onDriverClick(host, vi.fn()); + + fire(inner); + + document.removeEventListener("click", bystander); + expect(bystander).not.toHaveBeenCalled(); + }); + + it("lets the default action through when shouldPreventDefault returns false", () => { + const listener = vi.fn(); + onDriverClick(host, listener, () => false); + + expect(fire(inner)).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("replaces the previous handler when attached again to the same element", () => { + const first = vi.fn(); + const second = vi.fn(); + onDriverClick(host, first); + onDriverClick(host, second); + + fire(inner); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); +}); + +describe("destroyDriverClick", () => { + it("detaches the handler so clicks reach the page again", () => { + const listener = vi.fn(); + const bystander = vi.fn(); + onDriverClick(host, listener); + destroyDriverClick(host); + + document.addEventListener("click", bystander); + fire(inner); + document.removeEventListener("click", bystander); + + expect(listener).not.toHaveBeenCalled(); + expect(bystander).toHaveBeenCalledTimes(1); + }); + + it("is a no-op for an element that was never attached", () => { + expect(() => destroyDriverClick(outside)).not.toThrow(); + }); +}); diff --git a/packages/driver/tests/context.test.ts b/packages/driver/tests/context.test.ts new file mode 100644 index 0000000..f533595 --- /dev/null +++ b/packages/driver/tests/context.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import { createContext } from "../src/context"; +import type { Driver } from "../src/driver"; + +// Covers the per-instance context directly: the config store with its +// defaults, the state store, and the single-listener emitter. The public +// driver API built on top of it is covered in backward-compat.test.ts. + +describe("config store", () => { + it("seeds the documented defaults", () => { + const ctx = createContext(); + + expect(ctx.getConfig("animate")).toBe(true); + expect(ctx.getConfig("duration")).toBe(400); + expect(ctx.getConfig("allowClose")).toBe(true); + expect(ctx.getConfig("overlayClickBehavior")).toBe("close"); + expect(ctx.getConfig("overlayOpacity")).toBe(0.7); + expect(ctx.getConfig("stagePadding")).toBe(10); + expect(ctx.getConfig("stageRadius")).toBe(5); + expect(ctx.getConfig("popoverOffset")).toBe(10); + expect(ctx.getConfig("showButtons")).toEqual(["next", "previous", "close"]); + expect(ctx.getConfig("overlayColor")).toBe("#000"); + }); + + it("layers the instance options over the defaults", () => { + const ctx = createContext({ animate: false, stagePadding: 4 }); + + expect(ctx.getConfig("animate")).toBe(false); + expect(ctx.getConfig("stagePadding")).toBe(4); + expect(ctx.getConfig("duration")).toBe(400); + }); + + it("returns the whole config when called without a key", () => { + const ctx = createContext({ animate: false }); + + expect(ctx.getConfig()).toMatchObject({ animate: false, duration: 400 }); + }); + + it("replaces the config wholesale on setConfig, re-applying defaults", () => { + const ctx = createContext({ animate: false }); + + ctx.setConfig({ duration: 100 }); + + expect(ctx.getConfig("duration")).toBe(100); + expect(ctx.getConfig("animate")).toBe(true); + }); +}); + +describe("state store", () => { + it("reads back what was set, by key or wholesale", () => { + const ctx = createContext(); + + ctx.setState("activeIndex", 2); + + expect(ctx.getState("activeIndex")).toBe(2); + expect(ctx.getState()).toEqual({ activeIndex: 2 }); + }); + + it("clears everything on resetState", () => { + const ctx = createContext(); + + ctx.setState("activeIndex", 2); + ctx.resetState(); + + expect(ctx.getState()).toEqual({}); + }); +}); + +describe("emitter", () => { + it("fires the registered listener on emit", () => { + const ctx = createContext(); + const listener = vi.fn(); + + ctx.listen("nextClick", listener); + ctx.emit("nextClick"); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("keeps a single listener per event, the last one registered", () => { + const ctx = createContext(); + const first = vi.fn(); + const second = vi.fn(); + + ctx.listen("nextClick", first); + ctx.listen("nextClick", second); + ctx.emit("nextClick"); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("does nothing when emitting an event nobody listens to", () => { + const ctx = createContext(); + + expect(() => ctx.emit("prevClick")).not.toThrow(); + }); + + it("drops all listeners on resetEmitter", () => { + const ctx = createContext(); + const listener = vi.fn(); + + ctx.listen("nextClick", listener); + ctx.resetEmitter(); + ctx.emit("nextClick"); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("hook opts", () => { + it("bundles the config, state, driver and active index", () => { + const ctx = createContext({ animate: false }); + const fakeDriver = { isActive: () => true } as Driver; + + ctx.setDriver(fakeDriver); + ctx.setState("activeIndex", 1); + + const opts = ctx.getHookOpts(); + + expect(opts.config.animate).toBe(false); + expect(opts.state.activeIndex).toBe(1); + expect(opts.driver).toBe(fakeDriver); + expect(opts.index).toBe(1); + }); + + it("uses the state override when one is passed", () => { + const ctx = createContext(); + + ctx.setState("activeIndex", 1); + + const opts = ctx.getHookOpts({ activeIndex: 5 }); + + expect(opts.state.activeIndex).toBe(5); + expect(opts.index).toBe(5); + }); +}); diff --git a/packages/driver/tests/stage.test.ts b/packages/driver/tests/stage.test.ts new file mode 100644 index 0000000..0076d8d --- /dev/null +++ b/packages/driver/tests/stage.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { generateStageSvgPathString } from "../src/stage"; + +// Pure geometry of the overlay cutout. The rendered overlays built on top of +// it are covered in overlay.test.ts (tour) and hints.test.ts (hints). + +const stage = { x: 100, y: 100, width: 200, height: 50 }; + +describe("generateStageSvgPathString", () => { + it("opens with a rect covering the whole viewport", () => { + const path = generateStageSvgPathString(stage, { padding: 0, radius: 0 }); + const { innerWidth: w, innerHeight: h } = window; + + expect(path.startsWith(`M${w},0L0,0L0,${h}L${w},${h}L${w},0Z`)).toBe(true); + }); + + it("insets the cutout by the padding on every side", () => { + const path = generateStageSvgPathString(stage, { padding: 10, radius: 0 }); + + expect(path).toContain("M90,90"); + expect(path).toContain("h220"); + expect(path).toContain("v70"); + }); + + it("rounds the cutout corners by the radius", () => { + const path = generateStageSvgPathString(stage, { padding: 0, radius: 5 }); + + expect(path).toContain("a5,5 0 0 1 5,5"); + expect(path).toContain("M105,100"); + }); + + it("clamps the radius when the stage is too small for it", () => { + const tiny = { x: 0, y: 0, width: 10, height: 10 }; + const path = generateStageSvgPathString(tiny, { padding: 0, radius: 20 }); + + expect(path).toContain("a5,5"); + expect(path).not.toContain("a20,20"); + }); + + it("floors a negative radius at zero", () => { + const path = generateStageSvgPathString(stage, { padding: 0, radius: -5 }); + + expect(path).toContain("a0,0"); + }); +}); diff --git a/packages/driver/tests/step.test.ts b/packages/driver/tests/step.test.ts new file mode 100644 index 0000000..89e07ef --- /dev/null +++ b/packages/driver/tests/step.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; +import { Config, createContext } from "../src/context"; +import type { DriveStep } from "../src/driver"; +import { resolveCloseHook, resolveNextHook, resolvePrevHook, resolveTourStep, TourStepDefaults } from "../src/step"; + +// Covers the step resolution directly: which buttons a tour step ends up +// with, the texts on them, and how click hooks resolve between the step, the +// config and the tour's default actions. The rendered result is covered in +// popover.test.ts and interactions.test.ts. + +const STEPS: DriveStep[] = [ + { element: "#one", popover: { title: "First" } }, + { element: "#two", popover: { title: "Second" } }, + { element: "#three", popover: { title: "Third" } }, +]; + +function makeContext(config: Config = {}) { + return createContext({ steps: STEPS, ...config }); +} + +function makeDefaults(): TourStepDefaults { + return { onNextClick: vi.fn(), onPrevClick: vi.fn(), onCloseClick: vi.fn() }; +} + +describe("resolveTourStep", () => { + it("shows all built-in buttons and disables previous on the first step", () => { + const resolved = resolveTourStep(makeContext(), 0, makeDefaults()); + + expect(resolved.popover?.showButtons).toEqual(["next", "previous", "close"]); + expect(resolved.popover?.disableButtons).toEqual(["previous"]); + }); + + it("leaves previous enabled on later steps", () => { + const resolved = resolveTourStep(makeContext(), 1, makeDefaults()); + + expect(resolved.popover?.disableButtons).toEqual([]); + }); + + it("drops the close button when allowClose is false", () => { + const resolved = resolveTourStep(makeContext({ allowClose: false }), 0, makeDefaults()); + + expect(resolved.popover?.showButtons).toEqual(["next", "previous"]); + }); + + it("narrows the buttons to the configured showButtons", () => { + const resolved = resolveTourStep(makeContext({ showButtons: ["next"] }), 1, makeDefaults()); + + expect(resolved.popover?.showButtons).toEqual(["next"]); + }); + + it("lets a step's own showButtons win over the config", () => { + const ctx = makeContext({ + showButtons: ["next"], + steps: [{ element: "#one", popover: { title: "First", showButtons: ["close"] } }, ...STEPS.slice(1)], + }); + + const resolved = resolveTourStep(ctx, 0, makeDefaults()); + + expect(resolved.popover?.showButtons).toEqual(["close"]); + }); + + it("labels the next button as the done button on the last step", () => { + const resolved = resolveTourStep(makeContext(), STEPS.length - 1, makeDefaults()); + + expect(resolved.popover?.nextBtnText).toBe("Done"); + }); + + it("honours a configured done button text", () => { + const resolved = resolveTourStep(makeContext({ doneBtnText: "Finish" }), STEPS.length - 1, makeDefaults()); + + expect(resolved.popover?.nextBtnText).toBe("Finish"); + }); + + it("leaves the next button text unset before the last step", () => { + const resolved = resolveTourStep(makeContext(), 0, makeDefaults()); + + expect(resolved.popover?.nextBtnText).toBeUndefined(); + }); + + it("replaces the progress placeholders with the step position", () => { + const resolved = resolveTourStep(makeContext(), 1, makeDefaults()); + + expect(resolved.popover?.progressText).toBe("2 of 3"); + }); + + it("replaces the placeholders in a configured progress template", () => { + const resolved = resolveTourStep(makeContext({ progressText: "Step {{current}}/{{total}}" }), 0, makeDefaults()); + + expect(resolved.popover?.progressText).toBe("Step 1/3"); + }); + + it("resolves showProgress from the step before the config", () => { + const ctx = makeContext({ + showProgress: true, + steps: [{ element: "#one", popover: { title: "First", showProgress: false } }, ...STEPS.slice(1)], + }); + + const resolved = resolveTourStep(ctx, 0, makeDefaults()); + + expect(resolved.popover?.showProgress).toBe(false); + }); + + it("keeps the step's own popover fields", () => { + const resolved = resolveTourStep(makeContext(), 1, makeDefaults()); + + expect(resolved.element).toBe("#two"); + expect(resolved.popover?.title).toBe("Second"); + }); + + it("falls back to the tour's default button actions", () => { + const defaults = makeDefaults(); + + const resolved = resolveTourStep(makeContext(), 0, defaults); + + expect(resolved.popover?.onNextClick).toBe(defaults.onNextClick); + expect(resolved.popover?.onPrevClick).toBe(defaults.onPrevClick); + expect(resolved.popover?.onCloseClick).toBe(defaults.onCloseClick); + }); + + it("prefers a config-level hook over the default action", () => { + const onNextClick = vi.fn(); + + const resolved = resolveTourStep(makeContext({ onNextClick }), 0, makeDefaults()); + + expect(resolved.popover?.onNextClick).toBe(onNextClick); + }); + + it("prefers a step-level hook over the config and the default action", () => { + const stepHook = vi.fn(); + const ctx = makeContext({ + onNextClick: vi.fn(), + steps: [{ element: "#one", popover: { title: "First", onNextClick: stepHook } }, ...STEPS.slice(1)], + }); + + const resolved = resolveTourStep(ctx, 0, makeDefaults()); + + expect(resolved.popover?.onNextClick).toBe(stepHook); + }); +}); + +describe("resolveNextHook", () => { + it("returns nothing when no hook is configured", () => { + expect(resolveNextHook(makeContext(), STEPS[0])).toBeUndefined(); + }); + + it("prefers the step hook over the config hook", () => { + const stepHook = vi.fn(); + const ctx = makeContext({ onNextClick: vi.fn() }); + const step: DriveStep = { element: "#one", popover: { onNextClick: stepHook } }; + + expect(resolveNextHook(ctx, step)).toBe(stepHook); + }); + + it("falls back to the config hook", () => { + const configHook = vi.fn(); + const ctx = makeContext({ onNextClick: configHook }); + + expect(resolveNextHook(ctx, STEPS[0])).toBe(configHook); + }); + + it("prefers onDoneClick on the last step", () => { + const onDoneClick = vi.fn(); + const onNextClick = vi.fn(); + const ctx = makeContext({ onDoneClick, onNextClick }); + + ctx.setState("activeIndex", STEPS.length - 1); + + expect(resolveNextHook(ctx, STEPS[STEPS.length - 1])).toBe(onDoneClick); + }); + + it("ignores onDoneClick before the last step", () => { + const onDoneClick = vi.fn(); + const onNextClick = vi.fn(); + const ctx = makeContext({ onDoneClick, onNextClick }); + + ctx.setState("activeIndex", 0); + + expect(resolveNextHook(ctx, STEPS[0])).toBe(onNextClick); + }); + + it("ignores onDoneClick while no step is active", () => { + const onDoneClick = vi.fn(); + const ctx = makeContext({ onDoneClick }); + + expect(resolveNextHook(ctx, STEPS[0])).toBeUndefined(); + }); +}); + +describe("resolvePrevHook and resolveCloseHook", () => { + it("prefer the step hook over the config hook", () => { + const stepPrev = vi.fn(); + const stepClose = vi.fn(); + const ctx = makeContext({ onPrevClick: vi.fn(), onCloseClick: vi.fn() }); + const step: DriveStep = { element: "#one", popover: { onPrevClick: stepPrev, onCloseClick: stepClose } }; + + expect(resolvePrevHook(ctx, step)).toBe(stepPrev); + expect(resolveCloseHook(ctx, step)).toBe(stepClose); + }); + + it("fall back to the config hook and then to nothing", () => { + const configPrev = vi.fn(); + const ctx = makeContext({ onPrevClick: configPrev }); + + expect(resolvePrevHook(ctx, STEPS[0])).toBe(configPrev); + expect(resolveCloseHook(ctx, STEPS[0])).toBeUndefined(); + }); +});