mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-10-31 03:52:13 +08:00
Scope config, state and emitter to each driver instance
This commit is contained in:
+40
-33
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user