Please note that when you configure these callbacks, the default functionality of the buttons will be disabled. You will have to implement the functionality yourself.
diff --git a/docs/src/content/guides/configuration.mdx b/docs/src/content/guides/configuration.mdx
index 2826a3d7..722bfd58 100644
--- a/docs/src/content/guides/configuration.mdx
+++ b/docs/src/content/guides/configuration.mdx
@@ -71,6 +71,7 @@ type Config = {
nextBtnText?: string;
prevBtnText?: string;
doneBtnText?: string;
+ skipBtnText?: string;
// Called after the popover is rendered.
// PopoverDOM is an object with references to
@@ -111,6 +112,7 @@ type Config = {
onNextClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void;
onPrevClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void;
onCloseClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void;
+ onSkipClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void;
};
```
@@ -142,17 +144,18 @@ type Popover = {
// are no buttons by default. When showing
// a tour, the default buttons are "next",
// "previous" and "close".
- showButtons?: ("next" | "previous" | "close")[];
+ showButtons?: ("next" | "previous" | "close" | "skip")[];
// An array of buttons to disable. This is
// useful when you want to show some of the
// buttons, but disable some of them.
- disableButtons?: ("next" | "previous" | "close")[];
+ disableButtons?: ("next" | "previous" | "close" | "skip")[];
// Text to show in the buttons. `doneBtnText`
// is used on the last step of a tour.
nextBtnText?: string;
prevBtnText?: string;
doneBtnText?: string;
+ skipBtnText?: string;
// Whether to show the progress text in popover.
showProgress?: boolean;
@@ -186,6 +189,7 @@ type Popover = {
onNextClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void
onPrevClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void
onCloseClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void
+ onSkipClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void
}
```
diff --git a/docs/src/content/guides/theming.mdx b/docs/src/content/guides/theming.mdx
index 17b1f80d..814377f7 100644
--- a/docs/src/content/guides/theming.mdx
+++ b/docs/src/content/guides/theming.mdx
@@ -49,6 +49,7 @@ Here is the list of classes applied to the popover which you can use in conjunct
/* Footer of the popover displaying progress and navigation buttons */
.driver-popover-footer {}
.driver-popover-progress-text {}
+.driver-popover-skip-btn {}
.driver-popover-prev-btn {}
.driver-popover-next-btn {}
```
@@ -69,6 +70,7 @@ type PopoverDOM = {
progress: HTMLElement;
previousButton: HTMLElement;
nextButton: HTMLElement;
+ skipButton: HTMLElement;
closeButton: HTMLElement;
footerButtons: HTMLElement;
};
@@ -99,4 +101,4 @@ Whenever an element is highlighted, the following classes are applied to it.
```css
.driver-active-element {}
-```
\ No newline at end of file
+```
diff --git a/index.html b/index.html
index b1891c8e..36a7ce7d 100644
--- a/index.html
+++ b/index.html
@@ -212,6 +212,7 @@ Tour Feature
Examples below show the tour usage of driver.js.
Animated Tour
+ Skippable Tour
Non-Animated Tour
Asynchronous Tour
Confirm on Exit
@@ -638,6 +639,22 @@
Usage and Demo
driverObj.drive();
});
+ document.getElementById("skippable-tour").addEventListener("click", () => {
+ const driverObj = driver({
+ showProgress: true,
+ showButtons: ["skip", "previous", "next"],
+ skipBtnText: "Skip this tour",
+ progressText: "{{current}} / {{total}}",
+ steps: basicTourSteps,
+ onSkipClick: () => {
+ console.log("Tour skipped");
+ driverObj.destroy();
+ },
+ });
+
+ driverObj.drive();
+ });
+
document.getElementById("no-buttons").addEventListener("click", () => {
const driverObj = driver({});
diff --git a/src/config.ts b/src/config.ts
index c4a8e6cd..1053f18e 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -36,9 +36,10 @@ export type Config = {
nextBtnText?: string;
prevBtnText?: string;
doneBtnText?: string;
+ skipBtnText?: 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;
@@ -51,6 +52,7 @@ export type Config = {
onNextClick?: DriverHook;
onPrevClick?: DriverHook;
onCloseClick?: DriverHook;
+ onSkipClick?: DriverHook;
};
let currentConfig: Config = {};
diff --git a/src/driver.ts b/src/driver.ts
index 1bbe529e..4797d7ff 100644
--- a/src/driver.ts
+++ b/src/driver.ts
@@ -51,6 +51,14 @@ export function driver(options: Config = {}): Driver {
destroy();
}
+ function handleSkip() {
+ if (!getConfig("allowClose")) {
+ return;
+ }
+
+ destroy();
+ }
+
function handleOverlayClick() {
const overlayClickBehavior = getConfig("overlayClickBehavior");
@@ -184,6 +192,7 @@ export function driver(options: Config = {}): Driver {
listen("overlayClick", handleOverlayClick);
listen("escapePress", handleClose);
+ listen("skipClick", handleSkip);
listen("arrowLeftPress", handleArrowLeft);
listen("arrowRightPress", handleArrowRight);
}
@@ -222,6 +231,7 @@ export function driver(options: Config = {}): Driver {
const configuredButtons = currentStep.popover?.showButtons || getConfig("showButtons");
const calculatedButtons: AllowedButtons[] = [
+ ...(allowsClosing ? ["skip" as AllowedButtons] : []),
"next",
"previous",
...(allowsClosing ? ["close" as AllowedButtons] : []),
@@ -232,13 +242,16 @@ export function driver(options: Config = {}): Driver {
const onNextClick = currentStep.popover?.onNextClick || getConfig("onNextClick");
const onPrevClick = currentStep.popover?.onPrevClick || getConfig("onPrevClick");
const onCloseClick = currentStep.popover?.onCloseClick || getConfig("onCloseClick");
+ const onSkipClick = currentStep.popover?.onSkipClick || getConfig("onSkipClick");
+ const disabledButtons = currentStep.popover?.disableButtons || getConfig("disableButtons") || [];
highlight({
...currentStep,
popover: {
+ ...(currentStep?.popover || {}),
showButtons: calculatedButtons,
- nextBtnText: !hasNextStep ? doneBtnText : undefined,
- disableButtons: [...(!hasPreviousStep ? ["previous" as AllowedButtons] : [])],
+ nextBtnText: !hasNextStep ? doneBtnText : currentStep.popover?.nextBtnText,
+ disableButtons: [...disabledButtons, ...(!hasPreviousStep ? ["previous" as AllowedButtons] : [])],
showProgress: showProgress,
progressText: progressTextReplaced,
onNextClick: onNextClick
@@ -260,7 +273,11 @@ export function driver(options: Config = {}): Driver {
: () => {
destroy();
},
- ...(currentStep?.popover || {}),
+ onSkipClick: onSkipClick
+ ? onSkipClick
+ : () => {
+ destroy();
+ },
},
});
}
diff --git a/src/emitter.ts b/src/emitter.ts
index 7964dfd7..5cf8795b 100644
--- a/src/emitter.ts
+++ b/src/emitter.ts
@@ -4,6 +4,7 @@ type allowedEvents =
| "nextClick"
| "prevClick"
| "closeClick"
+ | "skipClick"
| "arrowRightPress"
| "arrowLeftPress";
diff --git a/src/popover.ts b/src/popover.ts
index 582d5005..3b9e4d2b 100644
--- a/src/popover.ts
+++ b/src/popover.ts
@@ -7,7 +7,7 @@ import { bringInView, getFocusableElements } from "./utils";
export type Side = "top" | "right" | "bottom" | "left" | "over";
export type Alignment = "start" | "center" | "end";
-export type AllowedButtons = "next" | "previous" | "close";
+export type AllowedButtons = "next" | "previous" | "close" | "skip";
export type Popover = {
title?: string;
@@ -26,6 +26,7 @@ export type Popover = {
doneBtnText?: string;
nextBtnText?: string;
prevBtnText?: string;
+ skipBtnText?: string;
// Called after the popover is rendered
onPopoverRender?: (popover: PopoverDOM, opts: { config: Config; state: State; driver: Driver }) => void;
@@ -34,6 +35,7 @@ export type Popover = {
onNextClick?: DriverHook;
onPrevClick?: DriverHook;
onCloseClick?: DriverHook;
+ onSkipClick?: DriverHook;
};
export type PopoverDOM = {
@@ -45,6 +47,7 @@ export type PopoverDOM = {
progress: HTMLElement;
previousButton: HTMLButtonElement;
nextButton: HTMLButtonElement;
+ skipButton: HTMLButtonElement;
closeButton: HTMLButtonElement;
footerButtons: HTMLElement;
};
@@ -76,11 +79,13 @@ export function renderPopover(element: Element, step: DriveStep) {
nextBtnText = getConfig("nextBtnText") || "Next →",
prevBtnText = getConfig("prevBtnText") || "← Previous",
+ skipBtnText = getConfig("skipBtnText") || "Skip tour",
progressText = getConfig("progressText") || "{current} of {total}",
} = step.popover || {};
popover.nextButton.innerHTML = nextBtnText;
popover.previousButton.innerHTML = prevBtnText;
+ popover.skipButton.innerHTML = skipBtnText;
popover.progress.innerHTML = progressText;
if (title) {
@@ -100,7 +105,10 @@ export function renderPopover(element: Element, step: DriveStep) {
const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
const showProgressConfig = showProgress || getConfig("showProgress") || false;
const showFooter =
- showButtonsConfig?.includes("next") || showButtonsConfig?.includes("previous") || showProgressConfig;
+ showButtonsConfig?.includes("skip") ||
+ showButtonsConfig?.includes("next") ||
+ showButtonsConfig?.includes("previous") ||
+ showProgressConfig;
popover.closeButton.style.display = showButtonsConfig.includes("close") ? "block" : "none";
@@ -108,6 +116,7 @@ export function renderPopover(element: Element, step: DriveStep) {
popover.footer.style.display = "flex";
popover.progress.style.display = showProgressConfig ? "block" : "none";
+ popover.skipButton.style.display = showButtonsConfig.includes("skip") ? "block" : "none";
popover.nextButton.style.display = showButtonsConfig.includes("next") ? "block" : "none";
popover.previousButton.style.display = showButtonsConfig.includes("previous") ? "block" : "none";
} else {
@@ -130,6 +139,11 @@ export function renderPopover(element: Element, step: DriveStep) {
popover.closeButton.classList.add("driver-popover-btn-disabled");
}
+ if (disabledButtonsConfig?.includes("skip")) {
+ popover.skipButton.disabled = true;
+ popover.skipButton.classList.add("driver-popover-btn-disabled");
+ }
+
// Reset the popover position
const popoverWrapper = popover.wrapper;
popoverWrapper.style.display = "block";
@@ -160,6 +174,7 @@ export function renderPopover(element: Element, step: DriveStep) {
const onNextClick = step.popover?.onNextClick || getConfig("onNextClick");
const onPrevClick = step.popover?.onPrevClick || getConfig("onPrevClick");
const onCloseClick = step.popover?.onCloseClick || getConfig("onCloseClick");
+ const onSkipClick = step.popover?.onSkipClick || getConfig("onSkipClick");
if (!!target.closest(".driver-popover-next-btn")) {
// If the user has provided a custom callback, call it
@@ -199,6 +214,18 @@ export function renderPopover(element: Element, step: DriveStep) {
}
}
+ if (!!target.closest(".driver-popover-skip-btn")) {
+ if (onSkipClick) {
+ return onSkipClick(element, step, {
+ config: getConfig(),
+ state: getState(),
+ driver: getCurrentDriver(),
+ });
+ } else {
+ return emit("skipClick");
+ }
+ }
+
return undefined;
},
target => {
@@ -663,11 +690,17 @@ function createPopover(): PopoverDOM {
previousButton.classList.add("driver-popover-prev-btn");
previousButton.innerHTML = "← Previous";
+ const skipButton = document.createElement("button");
+ skipButton.type = "button";
+ skipButton.classList.add("driver-popover-skip-btn");
+ skipButton.innerHTML = "Skip tour";
+
const nextButton = document.createElement("button");
nextButton.type = "button";
nextButton.classList.add("driver-popover-next-btn");
nextButton.innerHTML = "Next →";
+ footerButtons.appendChild(skipButton);
footerButtons.appendChild(previousButton);
footerButtons.appendChild(nextButton);
footer.appendChild(progress);
@@ -687,6 +720,7 @@ function createPopover(): PopoverDOM {
footer,
previousButton,
nextButton,
+ skipButton,
closeButton,
footerButtons,
progress,
diff --git a/tests/skip-button.test.ts b/tests/skip-button.test.ts
new file mode 100644
index 00000000..3801cf76
--- /dev/null
+++ b/tests/skip-button.test.ts
@@ -0,0 +1,321 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { driver, Driver } from "../src/driver";
+
+type Listener = (event: any) => void;
+
+class TestClassList {
+ private classes = new Set();
+
+ constructor(private element: TestElement) {}
+
+ add(...classes: string[]) {
+ classes.forEach(className => this.classes.add(className));
+ this.sync();
+ }
+
+ remove(...classes: string[]) {
+ classes.forEach(className => this.classes.delete(className));
+ this.sync();
+ }
+
+ contains(className: string) {
+ return this.classes.has(className);
+ }
+
+ set(value: string) {
+ this.classes = new Set(value.split(" ").filter(Boolean));
+ this.sync();
+ }
+
+ toString() {
+ return Array.from(this.classes).join(" ");
+ }
+
+ private sync() {
+ this.element.className = this.toString();
+ }
+}
+
+class TestElement {
+ attributes: Record = {};
+ children: TestElement[] = [];
+ classList: TestClassList;
+ className = "";
+ disabled = false;
+ id = "";
+ innerHTML = "";
+ innerText = "";
+ offsetHeight = 10;
+ offsetWidth = 10;
+ parentElement?: TestElement;
+ scrollHeight = 10;
+ clientHeight = 10;
+ style: Record = {};
+ type = "";
+
+ constructor(public tagName: string) {
+ this.tagName = tagName.toUpperCase();
+ this.classList = new TestClassList(this);
+ }
+
+ appendChild(child: TestElement) {
+ child.parentElement = this;
+ this.children.push(child);
+ return child;
+ }
+
+ removeChild(child: TestElement) {
+ this.children = this.children.filter(currentChild => currentChild !== child);
+ child.parentElement = undefined;
+ return child;
+ }
+
+ remove() {
+ this.parentElement?.removeChild(this);
+ }
+
+ setAttribute(name: string, value: string) {
+ this.attributes[name] = value;
+ }
+
+ removeAttribute(name: string) {
+ delete this.attributes[name];
+ }
+
+ contains(target: TestElement): boolean {
+ return this === target || this.children.some(child => child.contains(target));
+ }
+
+ closest(selector: string) {
+ if (!selector.startsWith(".")) {
+ return undefined;
+ }
+
+ const className = selector.slice(1);
+ let element: TestElement | undefined = this;
+ while (element) {
+ if (element.classList.contains(className)) {
+ return element;
+ }
+
+ element = element.parentElement;
+ }
+
+ return undefined;
+ }
+
+ matches(selector: string) {
+ return selector.includes("button:not([disabled])") && this.tagName === "BUTTON" && !this.disabled;
+ }
+
+ querySelector(selector: string) {
+ return this.querySelectorAll(selector)[0];
+ }
+
+ querySelectorAll(selector: string) {
+ const matches = (element: TestElement) => {
+ if (selector.startsWith(".")) {
+ return element.classList.contains(selector.slice(1));
+ }
+
+ if (selector.startsWith("#")) {
+ return element.id === selector.slice(1);
+ }
+
+ return element.matches(selector);
+ };
+
+ return this.walk().filter(matches);
+ }
+
+ getBoundingClientRect() {
+ return {
+ bottom: 10,
+ height: 10,
+ left: 0,
+ right: 10,
+ top: 0,
+ width: 10,
+ x: 0,
+ y: 0,
+ };
+ }
+
+ getClientRects() {
+ return [this.getBoundingClientRect()];
+ }
+
+ scrollIntoView() {}
+
+ focus() {
+ (globalThis.document as any).activeElement = this;
+ }
+
+ click() {
+ const event = {
+ target: this,
+ preventDefault: vi.fn(),
+ stopImmediatePropagation: vi.fn(),
+ stopPropagation: vi.fn(),
+ };
+
+ (globalThis.document as any).emit("click", event);
+ }
+
+ private walk(): TestElement[] {
+ return this.children.flatMap(child => [child, ...child.walk()]);
+ }
+}
+
+class TestDocument {
+ activeElement?: TestElement;
+ body = new TestElement("body");
+ documentElement = new TestElement("html");
+ private listeners: Record = {};
+
+ createElement(tagName: string) {
+ return new TestElement(tagName);
+ }
+
+ createElementNS(_: string, tagName: string) {
+ return new TestElement(tagName);
+ }
+
+ getElementById(id: string) {
+ return this.body.querySelector(`#${id}`);
+ }
+
+ querySelector(selector: string) {
+ return this.body.querySelector(selector);
+ }
+
+ querySelectorAll(selector: string) {
+ return this.body.querySelectorAll(selector);
+ }
+
+ addEventListener(eventName: string, listener: Listener) {
+ this.listeners[eventName] ||= [];
+ this.listeners[eventName].push(listener);
+ }
+
+ removeEventListener(eventName: string, listener: Listener) {
+ this.listeners[eventName] = (this.listeners[eventName] || []).filter(current => current !== listener);
+ }
+
+ emit(eventName: string, event: any) {
+ (this.listeners[eventName] || []).forEach(listener => listener(event));
+ }
+}
+
+function setupDom() {
+ const document = new TestDocument();
+ const first = document.createElement("button");
+ first.id = "first";
+ const second = document.createElement("button");
+ second.id = "second";
+
+ document.body.appendChild(first);
+ document.body.appendChild(second);
+
+ vi.stubGlobal("document", document);
+ vi.stubGlobal("window", {
+ addEventListener: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ innerHeight: 768,
+ innerWidth: 1024,
+ removeEventListener: vi.fn(),
+ requestAnimationFrame: vi.fn(),
+ });
+ vi.stubGlobal("getComputedStyle", () => ({ pointerEvents: "auto" }));
+}
+
+function setupTour(options: Parameters[0] = {}) {
+ const driverObj = driver({
+ animate: false,
+ steps: [
+ {
+ element: "#first",
+ popover: {
+ title: "First step",
+ },
+ },
+ {
+ element: "#second",
+ popover: {
+ title: "Second step",
+ },
+ },
+ ],
+ ...options,
+ });
+
+ driverObj.drive();
+
+ return driverObj;
+}
+
+function getSkipButton() {
+ return document.querySelector(".driver-popover-skip-btn") as HTMLButtonElement | undefined;
+}
+
+describe("skip button", () => {
+ let driverObj: Driver | undefined;
+
+ beforeEach(() => {
+ setupDom();
+ });
+
+ afterEach(() => {
+ driverObj?.destroy();
+ driverObj = undefined;
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("renders in the footer and destroys the active tour", () => {
+ driverObj = setupTour({
+ showButtons: ["skip", "previous", "next"],
+ });
+
+ const skipButton = getSkipButton();
+
+ expect(skipButton).toBeTruthy();
+ expect(skipButton?.style.display).toBe("block");
+
+ skipButton?.click();
+
+ expect(driverObj.isActive()).toBe(false);
+ });
+
+ it("uses configured skip button text", () => {
+ driverObj = setupTour({
+ showButtons: ["skip", "previous", "next"],
+ skipBtnText: "Skip setup",
+ });
+
+ expect(getSkipButton()?.innerHTML).toBe("Skip setup");
+ });
+
+ it("calls onSkipClick instead of destroying by default", () => {
+ const onSkipClick = vi.fn();
+
+ driverObj = setupTour({
+ showButtons: ["skip", "previous", "next"],
+ onSkipClick,
+ });
+
+ getSkipButton()?.click();
+
+ expect(onSkipClick).toHaveBeenCalledTimes(1);
+ expect(driverObj.isActive()).toBe(true);
+ });
+
+ it("hides the skip button when closing is not allowed", () => {
+ driverObj = setupTour({
+ allowClose: false,
+ showButtons: ["skip", "previous", "next"],
+ });
+
+ expect(getSkipButton()?.style.display).toBe("none");
+ });
+});