From dab5382e1fa8ed8b0aa7f15cb1ab585042377903 Mon Sep 17 00:00:00 2001 From: Amilcar Teixeira Date: Fri, 29 May 2026 19:05:34 -0300 Subject: [PATCH 1/3] feat: add footer skip button --- docs/src/content/guides/buttons.mdx | 53 +++- docs/src/content/guides/configuration.mdx | 8 +- docs/src/content/guides/theming.mdx | 4 +- index.html | 17 ++ src/config.ts | 4 +- src/driver.ts | 23 +- src/emitter.ts | 1 + src/popover.ts | 38 ++- tests/skip-button.test.ts | 321 ++++++++++++++++++++++ 9 files changed, 457 insertions(+), 12 deletions(-) create mode 100644 tests/skip-button.test.ts diff --git a/docs/src/content/guides/buttons.mdx b/docs/src/content/guides/buttons.mdx index aad19b80..d4aa2bf3 100644 --- a/docs/src/content/guides/buttons.mdx +++ b/docs/src/content/guides/buttons.mdx @@ -6,7 +6,7 @@ sort: 9 import { CodeSample } from "../../components/CodeSample.tsx"; -You can use the `showButtons` option to choose which buttons to show in the popover. The default value is `['next', 'previous', 'close']`. +You can use the `showButtons` option to choose which buttons to show in the popover. The default value is `['next', 'previous', 'close']`. You can also show a footer skip button by adding `'skip'`.
> **Note:** When using the `highlight` method to highlight a single element, the only button shown is the `close` @@ -99,6 +99,53 @@ You can use the `showButtons` option to choose which buttons to show in the popo ]} id={"code-sample"} client:load /> + + ```js + import { driver } from "driver.js"; + import "driver.js/dist/driver.css"; + + const driverObj = driver({ + showButtons: [ + 'skip', + 'previous', + 'next' + ], + skipBtnText: 'Skip tour', + steps: [ + // ... + ] + }); + + driverObj.drive(); + ``` + 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.

+ @@ -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"); + }); +}); From bd2dd13404a31a9d70ab4ad0dfb4be4f8f3894f6 Mon Sep 17 00:00:00 2001 From: Amilcar Teixeira Date: Fri, 29 May 2026 19:12:47 -0300 Subject: [PATCH 2/3] docs: add stackblitz share instructions --- STACKBLITZ.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 STACKBLITZ.md diff --git a/STACKBLITZ.md b/STACKBLITZ.md new file mode 100644 index 00000000..ff14db3c --- /dev/null +++ b/STACKBLITZ.md @@ -0,0 +1,31 @@ +## Skippable Tour Branch + +This branch contains an opt-in footer skip button for tours. + +- Adds `showButtons: ["skip", "previous", "next"]` +- Adds `skipBtnText` for configuring the skip label +- Adds `onSkipClick` for custom skip behavior +- Keeps forced tours intact by hiding skip when `allowClose: false` +- Documents the new option and adds a local demo example + +## StackBlitz + +After this branch is pushed to your GitHub fork, open it in StackBlitz with: + +```text +https://stackblitz.com/github//driver.js/tree/feat/footer-skip-button?file=index.html +``` + +For the `milkatx` account: + +```text +https://stackblitz.com/github/milkatx/driver.js/tree/feat/footer-skip-button?file=index.html +``` + +This URL works only after `feat/footer-skip-button` exists on the public fork. + +## Verification + +- `pnpm install --frozen-lockfile` +- `pnpm vitest run` +- `pnpm build` From 587270f0092c6a8d7a066d59a2fe2da5744d0bc3 Mon Sep 17 00:00:00 2001 From: Amilcar Teixeira Date: Fri, 29 May 2026 19:42:57 -0300 Subject: [PATCH 3/3] Delete STACKBLITZ.md --- STACKBLITZ.md | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 STACKBLITZ.md diff --git a/STACKBLITZ.md b/STACKBLITZ.md deleted file mode 100644 index ff14db3c..00000000 --- a/STACKBLITZ.md +++ /dev/null @@ -1,31 +0,0 @@ -## Skippable Tour Branch - -This branch contains an opt-in footer skip button for tours. - -- Adds `showButtons: ["skip", "previous", "next"]` -- Adds `skipBtnText` for configuring the skip label -- Adds `onSkipClick` for custom skip behavior -- Keeps forced tours intact by hiding skip when `allowClose: false` -- Documents the new option and adds a local demo example - -## StackBlitz - -After this branch is pushed to your GitHub fork, open it in StackBlitz with: - -```text -https://stackblitz.com/github//driver.js/tree/feat/footer-skip-button?file=index.html -``` - -For the `milkatx` account: - -```text -https://stackblitz.com/github/milkatx/driver.js/tree/feat/footer-skip-button?file=index.html -``` - -This URL works only after `feat/footer-skip-button` exists on the public fork. - -## Verification - -- `pnpm install --frozen-lockfile` -- `pnpm vitest run` -- `pnpm build`