mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-11-01 05:07:12 +08:00
The built-in button always dismissed; repurposing it meant hiding it and rebuilding one in onPopoverRender. onButtonClick (per hint or instance wide, hint wins) now runs instead of dismissing, mirroring how a tour's onNextClick takes over the default advance; call dismiss() from the hook to keep the old behavior. Custom and extra buttons remain the onPopoverRender pattern, same as tours.
954 lines
31 KiB
TypeScript
954 lines
31 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { driver, type Driver } from "../src/driver";
|
|
import { hints, type DriverHint, type Hints } from "../src/hints";
|
|
import { DEMO_HTML, nextFrame } from "./utils";
|
|
|
|
// jsdom has no IntersectionObserver; the beacon visibility tests install a
|
|
// controllable fake, the rest just need the constructor to exist.
|
|
type ObserverCallback = (entries: { isIntersecting: boolean }[]) => void;
|
|
let observerCallbacks: { callback: ObserverCallback; target: Element; disconnected: boolean }[] = [];
|
|
|
|
class FakeIntersectionObserver {
|
|
callback: ObserverCallback;
|
|
|
|
constructor(callback: ObserverCallback) {
|
|
this.callback = callback;
|
|
}
|
|
|
|
observe(target: Element) {
|
|
observerCallbacks.push({ callback: this.callback, target, disconnected: false });
|
|
}
|
|
|
|
disconnect() {
|
|
observerCallbacks
|
|
.filter(entry => entry.callback === this.callback)
|
|
.forEach(entry => {
|
|
entry.disconnected = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
function intersect(target: Element, isIntersecting: boolean) {
|
|
observerCallbacks
|
|
.filter(entry => entry.target === target && !entry.disconnected)
|
|
.forEach(entry => entry.callback([{ isIntersecting }]));
|
|
}
|
|
|
|
const SAMPLE_HINTS: DriverHint[] = [
|
|
{ element: "#intro", id: "intro", popover: { title: "Intro hint", description: "About the intro" } },
|
|
{ element: "#card-1", id: "card", popover: { title: "Card hint" } },
|
|
];
|
|
|
|
let active: Hints | undefined;
|
|
let activeDriver: Driver | undefined;
|
|
|
|
function createHints(config?: Parameters<typeof hints>[0]): Hints {
|
|
active = hints(config);
|
|
return active;
|
|
}
|
|
|
|
const beacons = () => Array.from(document.querySelectorAll<HTMLButtonElement>(".driver-hint"));
|
|
const beaconFor = (id: string) => beacons()[SAMPLE_HINTS.findIndex(hint => hint.id === id)];
|
|
const popoverEl = () => document.querySelector<HTMLElement>(".driver-popover");
|
|
const popoverTitle = () => document.querySelector(".driver-popover-title")?.textContent?.trim();
|
|
const dismissButton = () => document.querySelector<HTMLButtonElement>(".driver-popover-next-btn");
|
|
|
|
beforeEach(() => {
|
|
document.body.innerHTML = DEMO_HTML;
|
|
observerCallbacks = [];
|
|
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
|
|
});
|
|
|
|
afterEach(() => {
|
|
active?.hide();
|
|
active = undefined;
|
|
activeDriver?.destroy();
|
|
activeDriver = undefined;
|
|
document.body.innerHTML = "";
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("mounting beacons", () => {
|
|
it("mounts nothing until show() is called", () => {
|
|
createHints({ hints: SAMPLE_HINTS });
|
|
|
|
expect(beacons()).toHaveLength(0);
|
|
});
|
|
|
|
it("mounts a beacon per hint on show()", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("renders the beacon as a labelled button with a dot and a pulse", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
|
|
const beacon = beaconFor("intro");
|
|
expect(beacon.tagName).toBe("BUTTON");
|
|
expect(beacon.type).toBe("button");
|
|
expect(beacon.getAttribute("aria-label")).toBe("Intro hint");
|
|
expect(beacon.getAttribute("aria-haspopup")).toBe("dialog");
|
|
expect(beacon.getAttribute("aria-expanded")).toBe("false");
|
|
expect(beacon.querySelector(".driver-hint-dot")).not.toBeNull();
|
|
expect(beacon.querySelector(".driver-hint-pulse")).not.toBeNull();
|
|
});
|
|
|
|
it("falls back to a generic label when the hint has no title", () => {
|
|
createHints({ hints: [{ element: "#intro" }] }).show();
|
|
|
|
expect(beacons()[0].getAttribute("aria-label")).toBe("Show hint");
|
|
});
|
|
|
|
it("skips a hint whose element does not exist", () => {
|
|
createHints({ hints: [{ element: "#nope" }, { element: "#intro" }] }).show();
|
|
|
|
expect(beacons()).toHaveLength(1);
|
|
});
|
|
|
|
it("picks up an element that appears later on show()", () => {
|
|
const productHints = createHints({ hints: [{ element: "#late" }, ...SAMPLE_HINTS] });
|
|
productHints.show();
|
|
expect(beacons()).toHaveLength(2);
|
|
|
|
const late = document.createElement("div");
|
|
late.id = "late";
|
|
document.body.appendChild(late);
|
|
productHints.show();
|
|
|
|
expect(beacons()).toHaveLength(3);
|
|
});
|
|
|
|
it("resolves an element node and a function element", () => {
|
|
createHints({
|
|
hints: [{ element: document.querySelector("#intro")! }, { element: () => document.querySelector("#card-1")! }],
|
|
}).show();
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("is idempotent across repeated show() calls", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.show();
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("opts a beacon out of the pulse animation", () => {
|
|
createHints({ hints: [{ element: "#intro", beacon: { animate: false } }] }).show();
|
|
|
|
expect(beacons()[0].classList.contains("driver-hint-no-animation")).toBe(true);
|
|
});
|
|
|
|
it("applies a custom beacon class over the global default", () => {
|
|
createHints({
|
|
beacon: { className: "global-beacon" },
|
|
hints: [{ element: "#intro", beacon: { className: "hint-beacon" } }, { element: "#card-1" }],
|
|
}).show();
|
|
|
|
expect(beacons()[0].classList.contains("hint-beacon")).toBe(true);
|
|
expect(beacons()[1].classList.contains("global-beacon")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("beacon positioning", () => {
|
|
const rect = (over: Partial<DOMRect>): DOMRect =>
|
|
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
|
|
|
|
// Element box: 200x20 at (400, 400).
|
|
const ELEMENT_BOX = { top: 400, left: 400, right: 600, bottom: 420, width: 200, height: 20 };
|
|
|
|
function positionFor(beacon?: { side?: DriverHint["beacon"] extends infer B ? any : never; align?: any }) {
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect(ELEMENT_BOX);
|
|
|
|
createHints({ hints: [{ element: "#intro", beacon }] }).show();
|
|
|
|
const mounted = beacons()[0];
|
|
return { top: mounted.style.top, left: mounted.style.left };
|
|
}
|
|
|
|
it("defaults to the element's top-right corner", () => {
|
|
expect(positionFor()).toEqual({ top: "400px", left: "600px" });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
{ side: "top", align: "start" },
|
|
{ top: "400px", left: "400px" },
|
|
],
|
|
[
|
|
{ side: "top", align: "center" },
|
|
{ top: "400px", left: "500px" },
|
|
],
|
|
[
|
|
{ side: "top", align: "end" },
|
|
{ top: "400px", left: "600px" },
|
|
],
|
|
[
|
|
{ side: "bottom", align: "start" },
|
|
{ top: "420px", left: "400px" },
|
|
],
|
|
[
|
|
{ side: "bottom", align: "center" },
|
|
{ top: "420px", left: "500px" },
|
|
],
|
|
[
|
|
{ side: "left", align: "start" },
|
|
{ top: "400px", left: "400px" },
|
|
],
|
|
[
|
|
{ side: "left", align: "center" },
|
|
{ top: "410px", left: "400px" },
|
|
],
|
|
[
|
|
{ side: "left", align: "end" },
|
|
{ top: "420px", left: "400px" },
|
|
],
|
|
[
|
|
{ side: "right", align: "center" },
|
|
{ top: "410px", left: "600px" },
|
|
],
|
|
] as const)("anchors the beacon for %o", (beacon, expected) => {
|
|
expect(positionFor(beacon)).toEqual(expected);
|
|
});
|
|
|
|
it("repositions the beacons on scroll", async () => {
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect(ELEMENT_BOX);
|
|
createHints({ hints: [{ element: "#intro" }] }).show();
|
|
|
|
expect(beacons()[0].style.top).toBe("400px");
|
|
|
|
el.getBoundingClientRect = () => rect({ ...ELEMENT_BOX, top: 100, bottom: 120 });
|
|
window.dispatchEvent(new Event("scroll"));
|
|
await nextFrame();
|
|
|
|
expect(beacons()[0].style.top).toBe("100px");
|
|
});
|
|
|
|
it("repositions the beacons when a nested container scrolls", async () => {
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect(ELEMENT_BOX);
|
|
createHints({ hints: [{ element: "#intro" }] }).show();
|
|
|
|
el.getBoundingClientRect = () => rect({ ...ELEMENT_BOX, top: 200, bottom: 220 });
|
|
// A scroll inside a container never reaches the window while bubbling, so
|
|
// this only repositions if the listener is registered in capture phase.
|
|
document.querySelector(".feature-list")!.dispatchEvent(new Event("scroll"));
|
|
await nextFrame();
|
|
|
|
expect(beacons()[0].style.top).toBe("200px");
|
|
});
|
|
|
|
it("repositions the beacons on resize", async () => {
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect(ELEMENT_BOX);
|
|
createHints({ hints: [{ element: "#intro" }] }).show();
|
|
|
|
el.getBoundingClientRect = () => rect({ ...ELEMENT_BOX, left: 50, right: 250 });
|
|
window.dispatchEvent(new Event("resize"));
|
|
await nextFrame();
|
|
|
|
expect(beacons()[0].style.left).toBe("250px");
|
|
});
|
|
});
|
|
|
|
describe("popover arrow alignment", () => {
|
|
const rect = (over: Partial<DOMRect>): DOMRect =>
|
|
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
|
|
|
|
it("shifts the popover so the arrow points at the beacon's center", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
// The arrow tip sits 22px from the popover's corner (15px inset + half of
|
|
// the 14px hint arrow). For a 24px beacon the popover must therefore start
|
|
// 22 - 12 = 10px before the beacon's left edge.
|
|
const beacon = beaconFor("intro");
|
|
beacon.getBoundingClientRect = () => rect({ top: 400, left: 400, right: 424, bottom: 424, width: 24, height: 24 });
|
|
|
|
productHints.open("intro");
|
|
|
|
expect(popoverEl()?.style.left).toBe("390px");
|
|
});
|
|
});
|
|
|
|
describe("overlay", () => {
|
|
const overlayEl = () => document.querySelector<SVGSVGElement>(".driver-hint-overlay");
|
|
const overlayPath = () => overlayEl()?.querySelector("path");
|
|
|
|
it("shows no overlay by default", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
expect(overlayEl()).toBeNull();
|
|
});
|
|
|
|
it("dims the page while a hint is open when enabled", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
|
|
expect(overlayEl()).toBeNull();
|
|
|
|
productHints.open("intro");
|
|
expect(overlayEl()).not.toBeNull();
|
|
|
|
productHints.close();
|
|
expect(overlayEl()).toBeNull();
|
|
});
|
|
|
|
it("keeps a single overlay when swapping between hints", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
|
|
productHints.open("intro");
|
|
productHints.open("card");
|
|
|
|
expect(document.querySelectorAll(".driver-hint-overlay")).toHaveLength(1);
|
|
});
|
|
|
|
it("applies the configured color and opacity", () => {
|
|
const productHints = createHints({
|
|
hints: SAMPLE_HINTS,
|
|
overlay: true,
|
|
overlayColor: "#123456",
|
|
overlayOpacity: 0.4,
|
|
});
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
expect(["#123456", "rgb(18, 52, 86)"]).toContain(overlayPath()?.style.fill);
|
|
expect(overlayPath()?.style.opacity).toBe("0.4");
|
|
});
|
|
|
|
it("cuts the hint's element out of the dim", () => {
|
|
const rect = (over: Partial<DOMRect>): DOMRect =>
|
|
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect({ x: 400, y: 300, width: 200, height: 40 });
|
|
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
// Full-screen subpath plus a cutout subpath. The cutout starts at the
|
|
// element's x minus the 10px padding, plus the 5px corner radius: 395.
|
|
const d = overlayPath()?.getAttribute("d") ?? "";
|
|
expect(d).toContain(`M${window.innerWidth},0L0,0L0,${window.innerHeight}`);
|
|
expect(d).toContain("M395,290");
|
|
});
|
|
|
|
it("tracks the element when the page scrolls", async () => {
|
|
const rect = (over: Partial<DOMRect>): DOMRect =>
|
|
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect({ x: 400, y: 300, width: 200, height: 40 });
|
|
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
el.getBoundingClientRect = () => rect({ x: 400, y: 100, width: 200, height: 40 });
|
|
window.dispatchEvent(new Event("scroll"));
|
|
await nextFrame();
|
|
|
|
expect(overlayPath()?.getAttribute("d")).toContain("M395,90");
|
|
});
|
|
|
|
it("closes the hint when the dimmed page is clicked", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
// Clicks land on the dimmed path (the svg lets pointer events through).
|
|
overlayPath()!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
expect(popoverEl()).toBeNull();
|
|
expect(overlayEl()).toBeNull();
|
|
});
|
|
|
|
it("removes the overlay when the open hint is dismissed", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
productHints.dismiss("intro");
|
|
|
|
expect(overlayEl()).toBeNull();
|
|
});
|
|
|
|
it("removes the overlay on hide()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
productHints.hide();
|
|
|
|
expect(overlayEl()).toBeNull();
|
|
});
|
|
|
|
it("hides the beacon while its popover is open, and restores it on close", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
|
|
productHints.open("intro");
|
|
expect(beaconFor("intro").style.display).toBe("none");
|
|
|
|
productHints.close();
|
|
expect(beaconFor("intro").style.display).toBe("");
|
|
});
|
|
|
|
it("keeps the beacon visible when the overlay is disabled", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
productHints.open("intro");
|
|
|
|
expect(beaconFor("intro").style.display).not.toBe("none");
|
|
});
|
|
|
|
it("anchors the popover to the element instead of the beacon", () => {
|
|
const rect = (over: Partial<DOMRect>): DOMRect =>
|
|
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
|
|
const el = document.querySelector<HTMLElement>("#intro")!;
|
|
el.getBoundingClientRect = () => rect({ top: 300, left: 400, right: 600, bottom: 340, width: 200, height: 40 });
|
|
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
|
|
// Tour-step alignment: the popover's left lines up with the cutout's edge,
|
|
// element left minus the 10px overlay padding.
|
|
expect(popoverEl()?.style.left).toBe("390px");
|
|
});
|
|
});
|
|
|
|
describe("beacon visibility", () => {
|
|
it("hides the beacon when its element leaves the viewport", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
const element = document.querySelector("#intro")!;
|
|
|
|
intersect(element, false);
|
|
expect(beaconFor("intro").classList.contains("driver-hint-hidden")).toBe(true);
|
|
|
|
intersect(element, true);
|
|
expect(beaconFor("intro").classList.contains("driver-hint-hidden")).toBe(false);
|
|
});
|
|
|
|
it("closes an open hint when its element leaves the viewport", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.open("intro");
|
|
expect(popoverEl()).not.toBeNull();
|
|
|
|
intersect(document.querySelector("#intro")!, false);
|
|
|
|
expect(popoverEl()).toBeNull();
|
|
expect(productHints.getActive()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("opening and closing", () => {
|
|
it("opens the popover when the beacon is clicked", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
|
|
beaconFor("intro").click();
|
|
|
|
expect(popoverTitle()).toBe("Intro hint");
|
|
expect(beaconFor("intro").getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
it("anchors the popover to the beacon rather than the element", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
// The arrow points back at the beacon, which the position code only knows
|
|
// about because the beacon is the anchor.
|
|
expect(popoverEl()?.className).toContain("driver-hint-popover");
|
|
expect(document.querySelector(".driver-popover-arrow")).not.toBeNull();
|
|
});
|
|
|
|
it("toggles closed when the same beacon is clicked again", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
|
|
beaconFor("intro").click();
|
|
beaconFor("intro").click();
|
|
|
|
expect(popoverEl()).toBeNull();
|
|
expect(beaconFor("intro").getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
it("swaps the popover when another beacon is clicked", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
|
|
beaconFor("intro").click();
|
|
beaconFor("card").click();
|
|
|
|
expect(document.querySelectorAll(".driver-popover")).toHaveLength(1);
|
|
expect(popoverTitle()).toBe("Card hint");
|
|
expect(beaconFor("intro").getAttribute("aria-expanded")).toBe("false");
|
|
expect(beaconFor("card").getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
it("keeps the beacons interactive with no overlay behind them", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
expect(document.querySelector(".driver-overlay")).toBeNull();
|
|
expect(document.body.classList.contains("driver-active")).toBe(false);
|
|
});
|
|
|
|
it("closes on an outside click", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
document.querySelector<HTMLElement>(".page-header")!.click();
|
|
|
|
expect(popoverEl()).toBeNull();
|
|
});
|
|
|
|
it("stays open when the popover itself is clicked", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
document.querySelector<HTMLElement>(".driver-popover-description")!.click();
|
|
|
|
expect(popoverEl()).not.toBeNull();
|
|
});
|
|
|
|
it("closes on Escape and returns focus to the beacon", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
const focus = vi.spyOn(beaconFor("intro"), "focus");
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { key: "Escape" }));
|
|
|
|
expect(popoverEl()).toBeNull();
|
|
expect(focus).toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens and closes programmatically", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
productHints.open("card");
|
|
expect(popoverTitle()).toBe("Card hint");
|
|
expect(productHints.getActive()?.id).toBe("card");
|
|
|
|
productHints.close();
|
|
expect(popoverEl()).toBeNull();
|
|
expect(productHints.getActive()).toBeUndefined();
|
|
});
|
|
|
|
it("addresses a hint without an id by its index", () => {
|
|
const productHints = createHints({ hints: [{ element: "#intro", popover: { title: "First" } }] });
|
|
productHints.show();
|
|
|
|
productHints.open(0);
|
|
|
|
expect(popoverTitle()).toBe("First");
|
|
});
|
|
|
|
it("ignores an unknown id", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
expect(() => productHints.open("nope")).not.toThrow();
|
|
expect(popoverEl()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("dismissing", () => {
|
|
it("shows a Got it button by default", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
expect(dismissButton()?.innerHTML).toBe("Got it");
|
|
expect(dismissButton()?.style.display).toBe("block");
|
|
});
|
|
|
|
it("uses custom button text from the hint over the global default", () => {
|
|
createHints({
|
|
buttonText: "Global OK",
|
|
hints: [{ element: "#intro", popover: { title: "One", buttonText: "Hint OK" } }, { element: "#card-1" }],
|
|
}).show();
|
|
|
|
beacons()[0].click();
|
|
expect(dismissButton()?.innerHTML).toBe("Hint OK");
|
|
|
|
beacons()[1].click();
|
|
expect(dismissButton()?.innerHTML).toBe("Global OK");
|
|
});
|
|
|
|
it("hides the dismiss button when showButton is false", () => {
|
|
createHints({ hints: [{ element: "#intro", popover: { title: "One", showButton: false } }] }).show();
|
|
beacons()[0].click();
|
|
|
|
expect(document.querySelector<HTMLElement>(".driver-popover-footer")?.style.display).toBe("none");
|
|
});
|
|
|
|
it("renders no navigation or close buttons", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
expect(document.querySelector<HTMLElement>(".driver-popover-prev-btn")?.style.display).toBe("none");
|
|
expect(document.querySelector<HTMLElement>(".driver-popover-close-btn")?.style.display).toBe("none");
|
|
expect(document.querySelector<HTMLElement>(".driver-popover-progress-text")?.style.display).toBe("none");
|
|
});
|
|
|
|
it("removes the beacon and popover when dismissed", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
|
|
dismissButton()!.click();
|
|
|
|
expect(popoverEl()).toBeNull();
|
|
expect(beacons()).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps the beacon when the popover is merely closed", () => {
|
|
createHints({ hints: SAMPLE_HINTS }).show();
|
|
beaconFor("intro").click();
|
|
document.querySelector<HTMLElement>(".page-header")!.click();
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("does not resurrect a dismissed hint on hide/show", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.dismiss("intro");
|
|
|
|
productHints.hide();
|
|
productHints.show();
|
|
|
|
expect(beacons()).toHaveLength(1);
|
|
expect(beacons()[0].getAttribute("aria-label")).toBe("Card hint");
|
|
});
|
|
|
|
it("brings a dismissed hint back with restore()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.dismiss("intro");
|
|
expect(beacons()).toHaveLength(1);
|
|
|
|
productHints.restore("intro");
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("ignores restore() for a hint that was never dismissed", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
productHints.restore("intro");
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("resets dismissals on setHints()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.dismiss("intro");
|
|
|
|
productHints.setHints(SAMPLE_HINTS);
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("hooks", () => {
|
|
it("fires onOpen with the element, hint and instance", () => {
|
|
const onOpen = vi.fn();
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, onOpen });
|
|
productHints.show();
|
|
|
|
beaconFor("intro").click();
|
|
|
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
const [element, hint, opts] = onOpen.mock.calls[0];
|
|
expect(element).toBe(document.querySelector("#intro"));
|
|
expect(hint.id).toBe("intro");
|
|
expect(opts.hints).toBe(productHints);
|
|
expect(opts.config.hints).toBe(SAMPLE_HINTS);
|
|
});
|
|
|
|
it("prefers a hint-level onOpen over the global one", () => {
|
|
const globalOpen = vi.fn();
|
|
const hintOpen = vi.fn();
|
|
createHints({ hints: [{ element: "#intro", onOpen: hintOpen }], onOpen: globalOpen }).show();
|
|
|
|
beacons()[0].click();
|
|
|
|
expect(hintOpen).toHaveBeenCalledTimes(1);
|
|
expect(globalOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fires onOpen for a programmatic open", () => {
|
|
const onOpen = vi.fn();
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, onOpen });
|
|
productHints.show();
|
|
|
|
productHints.open("intro");
|
|
|
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fires onDismiss with a stable id for persistence", () => {
|
|
const onDismiss = vi.fn();
|
|
createHints({ hints: SAMPLE_HINTS, onDismiss }).show();
|
|
|
|
beaconFor("intro").click();
|
|
dismissButton()!.click();
|
|
|
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
const [element, hint] = onDismiss.mock.calls[0];
|
|
expect(element).toBe(document.querySelector("#intro"));
|
|
expect(hint.id).toBe("intro");
|
|
});
|
|
|
|
it("runs onButtonClick instead of dismissing when provided", () => {
|
|
const onButtonClick = vi.fn();
|
|
const onDismiss = vi.fn();
|
|
createHints({
|
|
onDismiss,
|
|
hints: [{ element: "#intro", id: "intro", popover: { title: "One", onButtonClick } }, SAMPLE_HINTS[1]],
|
|
}).show();
|
|
|
|
beacons()[0].click();
|
|
dismissButton()!.click();
|
|
|
|
expect(onButtonClick).toHaveBeenCalledTimes(1);
|
|
expect(onDismiss).not.toHaveBeenCalled();
|
|
// The hint is neither dismissed nor closed; the hook owns the behavior.
|
|
expect(beacons()).toHaveLength(2);
|
|
expect(popoverEl()).not.toBeNull();
|
|
});
|
|
|
|
it("falls back to the instance-level onButtonClick", () => {
|
|
const onButtonClick = vi.fn();
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, onButtonClick });
|
|
productHints.show();
|
|
|
|
beaconFor("intro").click();
|
|
dismissButton()!.click();
|
|
|
|
expect(onButtonClick).toHaveBeenCalledTimes(1);
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("lets onButtonClick dismiss through the instance", () => {
|
|
const productHints = createHints({
|
|
hints: SAMPLE_HINTS,
|
|
onButtonClick: (element, hint) => productHints.dismiss(hint.id!),
|
|
});
|
|
productHints.show();
|
|
|
|
beaconFor("intro").click();
|
|
dismissButton()!.click();
|
|
|
|
expect(beacons()).toHaveLength(1);
|
|
expect(popoverEl()).toBeNull();
|
|
});
|
|
|
|
it("fires onDismiss for a programmatic dismiss", () => {
|
|
const onDismiss = vi.fn();
|
|
const productHints = createHints({ hints: SAMPLE_HINTS, onDismiss });
|
|
productHints.show();
|
|
|
|
productHints.dismiss("card");
|
|
|
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fire onDismiss when the popover is only closed", () => {
|
|
const onDismiss = vi.fn();
|
|
createHints({ hints: SAMPLE_HINTS, onDismiss }).show();
|
|
|
|
beaconFor("intro").click();
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { key: "Escape" }));
|
|
|
|
expect(onDismiss).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes the hint data through to the hooks", () => {
|
|
const onOpen = vi.fn();
|
|
createHints({ hints: [{ element: "#intro", data: { tracking: "hint-1" } }], onOpen }).show();
|
|
|
|
beacons()[0].click();
|
|
|
|
expect(onOpen.mock.calls[0][1].data).toEqual({ tracking: "hint-1" });
|
|
});
|
|
|
|
it("lets onPopoverRender mutate the hint popover", () => {
|
|
createHints({
|
|
hints: [
|
|
{
|
|
element: "#intro",
|
|
popover: {
|
|
title: "One",
|
|
onPopoverRender: popover => {
|
|
const extra = document.createElement("button");
|
|
extra.classList.add("hint-extra-btn");
|
|
popover.footerButtons.appendChild(extra);
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}).show();
|
|
|
|
beacons()[0].click();
|
|
|
|
expect(document.querySelector(".driver-popover .hint-extra-btn")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("teardown", () => {
|
|
it("removes every beacon and the popover on hide()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
beaconFor("intro").click();
|
|
|
|
productHints.hide();
|
|
|
|
expect(beacons()).toHaveLength(0);
|
|
expect(popoverEl()).toBeNull();
|
|
expect(productHints.isVisible()).toBe(false);
|
|
});
|
|
|
|
it("detaches every listener on hide()", () => {
|
|
const addSpy = vi.spyOn(window, "addEventListener");
|
|
const removeSpy = vi.spyOn(window, "removeEventListener");
|
|
const docAddSpy = vi.spyOn(document, "addEventListener");
|
|
const docRemoveSpy = vi.spyOn(document, "removeEventListener");
|
|
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.hide();
|
|
|
|
// Every listener the module attaches is detached again, with the same
|
|
// function reference and capture flag. A capture mismatch is the classic
|
|
// way a listener silently outlives its teardown.
|
|
const normalize = (calls: [string, unknown, unknown?][]) =>
|
|
calls.map(([type, listener, options]) => ({
|
|
type,
|
|
listener,
|
|
capture: options === true || (typeof options === "object" && (options as AddEventListenerOptions)?.capture),
|
|
}));
|
|
|
|
const attached = normalize(addSpy.mock.calls.concat(docAddSpy.mock.calls) as [string, unknown, unknown?][]);
|
|
const detached = normalize(removeSpy.mock.calls.concat(docRemoveSpy.mock.calls) as [string, unknown, unknown?][]);
|
|
|
|
expect(attached).toHaveLength(4);
|
|
attached.forEach(entry => {
|
|
expect(detached).toContainEqual(entry);
|
|
});
|
|
});
|
|
|
|
it("disconnects the intersection observers on hide()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.hide();
|
|
|
|
expect(observerCallbacks.every(entry => entry.disconnected)).toBe(true);
|
|
});
|
|
|
|
it("stops repositioning after hide()", async () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.hide();
|
|
|
|
window.dispatchEvent(new Event("scroll"));
|
|
await nextFrame();
|
|
|
|
expect(beacons()).toHaveLength(0);
|
|
});
|
|
|
|
it("can be shown again after hide()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
productHints.hide();
|
|
productHints.show();
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
});
|
|
|
|
it("tolerates hide() before show()", () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
|
|
expect(() => productHints.hide()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("tour interplay", () => {
|
|
it("closes an open hint when a tour starts", async () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
beaconFor("intro").click();
|
|
expect(popoverEl()).not.toBeNull();
|
|
|
|
activeDriver = driver({ animate: false, steps: [{ element: "#card-1", popover: { title: "Tour step" } }] });
|
|
activeDriver.drive();
|
|
// The body-class observer settles on a microtask.
|
|
await nextFrame();
|
|
|
|
expect(productHints.getActive()).toBeUndefined();
|
|
expect(popoverTitle()).toBe("Tour step");
|
|
});
|
|
|
|
it("leaves the beacons mounted for CSS to hide during a tour", async () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
activeDriver = driver({ animate: false, steps: [{ element: "#card-1", popover: { title: "Tour step" } }] });
|
|
activeDriver.drive();
|
|
await nextFrame();
|
|
|
|
// The beacons stay in the DOM and come back on their own when the tour
|
|
// ends, because `.driver-active .driver-hint` hides them meanwhile.
|
|
expect(beacons()).toHaveLength(2);
|
|
expect(document.body.classList.contains("driver-active")).toBe(true);
|
|
|
|
activeDriver.destroy();
|
|
await nextFrame();
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
expect(document.body.classList.contains("driver-active")).toBe(false);
|
|
});
|
|
|
|
it("keeps hint state independent of the tour", async () => {
|
|
const productHints = createHints({ hints: SAMPLE_HINTS });
|
|
productHints.show();
|
|
|
|
activeDriver = driver({ animate: false, steps: [{ element: "#card-1", popover: { title: "Tour step" } }] });
|
|
activeDriver.drive();
|
|
activeDriver.destroy();
|
|
await nextFrame();
|
|
|
|
productHints.open("intro");
|
|
|
|
expect(popoverTitle()).toBe("Intro hint");
|
|
});
|
|
});
|
|
|
|
describe("multiple instances", () => {
|
|
it("keeps two hint instances isolated", () => {
|
|
const first = hints({ hints: [{ element: "#intro", id: "a", popover: { title: "First" } }] });
|
|
const second = hints({ hints: [{ element: "#card-1", id: "b", popover: { title: "Second" } }] });
|
|
|
|
first.show();
|
|
second.show();
|
|
first.open("a");
|
|
|
|
expect(beacons()).toHaveLength(2);
|
|
expect(popoverTitle()).toBe("First");
|
|
expect(second.getActive()).toBeUndefined();
|
|
|
|
first.hide();
|
|
second.hide();
|
|
});
|
|
});
|