Scope config, state and emitter to each driver instance

This commit is contained in:
Kamran Ahmed
2026-07-06 16:19:08 +01:00
parent 16a3684f54
commit f28d08bfb7
12 changed files with 458 additions and 353 deletions
+40 -33
View File
@@ -40,7 +40,7 @@ export type Config = {
doneBtnText?: string;
// Called after the popover is rendered
onPopoverRender?: (popover: PopoverDOM, opts: { config: Config; state: State, driver: Driver }) => void;
onPopoverRender?: (popover: PopoverDOM, opts: { config: Config; state: State; driver: Driver }) => void;
// State based callbacks, called upon state changes
onHighlightStarted?: DriverHook;
@@ -56,40 +56,47 @@ export type Config = {
onDoneClick?: DriverHook;
};
let currentConfig: Config = {};
let currentDriver: Driver;
export function configure(config: Config = {}) {
currentConfig = {
animate: true,
duration: 400,
allowClose: true,
allowScroll: true,
overlayClickBehavior: "close",
overlayOpacity: 0.7,
smoothScroll: false,
disableActiveInteraction: false,
showProgress: false,
stagePadding: 10,
stageRadius: 5,
popoverOffset: 10,
showButtons: ["next", "previous", "close"],
disableButtons: [],
overlayColor: "#000",
...config,
};
export interface GetConfig {
(): Config;
<K extends keyof Config>(key: K): Config[K];
}
export function getConfig(): Config;
export function getConfig<K extends keyof Config>(key: K): Config[K];
export function getConfig<K extends keyof Config>(key?: K) {
return key ? currentConfig[key] : currentConfig;
}
export type ConfigStore = {
getConfig: GetConfig;
configure: (config?: Config) => void;
};
export function setCurrentDriver(driver: Driver) {
currentDriver = driver;
}
// Each driver instance owns its own config store, so creating a second driver
// no longer overwrites the configuration of the first (issue #571).
export function createConfigStore(): ConfigStore {
let currentConfig: Config = {};
export function getCurrentDriver() {
return currentDriver;
function configure(config: Config = {}) {
currentConfig = {
animate: true,
duration: 400,
allowClose: true,
allowScroll: true,
overlayClickBehavior: "close",
overlayOpacity: 0.7,
smoothScroll: false,
disableActiveInteraction: 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 };
}
+52
View File
@@ -0,0 +1,52 @@
import { Config, createConfigStore, GetConfig } from "./config";
import { createStateStore, GetState, SetState } from "./state";
import { createEmitter, Emitter } from "./emitter";
import type { Driver } from "./driver";
// A Context bundles the per-instance config, state and emitter for a single
// driver. It is threaded through every helper so that nothing reaches for
// shared module-level state — that shared state was the root of issue #571,
// where a second `driver()` clobbered the config and state of the first.
export type Context = {
getConfig: GetConfig;
setConfig: (config?: Config) => void;
getState: GetState;
setState: SetState;
resetState: () => void;
listen: Emitter["listen"];
emit: Emitter["emit"];
resetEmitter: () => void;
getDriver: () => Driver;
setDriver: (driver: Driver) => void;
};
export function createContext(options: Config = {}): Context {
const config = createConfigStore();
config.configure(options);
const state = createStateStore();
const emitter = createEmitter();
let driver: Driver;
return {
getConfig: config.getConfig,
setConfig: config.configure,
getState: state.getState,
setState: state.setState,
resetState: state.resetState,
listen: emitter.listen,
emit: emitter.emit,
resetEmitter: emitter.reset,
getDriver: () => driver,
setDriver: (value: Driver) => {
driver = value;
},
};
}
+117 -117
View File
@@ -1,10 +1,9 @@
import { AllowedButtons, destroyPopover, Popover } from "./popover";
import { destroyOverlay } from "./overlay";
import { destroyEvents, initEvents, requireRefresh } from "./events";
import { Config, configure, DriverHook, getConfig, getCurrentDriver, setCurrentDriver } from "./config";
import { Config, DriverHook } from "./config";
import { createContext } from "./context";
import { destroyHighlight, highlight } from "./highlight";
import { destroyEmitter, listen } from "./emitter";
import { getState, resetState, setState } from "./state";
import "./driver.css";
// Re-export the public types so they remain part of the package's type surface.
@@ -49,10 +48,10 @@ export interface Driver {
}
export function driver(options: Config = {}): Driver {
configure(options);
const ctx = createContext(options);
function handleClose() {
if (!getConfig("allowClose")) {
if (!ctx.getConfig("allowClose")) {
return;
}
@@ -60,48 +59,48 @@ export function driver(options: Config = {}): Driver {
}
function handleOverlayClick() {
const overlayClickBehavior = getConfig("overlayClickBehavior");
const overlayClickBehavior = ctx.getConfig("overlayClickBehavior");
if (getConfig("allowClose") && overlayClickBehavior === "close") {
if (ctx.getConfig("allowClose") && overlayClickBehavior === "close") {
destroy();
return;
}
if (typeof overlayClickBehavior === "function") {
const activeStep = getState("__activeStep");
const activeElement = getState("__activeElement");
const activeStep = ctx.getState("__activeStep");
const activeElement = ctx.getState("__activeElement");
overlayClickBehavior(activeElement, activeStep!, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
return;
}
if (overlayClickBehavior === "nextStep") {
const activeStep = getState("activeStep");
const activeElement = getState("activeElement");
const activeStep = ctx.getState("activeStep");
const activeElement = ctx.getState("activeElement");
const steps = getConfig("steps") || [];
const isLastStep = getState("activeIndex") === steps.length - 1;
const onDoneClick = activeStep?.popover?.onDoneClick || getConfig("onDoneClick");
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!, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
return;
}
const onNextClick = activeStep?.popover?.onNextClick || getConfig("onNextClick");
const onNextClick = activeStep?.popover?.onNextClick || ctx.getConfig("onNextClick");
if (onNextClick) {
onNextClick(activeElement, activeStep!, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
return;
}
@@ -111,8 +110,8 @@ export function driver(options: Config = {}): Driver {
}
function moveNext() {
const activeIndex = getState("activeIndex");
const steps = getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
if (typeof activeIndex === "undefined") {
return;
}
@@ -126,8 +125,8 @@ export function driver(options: Config = {}): Driver {
}
function movePrevious() {
const activeIndex = getState("activeIndex");
const steps = getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
if (typeof activeIndex === "undefined") {
return;
}
@@ -141,7 +140,7 @@ export function driver(options: Config = {}): Driver {
}
function moveTo(index: number) {
const steps = getConfig("steps") || [];
const steps = ctx.getConfig("steps") || [];
if (steps[index]) {
drive(index);
@@ -151,29 +150,29 @@ export function driver(options: Config = {}): Driver {
}
function handleArrowLeft() {
const isTransitioning = getState("__transitionCallback");
const isTransitioning = ctx.getState("__transitionCallback");
if (isTransitioning) {
return;
}
const activeIndex = getState("activeIndex");
const activeStep = getState("__activeStep");
const activeElement = getState("__activeElement");
const activeIndex = ctx.getState("activeIndex");
const activeStep = ctx.getState("__activeStep");
const activeElement = ctx.getState("__activeElement");
if (typeof activeIndex === "undefined" || typeof activeStep === "undefined") {
return;
}
const steps = getConfig("steps") || [];
const steps = ctx.getConfig("steps") || [];
if (!steps[activeIndex - 1]) {
return;
}
const onPrevClick = activeStep.popover?.onPrevClick || getConfig("onPrevClick");
const onPrevClick = activeStep.popover?.onPrevClick || ctx.getConfig("onPrevClick");
if (onPrevClick) {
return onPrevClick(activeElement, activeStep, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
}
@@ -181,35 +180,35 @@ export function driver(options: Config = {}): Driver {
}
function handleArrowRight() {
const isTransitioning = getState("__transitionCallback");
const isTransitioning = ctx.getState("__transitionCallback");
if (isTransitioning) {
return;
}
const activeIndex = getState("activeIndex");
const activeStep = getState("__activeStep");
const activeElement = getState("__activeElement");
const activeIndex = ctx.getState("activeIndex");
const activeStep = ctx.getState("__activeStep");
const activeElement = ctx.getState("__activeElement");
if (typeof activeIndex === "undefined" || typeof activeStep === "undefined") {
return;
}
const steps = getConfig("steps") || [];
const steps = ctx.getConfig("steps") || [];
const isLastStep = activeIndex === steps.length - 1;
const onDoneClick = activeStep.popover?.onDoneClick || getConfig("onDoneClick");
const onDoneClick = activeStep.popover?.onDoneClick || ctx.getConfig("onDoneClick");
if (isLastStep && onDoneClick) {
return onDoneClick(activeElement, activeStep, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
}
const onNextClick = activeStep.popover?.onNextClick || getConfig("onNextClick");
const onNextClick = activeStep.popover?.onNextClick || ctx.getConfig("onNextClick");
if (onNextClick) {
return onNextClick(activeElement, activeStep, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
}
@@ -217,28 +216,28 @@ export function driver(options: Config = {}): Driver {
}
function init() {
if (getState("isInitialized")) {
if (ctx.getState("isInitialized")) {
return;
}
setState("isInitialized", true);
document.body.classList.add("driver-active", getConfig("animate") ? "driver-fade" : "driver-simple");
if (!getConfig("allowScroll")) {
ctx.setState("isInitialized", true);
document.body.classList.add("driver-active", ctx.getConfig("animate") ? "driver-fade" : "driver-simple");
if (!ctx.getConfig("allowScroll")) {
document.body.classList.add("driver-no-scroll");
}
document.body.style.setProperty("--driver-animation-duration", `${getConfig("duration") || 400}ms`);
document.body.style.setProperty("--driver-animation-duration", `${ctx.getConfig("duration") || 400}ms`);
initEvents();
initEvents(ctx);
listen("overlayClick", handleOverlayClick);
listen("escapePress", handleClose);
listen("closeClick", handleClose);
listen("arrowLeftPress", handleArrowLeft);
listen("arrowRightPress", handleArrowRight);
ctx.listen("overlayClick", handleOverlayClick);
ctx.listen("escapePress", handleClose);
ctx.listen("closeClick", handleClose);
ctx.listen("arrowLeftPress", handleArrowLeft);
ctx.listen("arrowRightPress", handleArrowRight);
}
function drive(stepIndex: number = 0) {
const steps = getConfig("steps");
const steps = ctx.getConfig("steps");
if (!steps) {
console.error("No steps to drive through");
destroy();
@@ -251,25 +250,26 @@ export function driver(options: Config = {}): Driver {
return;
}
setState("__activeOnDestroyed", document.activeElement as HTMLElement);
setState("activeIndex", stepIndex);
ctx.setState("__activeOnDestroyed", document.activeElement as HTMLElement);
ctx.setState("activeIndex", stepIndex);
const currentStep = steps[stepIndex];
const hasNextStep = steps[stepIndex + 1];
const hasPreviousStep = steps[stepIndex - 1];
const doneBtnText = currentStep.popover?.doneBtnText || getConfig("doneBtnText") || "Done";
const allowsClosing = getConfig("allowClose");
const doneBtnText = currentStep.popover?.doneBtnText || ctx.getConfig("doneBtnText") || "Done";
const allowsClosing = ctx.getConfig("allowClose");
const showProgress =
typeof currentStep.popover?.showProgress !== "undefined"
? currentStep.popover?.showProgress
: getConfig("showProgress");
const progressText = currentStep.popover?.progressText || getConfig("progressText") || "{{current}} of {{total}}";
: 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 || getConfig("showButtons");
const configuredButtons = currentStep.popover?.showButtons || ctx.getConfig("showButtons");
const calculatedButtons: AllowedButtons[] = [
"next",
"previous",
@@ -278,11 +278,11 @@ export function driver(options: Config = {}): Driver {
return !configuredButtons?.length || configuredButtons.includes(b as AllowedButtons);
}) as AllowedButtons[];
const onNextClick = currentStep.popover?.onNextClick || getConfig("onNextClick");
const onPrevClick = currentStep.popover?.onPrevClick || getConfig("onPrevClick");
const onCloseClick = currentStep.popover?.onCloseClick || getConfig("onCloseClick");
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({
highlight(ctx, {
...currentStep,
popover: {
showButtons: calculatedButtons,
@@ -315,56 +315,56 @@ export function driver(options: Config = {}): Driver {
}
function destroy(withOnDestroyStartedHook = true) {
const activeElement = getState("__activeElement");
const activeStep = getState("__activeStep");
const activeElement = ctx.getState("__activeElement");
const activeStep = ctx.getState("__activeStep");
const activeOnDestroyed = getState("__activeOnDestroyed");
const activeOnDestroyed = ctx.getState("__activeOnDestroyed");
const onDestroyStarted = getConfig("onDestroyStarted");
const onDestroyStarted = ctx.getConfig("onDestroyStarted");
// `onDestroyStarted` is used to confirm the exit of tour. If we trigger
// the hook for when user calls `destroy`, driver will get into infinite loop
// not causing tour to be destroyed.
if (withOnDestroyStartedHook && onDestroyStarted) {
const isActiveDummyElement = !activeElement || activeElement?.id === "driver-dummy-element";
onDestroyStarted(isActiveDummyElement ? undefined : activeElement, activeStep!, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
return;
}
const onDeselected = activeStep?.onDeselected || getConfig("onDeselected");
const onDestroyed = getConfig("onDestroyed");
const onDeselected = activeStep?.onDeselected || ctx.getConfig("onDeselected");
const onDestroyed = ctx.getConfig("onDestroyed");
document.body.classList.remove("driver-active", "driver-fade", "driver-simple", "driver-no-scroll");
document.body.style.removeProperty("--driver-animation-duration");
destroyEvents();
destroyPopover();
destroyEvents(ctx);
destroyPopover(ctx);
destroyHighlight();
destroyOverlay();
destroyEmitter();
destroyOverlay(ctx);
ctx.resetEmitter();
const stateBeforeDestroy = getState();
const stateBeforeDestroy = ctx.getState();
resetState();
ctx.resetState();
if (activeElement && activeStep) {
const isActiveDummyElement = activeElement.id === "driver-dummy-element";
if (onDeselected) {
onDeselected(isActiveDummyElement ? undefined : activeElement, activeStep, {
config: getConfig(),
config: ctx.getConfig(),
state: stateBeforeDestroy,
driver: getCurrentDriver(),
driver: ctx.getDriver(),
});
}
if (onDestroyed) {
onDestroyed(isActiveDummyElement ? undefined : activeElement, activeStep, {
config: getConfig(),
config: ctx.getConfig(),
state: stateBeforeDestroy,
driver: getCurrentDriver(),
driver: ctx.getDriver(),
});
}
}
@@ -375,37 +375,37 @@ export function driver(options: Config = {}): Driver {
}
const api: Driver = {
isActive: () => getState("isInitialized") || false,
refresh: requireRefresh,
isActive: () => ctx.getState("isInitialized") || false,
refresh: () => requireRefresh(ctx),
drive: (stepIndex: number = 0) => {
init();
drive(stepIndex);
},
setConfig: configure,
setConfig: ctx.setConfig,
setSteps: (steps: DriveStep[]) => {
resetState();
configure({
...getConfig(),
ctx.resetState();
ctx.setConfig({
...ctx.getConfig(),
steps,
});
},
getConfig,
getState,
getActiveIndex: () => getState("activeIndex"),
isFirstStep: () => getState("activeIndex") === 0,
getConfig: ctx.getConfig,
getState: ctx.getState,
getActiveIndex: () => ctx.getState("activeIndex"),
isFirstStep: () => ctx.getState("activeIndex") === 0,
isLastStep: () => {
const steps = getConfig("steps") || [];
const activeIndex = getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
return activeIndex !== undefined && activeIndex === steps.length - 1;
},
getActiveStep: () => getState("activeStep"),
getActiveElement: () => getState("activeElement"),
getPreviousElement: () => getState("previousElement"),
getPreviousStep: () => getState("previousStep"),
getActiveStep: () => ctx.getState("activeStep"),
getActiveElement: () => ctx.getState("activeElement"),
getPreviousElement: () => ctx.getState("previousElement"),
getPreviousStep: () => ctx.getState("previousStep"),
getNextStep: () => {
const steps = getConfig("steps") || [];
const activeIndex = getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
return activeIndex !== undefined ? steps[activeIndex + 1] : undefined;
},
@@ -413,20 +413,20 @@ export function driver(options: Config = {}): Driver {
movePrevious,
moveTo,
hasNextStep: () => {
const steps = getConfig("steps") || [];
const activeIndex = getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
return activeIndex !== undefined && !!steps[activeIndex + 1];
},
hasPreviousStep: () => {
const steps = getConfig("steps") || [];
const activeIndex = getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
return activeIndex !== undefined && !!steps[activeIndex - 1];
},
highlight: (step: DriveStep) => {
init();
highlight({
highlight(ctx, {
...step,
popover: step.popover
? {
@@ -443,7 +443,7 @@ export function driver(options: Config = {}): Driver {
},
};
setCurrentDriver(api);
ctx.setDriver(api);
return api;
}
+21 -9
View File
@@ -7,16 +7,28 @@ type allowedEvents =
| "arrowRightPress"
| "arrowLeftPress";
let registeredListeners: Partial<{ [key in allowedEvents]: () => void }> = {};
export type Emitter = {
listen: (hook: allowedEvents, callback: () => void) => void;
emit: (hook: allowedEvents) => void;
reset: () => void;
};
export function listen(hook: allowedEvents, callback: () => void) {
registeredListeners[hook] = callback;
}
// Each driver instance owns its own emitter so events raised by one tour never
// trigger listeners registered by another.
export function createEmitter(): Emitter {
let registeredListeners: Partial<{ [key in allowedEvents]: () => void }> = {};
export function emit(hook: allowedEvents) {
registeredListeners[hook]?.();
}
function listen(hook: allowedEvents, callback: () => void) {
registeredListeners[hook] = callback;
}
export function destroyEmitter() {
registeredListeners = {};
function emit(hook: allowedEvents) {
registeredListeners[hook]?.();
}
function reset() {
registeredListeners = {};
}
return { listen, emit, reset };
}
+46 -25
View File
@@ -1,20 +1,21 @@
import { Context } from "./context";
import { refreshActiveHighlight } from "./highlight";
import { emit } from "./emitter";
import { getState, setState } from "./state";
import { getConfig } from "./config";
import { getFocusableElements } from "./utils";
export function requireRefresh() {
const resizeTimeout = getState("__resizeTimeout");
export function requireRefresh(ctx: Context) {
const resizeTimeout = ctx.getState("__resizeTimeout");
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
setState("__resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
ctx.setState(
"__resizeTimeout",
window.requestAnimationFrame(() => refreshActiveHighlight(ctx))
);
}
function trapFocus(e: KeyboardEvent) {
const isActivated = getState("isInitialized");
function trapFocus(ctx: Context, e: KeyboardEvent) {
const isActivated = ctx.getState("isInitialized");
if (!isActivated) {
return;
}
@@ -24,8 +25,8 @@ function trapFocus(e: KeyboardEvent) {
return;
}
const activeElement = getState("__activeElement");
const popoverEl = getState("popover")?.wrapper;
const activeElement = ctx.getState("__activeElement");
const popoverEl = ctx.getState("popover")?.wrapper;
const focusableEls = getFocusableElements([
...(popoverEl ? [popoverEl] : []),
@@ -48,19 +49,19 @@ function trapFocus(e: KeyboardEvent) {
}
}
function onKeyup(e: KeyboardEvent) {
const allowKeyboardControl = getConfig("allowKeyboardControl") ?? true;
function onKeyup(ctx: Context, e: KeyboardEvent) {
const allowKeyboardControl = ctx.getConfig("allowKeyboardControl") ?? true;
if (!allowKeyboardControl) {
return;
}
if (e.key === "Escape") {
emit("escapePress");
ctx.emit("escapePress");
} else if (e.key === "ArrowRight") {
emit("arrowRightPress");
ctx.emit("arrowRightPress");
} else if (e.key === "ArrowLeft") {
emit("arrowLeftPress");
ctx.emit("arrowLeftPress");
}
}
@@ -125,16 +126,36 @@ export function destroyDriverClick(element: Element) {
driverClickHandlers.delete(element);
}
export function initEvents() {
window.addEventListener("keyup", onKeyup, false);
window.addEventListener("keydown", trapFocus, false);
window.addEventListener("resize", requireRefresh);
window.addEventListener("scroll", requireRefresh);
export function initEvents(ctx: Context) {
// The handlers close over this instance's context and are stashed in state so
// destroyEvents can detach the very same references, keeping instances from
// removing each other's listeners.
const onWindowKeyup = (e: KeyboardEvent) => onKeyup(ctx, e);
const onWindowKeydown = (e: KeyboardEvent) => trapFocus(ctx, e);
const onWindowResize = () => requireRefresh(ctx);
const onWindowScroll = () => requireRefresh(ctx);
ctx.setState("__events", {
onKeyup: onWindowKeyup,
onKeydown: onWindowKeydown,
onResize: onWindowResize,
onScroll: onWindowScroll,
});
window.addEventListener("keyup", onWindowKeyup, false);
window.addEventListener("keydown", onWindowKeydown, false);
window.addEventListener("resize", onWindowResize);
window.addEventListener("scroll", onWindowScroll);
}
export function destroyEvents() {
window.removeEventListener("keyup", onKeyup);
window.removeEventListener("keydown", trapFocus);
window.removeEventListener("resize", requireRefresh);
window.removeEventListener("scroll", requireRefresh);
export function destroyEvents(ctx: Context) {
const events = ctx.getState("__events");
if (!events) {
return;
}
window.removeEventListener("keyup", events.onKeyup);
window.removeEventListener("keydown", events.onKeydown);
window.removeEventListener("resize", events.onResize);
window.removeEventListener("scroll", events.onScroll);
}
+43 -44
View File
@@ -1,10 +1,9 @@
import { Context } from "./context";
import { DriveStep } from "./driver";
import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
import { getConfig, getCurrentDriver } from "./config";
import { hidePopover, renderPopover } from "./popover";
import { repositionPopover } from "./position";
import { bringInView, isScrollable } from "./utils";
import { getState, setState } from "./state";
function mountDummyElement(): Element {
const existingDummy = document.getElementById("driver-dummy-element");
@@ -28,7 +27,7 @@ function mountDummyElement(): Element {
return element;
}
export function highlight(step: DriveStep) {
export function highlight(ctx: Context, step: DriveStep) {
const { element } = step;
let elemObj =
typeof element === "function" ? element() : typeof element === "string" ? document.querySelector(element) : element;
@@ -41,28 +40,28 @@ export function highlight(step: DriveStep) {
elemObj = mountDummyElement();
}
transferHighlight(elemObj, step);
transferHighlight(ctx, elemObj, step);
}
export function refreshActiveHighlight() {
const activeHighlight = getState("__activeElement");
const activeStep = getState("__activeStep")!;
export function refreshActiveHighlight(ctx: Context) {
const activeHighlight = ctx.getState("__activeElement");
const activeStep = ctx.getState("__activeStep")!;
if (!activeHighlight) {
return;
}
trackActiveElement(activeHighlight);
refreshOverlay();
repositionPopover(activeHighlight, activeStep);
trackActiveElement(ctx, activeHighlight);
refreshOverlay(ctx);
repositionPopover(ctx, activeHighlight, activeStep);
}
function transferHighlight(toElement: Element, toStep: DriveStep) {
const duration = getConfig("duration") || 400;
function transferHighlight(ctx: Context, toElement: Element, toStep: DriveStep) {
const duration = ctx.getConfig("duration") || 400;
const start = Date.now();
const fromStep = getState("__activeStep");
const fromElement = getState("__activeElement") || toElement;
const fromStep = ctx.getState("__activeStep");
const fromElement = ctx.getState("__activeElement") || toElement;
// If it's the first time we're highlighting an element, we show
// the popover immediately. Otherwise, we wait for the animation
@@ -71,19 +70,19 @@ function transferHighlight(toElement: Element, toStep: DriveStep) {
const isToDummyElement = toElement.id === "driver-dummy-element";
const isFromDummyElement = fromElement.id === "driver-dummy-element";
const isAnimatedTour = getConfig("animate");
const highlightStartedHook = toStep.onHighlightStarted || getConfig("onHighlightStarted");
const highlightedHook = toStep?.onHighlighted || getConfig("onHighlighted");
const deselectedHook = fromStep?.onDeselected || getConfig("onDeselected");
const isAnimatedTour = ctx.getConfig("animate");
const highlightStartedHook = toStep.onHighlightStarted || ctx.getConfig("onHighlightStarted");
const highlightedHook = toStep?.onHighlighted || ctx.getConfig("onHighlighted");
const deselectedHook = fromStep?.onDeselected || ctx.getConfig("onDeselected");
const config = getConfig();
const state = getState();
const config = ctx.getConfig();
const state = ctx.getState();
if (!isFirstHighlight && deselectedHook) {
deselectedHook(isFromDummyElement ? undefined : fromElement, fromStep!, {
config,
state,
driver: getCurrentDriver(),
driver: ctx.getDriver(),
});
}
@@ -91,22 +90,22 @@ function transferHighlight(toElement: Element, toStep: DriveStep) {
highlightStartedHook(isToDummyElement ? undefined : toElement, toStep, {
config,
state,
driver: getCurrentDriver(),
driver: ctx.getDriver(),
});
}
const hasDelayedPopover = !isFirstHighlight && isAnimatedTour;
let isPopoverRendered = false;
hidePopover();
hidePopover(ctx);
setState("previousStep", fromStep);
setState("previousElement", fromElement);
setState("activeStep", toStep);
setState("activeElement", toElement);
ctx.setState("previousStep", fromStep);
ctx.setState("previousElement", fromElement);
ctx.setState("activeStep", toStep);
ctx.setState("activeElement", toElement);
const animate = () => {
const transitionCallback = getState("__transitionCallback");
const transitionCallback = ctx.getState("__transitionCallback");
// This makes sure that the repeated calls to transferHighlight
// don't interfere with each other. Only the last call will be
@@ -120,40 +119,40 @@ function transferHighlight(toElement: Element, toStep: DriveStep) {
const isHalfwayThrough = timeRemaining <= duration / 2;
if (toStep.popover && isHalfwayThrough && !isPopoverRendered && hasDelayedPopover) {
renderPopover(toElement, toStep);
renderPopover(ctx, toElement, toStep);
isPopoverRendered = true;
}
if (getConfig("animate") && elapsed < duration) {
transitionStage(elapsed, duration, fromElement, toElement);
if (ctx.getConfig("animate") && elapsed < duration) {
transitionStage(ctx, elapsed, duration, fromElement, toElement);
} else {
trackActiveElement(toElement);
trackActiveElement(ctx, toElement);
if (highlightedHook) {
highlightedHook(isToDummyElement ? undefined : toElement, toStep, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
}
setState("__transitionCallback", undefined);
setState("__previousStep", fromStep);
setState("__previousElement", fromElement);
setState("__activeStep", toStep);
setState("__activeElement", toElement);
ctx.setState("__transitionCallback", undefined);
ctx.setState("__previousStep", fromStep);
ctx.setState("__previousElement", fromElement);
ctx.setState("__activeStep", toStep);
ctx.setState("__activeElement", toElement);
}
window.requestAnimationFrame(animate);
};
setState("__transitionCallback", animate);
ctx.setState("__transitionCallback", animate);
window.requestAnimationFrame(animate);
bringInView(toElement);
bringInView(ctx, toElement);
if (!hasDelayedPopover && toStep.popover) {
renderPopover(toElement, toStep);
renderPopover(ctx, toElement, toStep);
}
document.querySelectorAll(".driver-active-element-parent").forEach(element => {
@@ -165,7 +164,7 @@ function transferHighlight(toElement: Element, toStep: DriveStep) {
fromElement.removeAttribute("aria-expanded");
fromElement.removeAttribute("aria-controls");
const disableActiveInteraction = toStep.disableActiveInteraction ?? getConfig("disableActiveInteraction");
const disableActiveInteraction = toStep.disableActiveInteraction ?? ctx.getConfig("disableActiveInteraction");
if (disableActiveInteraction) {
toElement.classList.add("driver-no-interaction");
}
+28 -30
View File
@@ -1,8 +1,6 @@
import { easeInOutQuad } from "./utils";
import { destroyDriverClick, onDriverClick } from "./events";
import { emit } from "./emitter";
import { getConfig } from "./config";
import { getState, setState } from "./state";
import { Context } from "./context";
export type StageDefinition = {
x: number;
@@ -13,8 +11,8 @@ export type StageDefinition = {
// This method calculates the animated new position of the
// stage (called for each frame by requestAnimationFrame)
export function transitionStage(elapsed: number, duration: number, from: Element, to: Element) {
let activeStagePosition = getState("__activeStagePosition");
export function transitionStage(ctx: Context, elapsed: number, duration: number, from: Element, to: Element) {
let activeStagePosition = ctx.getState("__activeStagePosition");
const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect();
const toDefinition = to.getBoundingClientRect();
@@ -31,11 +29,11 @@ export function transitionStage(elapsed: number, duration: number, from: Element
height,
};
renderOverlay(activeStagePosition);
setState("__activeStagePosition", activeStagePosition);
renderOverlay(ctx, activeStagePosition);
ctx.setState("__activeStagePosition", activeStagePosition);
}
export function trackActiveElement(element: Element) {
export function trackActiveElement(ctx: Context, element: Element) {
if (!element) {
return;
}
@@ -49,14 +47,14 @@ export function trackActiveElement(element: Element) {
height: definition.height,
};
setState("__activeStagePosition", activeStagePosition);
ctx.setState("__activeStagePosition", activeStagePosition);
renderOverlay(activeStagePosition);
renderOverlay(ctx, activeStagePosition);
}
export function refreshOverlay() {
const activeStagePosition = getState("__activeStagePosition");
const overlaySvg = getState("__overlaySvg");
export function refreshOverlay(ctx: Context) {
const activeStagePosition = ctx.getState("__activeStagePosition");
const overlaySvg = ctx.getState("__overlaySvg");
if (!activeStagePosition) {
return;
@@ -73,8 +71,8 @@ export function refreshOverlay() {
overlaySvg.setAttribute("viewBox", `0 0 ${windowX} ${windowY}`);
}
function mountOverlay(stagePosition: StageDefinition) {
const overlaySvg = createOverlaySvg(stagePosition);
function mountOverlay(ctx: Context, stagePosition: StageDefinition) {
const overlaySvg = createOverlaySvg(ctx, stagePosition);
document.body.appendChild(overlaySvg);
onDriverClick(overlaySvg, e => {
@@ -83,18 +81,18 @@ function mountOverlay(stagePosition: StageDefinition) {
return;
}
emit("overlayClick");
ctx.emit("overlayClick");
});
setState("__overlaySvg", overlaySvg);
ctx.setState("__overlaySvg", overlaySvg);
}
function renderOverlay(stagePosition: StageDefinition) {
const overlaySvg = getState("__overlaySvg");
function renderOverlay(ctx: Context, stagePosition: StageDefinition) {
const overlaySvg = ctx.getState("__overlaySvg");
// TODO: cancel rendering if element is not visible
if (!overlaySvg) {
mountOverlay(stagePosition);
mountOverlay(ctx, stagePosition);
return;
}
@@ -104,10 +102,10 @@ function renderOverlay(stagePosition: StageDefinition) {
throw new Error("no path element found in stage svg");
}
pathElement.setAttribute("d", generateStageSvgPathString(stagePosition));
pathElement.setAttribute("d", generateStageSvgPathString(ctx, stagePosition));
}
function createOverlaySvg(stage: StageDefinition): SVGSVGElement {
function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
const windowX = window.innerWidth;
const windowY = window.innerHeight;
@@ -133,10 +131,10 @@ function createOverlaySvg(stage: StageDefinition): SVGSVGElement {
const stagePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
stagePath.setAttribute("d", generateStageSvgPathString(stage));
stagePath.setAttribute("d", generateStageSvgPathString(ctx, stage));
stagePath.style.fill = getConfig("overlayColor") || "rgb(0,0,0)";
stagePath.style.opacity = `${getConfig("overlayOpacity")}`;
stagePath.style.fill = ctx.getConfig("overlayColor") || "rgb(0,0,0)";
stagePath.style.opacity = `${ctx.getConfig("overlayOpacity")}`;
stagePath.style.pointerEvents = "auto";
stagePath.style.cursor = "auto";
@@ -145,12 +143,12 @@ function createOverlaySvg(stage: StageDefinition): SVGSVGElement {
return svg;
}
function generateStageSvgPathString(stage: StageDefinition) {
function generateStageSvgPathString(ctx: Context, stage: StageDefinition) {
const windowX = window.innerWidth;
const windowY = window.innerHeight;
const stagePadding = getConfig("stagePadding") || 0;
const stageRadius = getConfig("stageRadius") || 0;
const stagePadding = ctx.getConfig("stagePadding") || 0;
const stageRadius = ctx.getConfig("stageRadius") || 0;
const stageWidth = stage.width + stagePadding * 2;
const stageHeight = stage.height + stagePadding * 2;
@@ -170,8 +168,8 @@ function generateStageSvgPathString(stage: StageDefinition) {
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() {
const overlaySvg = getState("__overlaySvg");
export function destroyOverlay(ctx: Context) {
const overlaySvg = ctx.getState("__overlaySvg");
if (overlaySvg) {
destroyDriverClick(overlaySvg);
overlaySvg.remove();
+48 -50
View File
@@ -1,9 +1,9 @@
import { Config, DriverHook, getConfig, getCurrentDriver } from "./config";
import { Config, DriverHook } from "./config";
import { Context } from "./context";
import { Driver, DriveStep } from "./driver";
import { emit } from "./emitter";
import { destroyDriverClick, onDriverClick } from "./events";
import { repositionPopover } from "./position";
import { getState, setState, State } from "./state";
import { State } from "./state";
import { bringInView, getFocusableElements } from "./utils";
export type Side = "top" | "right" | "bottom" | "left";
@@ -51,8 +51,8 @@ export type PopoverDOM = {
footerButtons: HTMLElement;
};
export function hidePopover() {
const popover = getState("popover");
export function hidePopover(ctx: Context) {
const popover = ctx.getState("popover");
if (!popover) {
return;
}
@@ -60,8 +60,8 @@ export function hidePopover() {
popover.wrapper.style.display = "none";
}
export function renderPopover(element: Element, step: DriveStep) {
let popover = getState("popover");
export function renderPopover(ctx: Context, element: Element, step: DriveStep) {
let popover = ctx.getState("popover");
if (popover) {
destroyDriverClick(popover.wrapper);
popover.wrapper.remove();
@@ -77,17 +77,17 @@ export function renderPopover(element: Element, step: DriveStep) {
disableButtons,
showProgress,
nextBtnText = getConfig("nextBtnText") || "Next",
prevBtnText = getConfig("prevBtnText") || "Previous",
progressText = getConfig("progressText") || "{current} of {total}",
nextBtnText = ctx.getConfig("nextBtnText") || "Next",
prevBtnText = ctx.getConfig("prevBtnText") || "Previous",
progressText = ctx.getConfig("progressText") || "{current} of {total}",
} = step.popover || {};
popover.nextButton.innerHTML = nextBtnText;
popover.previousButton.innerHTML = prevBtnText;
popover.progress.innerHTML = progressText;
const steps = getConfig("steps") || [];
const activeIndex = getState("activeIndex");
const steps = ctx.getConfig("steps") || [];
const activeIndex = ctx.getState("activeIndex");
const isDoneStep = activeIndex !== undefined && activeIndex === steps.length - 1;
if (isDoneStep) {
popover.nextButton.classList.add("driver-popover-done-btn");
@@ -107,8 +107,8 @@ export function renderPopover(element: Element, step: DriveStep) {
popover.description.style.display = "none";
}
const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
const showProgressConfig = showProgress || getConfig("showProgress") || false;
const showButtonsConfig: AllowedButtons[] = showButtons || ctx.getConfig("showButtons")!;
const showProgressConfig = showProgress || ctx.getConfig("showProgress") || false;
const showFooter =
showButtonsConfig?.includes("next") || showButtonsConfig?.includes("previous") || showProgressConfig;
@@ -124,7 +124,7 @@ export function renderPopover(element: Element, step: DriveStep) {
popover.footer.style.display = "none";
}
const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig("disableButtons")! || [];
const disabledButtonsConfig: AllowedButtons[] = disableButtons || ctx.getConfig("disableButtons")! || [];
if (disabledButtonsConfig?.includes("next")) {
popover.nextButton.disabled = true;
popover.nextButton.classList.add("driver-popover-btn-disabled");
@@ -158,7 +158,7 @@ export function renderPopover(element: Element, step: DriveStep) {
popoverArrow.className = "driver-popover-arrow";
// Reset any custom classes on the popover
const customPopoverClass = step.popover?.popoverClass || getConfig("popoverClass") || "";
const customPopoverClass = step.popover?.popoverClass || ctx.getConfig("popoverClass") || "";
popoverWrapper.className = `driver-popover ${customPopoverClass}`.trim();
// Handles the popover button clicks
@@ -167,19 +167,19 @@ export function renderPopover(element: Element, step: DriveStep) {
e => {
const target = e.target as HTMLElement;
const onNextClick = step.popover?.onNextClick || getConfig("onNextClick");
const onPrevClick = step.popover?.onPrevClick || getConfig("onPrevClick");
const onCloseClick = step.popover?.onCloseClick || getConfig("onCloseClick");
const onDoneClick = step.popover?.onDoneClick || getConfig("onDoneClick");
const onNextClick = step.popover?.onNextClick || ctx.getConfig("onNextClick");
const onPrevClick = step.popover?.onPrevClick || ctx.getConfig("onPrevClick");
const onCloseClick = step.popover?.onCloseClick || ctx.getConfig("onCloseClick");
const onDoneClick = step.popover?.onDoneClick || ctx.getConfig("onDoneClick");
if (!!target.closest(".driver-popover-next-btn")) {
// On the final step the next button acts as the done button, so a
// dedicated onDoneClick takes precedence over onNextClick when provided.
if (isDoneStep && onDoneClick) {
return onDoneClick(element, step, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
}
@@ -187,36 +187,36 @@ export function renderPopover(element: Element, step: DriveStep) {
// otherwise, emit the event.
if (onNextClick) {
return onNextClick(element, step, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
} else {
return emit("nextClick");
return ctx.emit("nextClick");
}
}
if (!!target.closest(".driver-popover-prev-btn")) {
if (onPrevClick) {
return onPrevClick(element, step, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
} else {
return emit("prevClick");
return ctx.emit("prevClick");
}
}
if (!!target.closest(".driver-popover-close-btn")) {
if (onCloseClick) {
return onCloseClick(element, step, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
} else {
return emit("closeClick");
return ctx.emit("closeClick");
}
}
@@ -230,26 +230,24 @@ export function renderPopover(element: Element, step: DriveStep) {
return false;
}
return !!target.closest(
".driver-popover-prev-btn, .driver-popover-next-btn, .driver-popover-close-btn"
);
return !!target.closest(".driver-popover-prev-btn, .driver-popover-next-btn, .driver-popover-close-btn");
}
);
setState("popover", popover);
ctx.setState("popover", popover);
const onPopoverRender = step.popover?.onPopoverRender || getConfig("onPopoverRender");
const onPopoverRender = step.popover?.onPopoverRender || ctx.getConfig("onPopoverRender");
if (onPopoverRender) {
onPopoverRender(popover, {
config: getConfig(),
state: getState(),
driver: getCurrentDriver(),
config: ctx.getConfig(),
state: ctx.getState(),
driver: ctx.getDriver(),
});
}
repositionPopover(element, step);
repositionOnImagesLoad(popover, element, step);
bringInView(popoverWrapper);
repositionPopover(ctx, element, step);
repositionOnImagesLoad(ctx, popover, element, step);
bringInView(ctx, popoverWrapper);
// Focus on the first focusable element in active element or popover
const isToDummyElement = element.classList.contains("driver-dummy-element");
@@ -259,7 +257,7 @@ export function renderPopover(element: Element, step: DriveStep) {
}
}
function repositionOnImagesLoad(popover: PopoverDOM, element: Element, step: DriveStep) {
function repositionOnImagesLoad(ctx: Context, popover: PopoverDOM, element: Element, step: DriveStep) {
const images = popover.wrapper.querySelectorAll("img");
images.forEach(image => {
@@ -267,7 +265,7 @@ function repositionOnImagesLoad(popover: PopoverDOM, element: Element, step: Dri
return;
}
const reposition = () => repositionPopover(element, step);
const reposition = () => repositionPopover(ctx, element, step);
image.addEventListener("load", reposition, { once: true });
image.addEventListener("error", reposition, { once: true });
@@ -344,8 +342,8 @@ function createPopover(): PopoverDOM {
};
}
export function destroyPopover() {
const popover = getState("popover");
export function destroyPopover(ctx: Context) {
const popover = ctx.getState("popover");
if (!popover) {
return;
}
+12 -13
View File
@@ -1,7 +1,6 @@
import { getConfig } from "./config";
import { Context } from "./context";
import { DriveStep } from "./driver";
import type { Alignment, Side } from "./popover";
import { getState } from "./state";
// Internal placement. Beyond the public sides it includes "over", used when
// there is no target element (or the popover is intentionally centered like a
@@ -15,16 +14,16 @@ type PopoverDimensions = {
realHeight: number;
};
function getPopoverDimensions(): PopoverDimensions | undefined {
const popover = getState("popover");
function getPopoverDimensions(ctx: Context): PopoverDimensions | undefined {
const popover = ctx.getState("popover");
if (!popover?.wrapper) {
return;
}
const boundingClientRect = popover.wrapper.getBoundingClientRect();
const stagePadding = getConfig("stagePadding") || 0;
const popoverOffset = getConfig("popoverOffset") || 0;
const stagePadding = ctx.getConfig("stagePadding") || 0;
const popoverOffset = ctx.getConfig("popoverOffset") || 0;
return {
width: boundingClientRect.width + stagePadding + popoverOffset,
@@ -124,8 +123,8 @@ function calculateLeftForTopBottom(
return 0;
}
export function repositionPopover(element: Element, step: DriveStep) {
const popover = getState("popover");
export function repositionPopover(ctx: Context, element: Element, step: DriveStep) {
const popover = ctx.getState("popover");
if (!popover) {
return;
}
@@ -135,9 +134,9 @@ export function repositionPopover(element: Element, step: DriveStep) {
// Configure the popover positioning
const requiredAlignment: Alignment = align;
const requiredSide: Placement = element.id === "driver-dummy-element" ? "over" : side;
const popoverPadding = getConfig("stagePadding") || 0;
const popoverPadding = ctx.getConfig("stagePadding") || 0;
const popoverDimensions = getPopoverDimensions()!;
const popoverDimensions = getPopoverDimensions(ctx)!;
const popoverArrowDimensions = popover.arrow.getBoundingClientRect();
const elementDimensions = element.getBoundingClientRect();
@@ -261,7 +260,7 @@ export function repositionPopover(element: Element, step: DriveStep) {
// Point the arrow at the element. When no side is optimal the popover is
// detached from the element (centered/pinned to the bottom of the screen),
// so there is nothing sensible to point at and the arrow is hidden.
renderPopoverArrow(noneOptimal ? "over" : popoverRenderedSide, requiredAlignment, element);
renderPopoverArrow(ctx, noneOptimal ? "over" : popoverRenderedSide, requiredAlignment, element);
[...popover.wrapper.classList]
.filter(className => className.startsWith("driver-popover-side-") || className.startsWith("driver-popover-align-"))
@@ -368,8 +367,8 @@ export function resolveArrowSide(
return element.right <= popover.left ? "right" : "left";
}
function renderPopoverArrow(side: Placement, alignment: Alignment, element: Element) {
const popover = getState("popover");
function renderPopoverArrow(ctx: Context, side: Placement, alignment: Alignment, element: Element) {
const popover = ctx.getState("popover");
if (!popover) {
return;
}
+36 -11
View File
@@ -26,20 +26,45 @@ export type State = {
__transitionCallback?: () => void;
__activeStagePosition?: StageDefinition;
__overlaySvg?: SVGSVGElement;
// Per-instance window listeners, stashed so they can be detached on destroy.
__events?: {
onKeyup: (e: KeyboardEvent) => void;
onKeydown: (e: KeyboardEvent) => void;
onResize: () => void;
onScroll: () => void;
};
};
let currentState: State = {};
export function setState<K extends keyof State>(key: K, value: State[K]) {
currentState[key] = value;
export interface GetState {
(): State;
<K extends keyof State>(key: K): State[K];
}
export function getState(): State;
export function getState<K extends keyof State>(key: K): State[K];
export function getState<K extends keyof State>(key?: K) {
return key ? currentState[key] : currentState;
}
export type SetState = <K extends keyof State>(key: K, value: State[K]) => void;
export function resetState() {
currentState = {};
export type StateStore = {
getState: GetState;
setState: SetState;
resetState: () => void;
};
// Each driver instance owns its own state store, so two drivers on the same
// page never read or clobber each other's active step, popover or overlay.
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 };
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { getConfig } from "./config";
import type { Context } from "./context";
export function isScrollable(element: Element) {
const style = window.getComputedStyle(element);
@@ -30,12 +30,12 @@ export function getFocusableElements(parentEls: Element[] | HTMLElement[]) {
});
}
export function bringInView(element: Element) {
export function bringInView(ctx: Context, element: Element) {
if (!element || isElementInView(element)) {
return;
}
const shouldSmoothScroll = getConfig("smoothScroll");
const shouldSmoothScroll = ctx.getConfig("smoothScroll");
const isTallerThanViewport = (element as HTMLElement).offsetHeight > window.innerHeight;
+12 -18
View File
@@ -9,11 +9,9 @@ import { nextFrame, popoverTitle, SAMPLE_STEPS, useDriverHarness } from "./utils
// These are the real backward-compatibility guarantees; they should stay green
// through the refactor.
//
// Part 2 characterises the CURRENT, shared-global behaviour that issue #571 is
// about. These assertions intentionally encode today's (buggy) singleton
// semantics so the baseline is green. When the refactor lands, these are the
// tests we flip — they become the proof that the fix changed what it should and
// nothing else.
// Part 2 locks in the per-instance isolation that issue #571 asked for: config,
// steps, active state and hook identity are now scoped to each driver instance
// instead of shared through module-level globals.
useDriverHarness();
@@ -164,15 +162,11 @@ describe("backward compatibility — single instance public API", () => {
});
});
describe("per-instance isolation (#571) — desired behaviour, currently failing", () => {
// These are forward specs for the per-instance refactor, written as the
// behaviour we WANT. They use `it.fails`, so today they pass precisely because
// the assertion fails against the current shared-global implementation. When
// the refactor lands and isolation works, each spec will start passing, which
// makes `it.fails` fail — that's the signal to drop `.fails` and lock the
// behaviour in as a regular regression test.
describe("per-instance isolation (#571)", () => {
// Each driver instance owns its own config, steps and state, so creating a
// second driver never disturbs the first.
it.fails("gives each instance its own config", () => {
it("gives each instance its own config", () => {
const d1 = track(driver({ animate: false, stagePadding: 1 }));
const d2 = track(driver({ animate: false, stagePadding: 2 }));
@@ -180,7 +174,7 @@ describe("per-instance isolation (#571) — desired behaviour, currently failing
expect(d2.getConfig().stagePadding).toBe(2);
});
it.fails("drives each instance's own steps", () => {
it("drives each instance's own steps", () => {
const d1 = track(driver({ animate: false, steps: [{ element: "#intro", popover: { title: "D1 Step" } }] }));
track(driver({ animate: false, steps: [{ element: "#card-1", popover: { title: "D2 Step" } }] }));
@@ -189,7 +183,7 @@ describe("per-instance isolation (#571) — desired behaviour, currently failing
expect(popoverTitle()).toBe("D1 Step");
});
it.fails("keeps each instance's active state independent", () => {
it("keeps each instance's active state independent", () => {
const d1 = track(driver({ animate: false, steps: SAMPLE_STEPS }));
const d2 = track(driver({ animate: false, steps: SAMPLE_STEPS }));
@@ -200,7 +194,7 @@ describe("per-instance isolation (#571) — desired behaviour, currently failing
expect(d2.getActiveIndex()).toBeUndefined();
});
it.fails("keeps setConfig changes scoped to one instance", () => {
it("keeps setConfig changes scoped to one instance", () => {
const d1 = track(driver({ animate: false, stagePadding: 1 }));
const d2 = track(driver({ animate: false, stagePadding: 2 }));
@@ -210,7 +204,7 @@ describe("per-instance isolation (#571) — desired behaviour, currently failing
expect(d2.getConfig().stagePadding).toBe(2);
});
it.fails("keeps setSteps scoped to one instance", () => {
it("keeps setSteps scoped to one instance", () => {
const d1 = track(driver({ animate: false }));
const d2 = track(driver({ animate: false }));
@@ -222,7 +216,7 @@ describe("per-instance isolation (#571) — desired behaviour, currently failing
expect(popoverTitle()).toBe("D1 only");
});
it.fails("passes each instance's own driver into its hooks", async () => {
it("passes each instance's own driver into its hooks", async () => {
const seen: Driver[] = [];
const d1 = track(
driver({