Reorganize internals and unify step popover

This commit is contained in:
Kamran Ahmed
2026-07-17 13:07:19 +01:00
parent dd468dcc10
commit e2aa992dbf
18 changed files with 1015 additions and 508 deletions
+56
View File
@@ -0,0 +1,56 @@
const DRIVER_CLICK_EVENTS = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"] as const;
const driverClickHandlers = new WeakMap<Element, (e: MouseEvent | PointerEvent) => 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);
}
-108
View File
@@ -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;
<K extends keyof 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 = (<K extends keyof Config>(key?: K) => {
return key ? currentConfig[key] : currentConfig;
}) as GetConfig;
configure();
return { getConfig, configure };
}
+191 -6
View File
@@ -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;
<K extends keyof 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 = (<K extends keyof Config>(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;
<K extends keyof State>(key: K): State[K];
}
export type SetState = <K extends keyof State>(key: K, value: State[K]) => void;
function createStateStore() {
let currentState: State = {};
const getState: GetState = (<K extends keyof State>(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;
+26 -80
View File
@@ -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) {
-32
View File
@@ -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 };
}
-61
View File
@@ -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<Element, (e: MouseEvent | PointerEvent) => 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);
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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";
+2 -35
View File
@@ -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) {
+2 -2
View File
@@ -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";
+40
View File
@@ -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`;
}
-67
View File
@@ -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;
<K extends keyof State>(key: K): State[K];
}
export type SetState = <K extends keyof State>(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 = (<K extends keyof State>(key?: K) => {
return key ? currentState[key] : currentState;
}) as GetState;
const setState: SetState = (key, value) => {
currentState[key] = value;
};
function resetState() {
currentState = {};
}
return { getState, setState, resetState };
}
-115
View File
@@ -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));
}
+186
View File
@@ -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));
}
+121
View File
@@ -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 = `
<div id="host"><button id="inner">Inner</button></div>
<button id="outside">Outside</button>
`;
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();
});
});
+137
View File
@@ -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);
});
});
+45
View File
@@ -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");
});
});
+207
View File
@@ -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();
});
});