Files
driver.js/packages/driver/tests/hints.test.ts

985 lines
32 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?: DriverHint["beacon"]) {
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("nudges the beacon by offsetX and offsetY", () => {
expect(positionFor({ side: "top", align: "end", offsetX: 12, offsetY: -8 })).toEqual({
top: "392px",
left: "612px",
});
});
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("brings every dismissed hint back with restoreAll()", () => {
const productHints = createHints({ hints: SAMPLE_HINTS });
productHints.show();
productHints.dismiss("intro");
productHints.dismiss("card");
expect(beacons()).toHaveLength(0);
productHints.restoreAll();
expect(beacons()).toHaveLength(2);
});
it("clears dismissals for the next show() when hidden", () => {
const productHints = createHints({ hints: SAMPLE_HINTS });
productHints.show();
productHints.dismiss("intro");
productHints.hide();
productHints.restoreAll();
productHints.show();
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();
});
});