From f28d08bfb77af0eb23d98cdeea174b1160ebff68 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Mon, 6 Jul 2026 16:19:08 +0100 Subject: [PATCH] Scope config, state and emitter to each driver instance --- src/config.ts | 73 ++++++----- src/context.ts | 52 ++++++++ src/driver.ts | 234 +++++++++++++++++----------------- src/emitter.ts | 30 +++-- src/events.ts | 71 +++++++---- src/highlight.ts | 87 +++++++------ src/overlay.ts | 58 ++++----- src/popover.ts | 98 +++++++------- src/position.ts | 25 ++-- src/state.ts | 47 +++++-- src/utils.ts | 6 +- tests/backward-compat.test.ts | 30 ++--- 12 files changed, 458 insertions(+), 353 deletions(-) create mode 100644 src/context.ts diff --git a/src/config.ts b/src/config.ts index 7081e46..cae28ad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; + (key: K): Config[K]; } -export function getConfig(): Config; -export function getConfig(key: K): Config[K]; -export function getConfig(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 = ((key?: K) => { + return key ? currentConfig[key] : currentConfig; + }) as GetConfig; + + configure(); + + return { getConfig, configure }; } diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..2f7dbb3 --- /dev/null +++ b/src/context.ts @@ -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; + }, + }; +} diff --git a/src/driver.ts b/src/driver.ts index 39bfbb9..c0750ea 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -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; } diff --git a/src/emitter.ts b/src/emitter.ts index 7964dfd..069b7bb 100644 --- a/src/emitter.ts +++ b/src/emitter.ts @@ -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 }; } diff --git a/src/events.ts b/src/events.ts index 0d11707..6858d60 100644 --- a/src/events.ts +++ b/src/events.ts @@ -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); } diff --git a/src/highlight.ts b/src/highlight.ts index 8381316..e801dc9 100644 --- a/src/highlight.ts +++ b/src/highlight.ts @@ -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"); } diff --git a/src/overlay.ts b/src/overlay.ts index b79dbf8..25fd0d5 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -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(); diff --git a/src/popover.ts b/src/popover.ts index 6eb47bf..8ff2755 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -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; } diff --git a/src/position.ts b/src/position.ts index a96d534..03b9049 100644 --- a/src/position.ts +++ b/src/position.ts @@ -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; } diff --git a/src/state.ts b/src/state.ts index 2e69a90..0757b5d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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(key: K, value: State[K]) { - currentState[key] = value; +export interface GetState { + (): State; + (key: K): State[K]; } -export function getState(): State; -export function getState(key: K): State[K]; -export function getState(key?: K) { - return key ? currentState[key] : currentState; -} +export type SetState = (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 = ((key?: K) => { + return key ? currentState[key] : currentState; + }) as GetState; + + const setState: SetState = (key, value) => { + currentState[key] = value; + }; + + function resetState() { + currentState = {}; + } + + return { getState, setState, resetState }; } diff --git a/src/utils.ts b/src/utils.ts index 7a06267..5d675ab 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; diff --git a/tests/backward-compat.test.ts b/tests/backward-compat.test.ts index 505683f..1aba710 100644 --- a/tests/backward-compat.test.ts +++ b/tests/backward-compat.test.ts @@ -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({