From a8fcd260afe59005f2a15efa629007328737604f Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Wed, 24 Jun 2026 01:03:47 +0100 Subject: [PATCH] Add support onDoneClick --- docs/src/content/guides/buttons.mdx | 9 +++- docs/src/content/guides/configuration.mdx | 9 ++++ src/config.ts | 1 + src/driver.ts | 25 ++++++++++- src/popover.ts | 12 +++++ tests/interactions.test.ts | 53 +++++++++++++++++++++++ tests/keyboard.test.ts | 16 ++++++- 7 files changed, 122 insertions(+), 3 deletions(-) diff --git a/docs/src/content/guides/buttons.mdx b/docs/src/content/guides/buttons.mdx index 45e50c8..502ddf0 100644 --- a/docs/src/content/guides/buttons.mdx +++ b/docs/src/content/guides/buttons.mdx @@ -180,10 +180,12 @@ You can also change the text of buttons using `nextBtnText`, `prevBtnText` and ` ## Event Handlers -You can use the `onNextClick`, `onPreviousClick` and `onCloseClick` callbacks to implement custom functionality when the user clicks on the next and previous buttons. +You can use the `onNextClick`, `onPrevClick`, `onCloseClick` and `onDoneClick` callbacks to implement custom functionality when the user clicks on the buttons. > 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. +> `onDoneClick` runs when the done button (the next button on the last step) is clicked. When provided, it runs instead of `onNextClick` on the last step, so you can detect when the user finishes the tour. Driver.js won't tear down the tour for you here — call `driverObj.destroy()` yourself. + { + console.log('Done Button Clicked'); + // Runs on the last step instead of onNextClick + driverObj.destroy(); + }, steps: [ // ... ] diff --git a/docs/src/content/guides/configuration.mdx b/docs/src/content/guides/configuration.mdx index ca74fe1..dbbc67e 100644 --- a/docs/src/content/guides/configuration.mdx +++ b/docs/src/content/guides/configuration.mdx @@ -112,6 +112,10 @@ 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; + // Runs when the done button (the next button on the + // last step of a tour) is clicked. When provided, it + // runs instead of `onNextClick` on the last step. + onDoneClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void; }; ``` @@ -120,6 +124,8 @@ type Config = { > You can use this to implement custom logic for navigating between steps. This is also useful when you are dealing with dynamic content and want to highlight the next/previous element based on some logic. > > `onNextClick` and `onPrevClick` hooks can be configured at the step level as well. When configured at the driver level, you control the navigation for all the steps. When configured at the step level, you control the navigation for that particular step only. +> +> `onDoneClick` lets you detect when the user finishes the tour by clicking the done button on the last step. When provided, it runs instead of `onNextClick` on the last step, and Driver.js won't destroy the tour automatically — you control teardown by calling `driverObj.destroy()` yourself. Like the other click hooks, it can be configured at the driver or step level. ## Popover Configuration @@ -187,6 +193,9 @@ 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 + // Runs instead of `onNextClick` when the done button + // (the next button on the last step) is clicked. + onDoneClick?: (element?: Element, step: DriveStep, options: { config: Config; state: State, driver: Driver }) => void } ``` diff --git a/src/config.ts b/src/config.ts index c4a8e6c..ad9e3e7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,7 @@ export type Config = { onNextClick?: DriverHook; onPrevClick?: DriverHook; onCloseClick?: DriverHook; + onDoneClick?: DriverHook; }; let currentConfig: Config = {}; diff --git a/src/driver.ts b/src/driver.ts index 5cbdae2..29d3611 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -82,8 +82,20 @@ export function driver(options: Config = {}): Driver { if (overlayClickBehavior === "nextStep") { const activeStep = getState("activeStep"); const activeElement = getState("activeElement"); - const onNextClick = activeStep?.popover?.onNextClick || getConfig("onNextClick"); + const steps = getConfig("steps") || []; + const isLastStep = getState("activeIndex") === steps.length - 1; + const onDoneClick = activeStep?.popover?.onDoneClick || getConfig("onDoneClick"); + if (isLastStep && onDoneClick) { + onDoneClick(activeElement, activeStep!, { + config: getConfig(), + state: getState(), + driver: getCurrentDriver(), + }); + return; + } + + const onNextClick = activeStep?.popover?.onNextClick || getConfig("onNextClick"); if (onNextClick) { onNextClick(activeElement, activeStep!, { config: getConfig(), @@ -180,6 +192,17 @@ export function driver(options: Config = {}): Driver { return; } + const steps = getConfig("steps") || []; + const isLastStep = activeIndex === steps.length - 1; + const onDoneClick = activeStep.popover?.onDoneClick || getConfig("onDoneClick"); + if (isLastStep && onDoneClick) { + return onDoneClick(activeElement, activeStep, { + config: getConfig(), + state: getState(), + driver: getCurrentDriver(), + }); + } + const onNextClick = activeStep.popover?.onNextClick || getConfig("onNextClick"); if (onNextClick) { return onNextClick(activeElement, activeStep, { diff --git a/src/popover.ts b/src/popover.ts index c1d3135..e2747e7 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -34,6 +34,7 @@ export type Popover = { onNextClick?: DriverHook; onPrevClick?: DriverHook; onCloseClick?: DriverHook; + onDoneClick?: DriverHook; }; export type PopoverDOM = { @@ -167,8 +168,19 @@ 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 onDoneClick = step.popover?.onDoneClick || 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(), + }); + } + // If the user has provided a custom callback, call it // otherwise, emit the event. if (onNextClick) { diff --git a/tests/interactions.test.ts b/tests/interactions.test.ts index 5400ac9..6f409a8 100644 --- a/tests/interactions.test.ts +++ b/tests/interactions.test.ts @@ -86,4 +86,57 @@ describe("button interactions", () => { expect(onNextClick).toHaveBeenCalledTimes(1); expect(d.getActiveIndex()).toBe(0); }); + + it("runs onDoneClick instead of onNextClick on the final step", () => { + const onDoneClick = vi.fn(); + const onNextClick = vi.fn(); + const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onDoneClick, onNextClick }); + d.drive(SAMPLE_STEPS.length - 1); + navButton("next")?.click(); + + expect(onDoneClick).toHaveBeenCalledTimes(1); + expect(onNextClick).not.toHaveBeenCalled(); + + const [element, step, options] = onDoneClick.mock.calls[0]; + expect(element).toBe(document.querySelector(".feature-list")); + expect(step.popover?.title).toBe("Step 3"); + expect(options.driver).toBe(d); + }); + + it("leaves teardown to onDoneClick instead of auto-destroying", () => { + const onDoneClick = vi.fn(); + const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onDoneClick }); + d.drive(SAMPLE_STEPS.length - 1); + navButton("next")?.click(); + + expect(onDoneClick).toHaveBeenCalledTimes(1); + expect(d.isActive()).toBe(true); + }); + + it("does not fire onDoneClick on non-final steps", () => { + const onDoneClick = vi.fn(); + const onNextClick = vi.fn(); + const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onDoneClick, onNextClick }); + d.drive(); + navButton("next")?.click(); + + expect(onDoneClick).not.toHaveBeenCalled(); + expect(onNextClick).toHaveBeenCalledTimes(1); + }); + + it("supports a step-level onDoneClick override", () => { + const onDoneClick = vi.fn(); + const d = createDriver({ + animate: false, + steps: [ + { element: "#intro", popover: { title: "Step 1" } }, + { element: "#card-1", popover: { title: "Step 2", onDoneClick } }, + ], + }); + d.drive(1); + navButton("next")?.click(); + + expect(onDoneClick).toHaveBeenCalledTimes(1); + expect(d.isActive()).toBe(true); + }); }); diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts index bd0f52c..2098c7f 100644 --- a/tests/keyboard.test.ts +++ b/tests/keyboard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createDriver, nextFrame, popoverTitle, pressKey, SAMPLE_STEPS, useDriverHarness } from "./utils"; useDriverHarness(); @@ -48,6 +48,20 @@ describe("keyboard control", () => { expect(d.getActiveIndex()).toBe(0); }); + it("runs onDoneClick when ArrowRight is pressed on the final step", async () => { + const onDoneClick = vi.fn(); + const onNextClick = vi.fn(); + const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onDoneClick, onNextClick }); + d.drive(SAMPLE_STEPS.length - 1); + await nextFrame(); + + pressKey("ArrowRight"); + + expect(onDoneClick).toHaveBeenCalledTimes(1); + expect(onNextClick).not.toHaveBeenCalled(); + expect(d.isActive()).toBe(true); + }); + it("ignores keys when allowKeyboardControl is false", () => { const d = createDriver({ animate: false, allowKeyboardControl: false, steps: SAMPLE_STEPS }); d.drive();