mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-10-31 12:04:55 +08:00
Reorganize internals and unify step popover
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user