diff --git a/apps/docs/src/content/guides/hints.mdx b/apps/docs/src/content/guides/hints.mdx index 382ddc0..34c93d5 100644 --- a/apps/docs/src/content/guides/hints.mdx +++ b/apps/docs/src/content/guides/hints.mdx @@ -214,7 +214,7 @@ Each hint in the `hints` array: ```js const hint = { // Selector, element, or a function returning one. A hint whose element - // is missing is skipped and picked up again on the next show()/refresh(). + // is missing is skipped and picked up again on the next show(). element: "#export-btn", // Stable identity, used by open/dismiss/restore and in the hooks. diff --git a/packages/driver/src/driver.css b/packages/driver/src/driver.css index 7d4a00f..9c474da 100644 --- a/packages/driver/src/driver.css +++ b/packages/driver/src/driver.css @@ -45,6 +45,7 @@ overflow: hidden !important; } -.driver-no-interaction, .driver-no-interaction * { +.driver-no-interaction, +.driver-no-interaction * { pointer-events: none !important; } diff --git a/packages/driver/src/hints.css b/packages/driver/src/hints.css index 7b540fd..d055079 100644 --- a/packages/driver/src/hints.css +++ b/packages/driver/src/hints.css @@ -1,7 +1,5 @@ -/* The hint popover shares its styling with the tour, so this entry pulls in - the same popover stylesheet the tour uses. Importing hints.css is therefore - all a hints-only consumer needs — no separate popover styles to remember. - Everything below is beacon-specific. */ +/* Popover styles are shared with the tour; importing hints.css alone is + enough for hints-only consumers. Everything below is hint-specific. */ @import "./popover.css"; .driver-hint { @@ -12,10 +10,9 @@ width: var(--driver-hint-size, 24px); height: var(--driver-hint-size, 24px); cursor: pointer; - /* Above the page but below the overlay, so when a hint is open with the - overlay enabled, the other beacons dim with the rest of the page. */ + /* Below the overlay, so other beacons dim with the page while a hint is open. */ z-index: 999999996; - /* The beacon is centered on the anchor point set inline by the script. */ + /* Centered on the anchor point that the script sets inline. */ transform: translate(-50%, -50%); } @@ -32,13 +29,10 @@ .driver-hint-pulse { position: absolute; border-radius: 50%; - /* A light indigo by default — noticeable without shouting, and it reads on - both light and dark pages; override with --driver-hint-color. */ background-color: var(--driver-hint-color, #818cf8); } .driver-hint-dot { - /* The solid core, half the size of the beacon box. */ inset: 25%; } @@ -75,28 +69,24 @@ } } -/* A larger arrow than the tour's 5px triangle, proportionate to the beacon. - The placement code measures the arrow, so the size only lives here — if you - restyle it, alignment follows. Kept in sync with HINT_ARROW_SIZE in - hints.ts, which shifts the popover so the tip lands on the beacon. */ +/* A larger arrow than the tour's, proportionate to the beacon. Placement + measures the arrow, so restyling it keeps the tip on target; only the + beacon shift assumes the size (HINT_ARROW_SIZE in hints.ts). */ .driver-hint-popover .driver-popover-arrow { border-width: 7px; } -/* box-shadow stops at the popover's box, which leaves the protruding arrow - flat and shadow-less. A drop-shadow filter follows the rendered silhouette - instead, so the popover and its arrow cast one seamless shadow, whatever - the theme's colors are. */ +/* box-shadow stops at the popover's box, leaving the protruding arrow flat. + A drop-shadow filter follows the rendered silhouette instead, so the box + and the arrow cast one seamless shadow in any theme. */ .driver-hint-popover.driver-popover { box-shadow: none; filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.25)) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2)); } -/* The opt-in overlay shown while a hint is open: a full-screen dim with the - hint's element cut out. Only the open hint's beacon and popover sit above - it. The svg itself lets pointer events through (the element in the cutout - stays interactive); the dimmed path catches them, so clicking the dim - closes the hint. */ +/* The opt-in dim with the hint's element cut out. The svg lets pointer events + through (the cutout stays interactive); the dimmed path catches them, and + clicking it closes the hint. */ .driver-hint-overlay { position: fixed; inset: 0; @@ -119,8 +109,7 @@ } } -/* A tour takes over the screen, so the beacons step aside until it ends. The - tour marks the body while it runs, which keeps the two decoupled. */ +/* The tour marks the body while it runs; beacons step aside until it ends. */ .driver-active .driver-hint { display: none; } diff --git a/packages/driver/src/hints.ts b/packages/driver/src/hints.ts index fdabece..d2baf7b 100644 --- a/packages/driver/src/hints.ts +++ b/packages/driver/src/hints.ts @@ -16,13 +16,11 @@ import "./hints.css"; const OVERLAY_PADDING = 10; const OVERLAY_RADIUS = 5; -// The hint arrow is rendered larger than the tour's (7px borders — see -// hints.css); the popover shift that puts the arrow tip on the beacon has to -// assume the same size, since it is computed before the popover exists. +// Must match the arrow's border-width in hints.css; the popover shift that +// puts the arrow tip on the beacon runs before the popover exists, so the +// size cannot be measured. const HINT_ARROW_SIZE = 14; -// Part of the hints API surface: beacon/popover placement and the DOM handed -// to onPopoverRender are described with the popover primitive's types. export type { Alignment, PopoverDOM, Side } from "./popover"; export type HintBeacon = { @@ -76,9 +74,10 @@ export type HintsConfig = { popoverClass?: string; popoverOffset?: number; - // Dim the page while a hint is open. The beacons and the popover stay above - // the overlay so other hints remain clickable; clicking the dimmed page - // closes the open hint like any outside click. + // Dim the page while a hint is open, with the hint's element cut out like a + // tour step. The popover then anchors to the element, the open hint's beacon + // steps aside, and the other beacons wait under the dim; clicking the dimmed + // page closes the hint. overlay?: boolean; overlayColor?: string; overlayOpacity?: number; @@ -110,9 +109,9 @@ type MountedHint = { }; export function hints(config: HintsConfig = {}): Hints { - let currentConfig: HintsConfig = { ...config }; + const currentConfig: HintsConfig = { ...config }; + const dismissed = new Set(); let mounted: MountedHint[] = []; - let dismissed = new Set(); let teardown: (() => void)[] = []; let activeId: string | undefined; @@ -205,10 +204,10 @@ export function hints(config: HintsConfig = {}): Hints { } function mountHint(hint: DriverHint, id: string) { - const element = resolveElement(hint.element); // A hint without an anchor has nothing to point at. It is skipped rather // than centered like a tour's element-less popover, and picked up on the - // next show()/refresh() if the element appears later. + // next show() if the element appears later. + const element = resolveElement(hint.element); if (!element) { return; } @@ -226,7 +225,7 @@ export function hints(config: HintsConfig = {}): Hints { function mountHints() { (currentConfig.hints || []).forEach((hint, index) => { const id = hintId(hint, index); - if (dismissed.has(id)) { + if (dismissed.has(id) || find(id)) { return; } @@ -252,9 +251,6 @@ export function hints(config: HintsConfig = {}): Hints { } function bindListeners() { - const onScroll = () => requireRefresh(); - const onResize = () => requireRefresh(); - const onDocumentClick = (event: MouseEvent) => { if (!activeId) { return; @@ -275,22 +271,22 @@ export function hints(config: HintsConfig = {}): Hints { return; } + // Escape came from the keyboard, so send focus back where it started. const beacon = find(activeId)?.beacon; close(); - // Escape came from the keyboard, so send focus back where it started. beacon?.focus(); }; // Capture, so scrolling inside a nested container repositions the beacons - // too — those events never reach the window during the bubble phase. - window.addEventListener("scroll", onScroll, true); - window.addEventListener("resize", onResize); + // too; those events never reach the window during the bubble phase. + window.addEventListener("scroll", requireRefresh, true); + window.addEventListener("resize", requireRefresh); document.addEventListener("click", onDocumentClick); window.addEventListener("keyup", onKeyup); teardown = [ - () => window.removeEventListener("scroll", onScroll, true), - () => window.removeEventListener("resize", onResize), + () => window.removeEventListener("scroll", requireRefresh, true), + () => window.removeEventListener("resize", requireRefresh), () => document.removeEventListener("click", onDocumentClick), () => window.removeEventListener("keyup", onKeyup), ]; @@ -424,15 +420,15 @@ export function hints(config: HintsConfig = {}): Hints { }; } - function closePopover() { + function close() { if (!popover) { return; } const entry = find(activeId!); - entry?.beacon.setAttribute("aria-expanded", "false"); - // Bring back a beacon that overlay mode tucked away. if (entry) { + entry.beacon.setAttribute("aria-expanded", "false"); + // Bring back a beacon that overlay mode tucked away. entry.beacon.style.display = ""; } @@ -450,7 +446,7 @@ export function hints(config: HintsConfig = {}): Hints { } // Only one hint is open at a time; opening another swaps it out. - closePopover(); + close(); activeId = entry.id; entry.beacon.setAttribute("aria-expanded", "true"); @@ -468,10 +464,6 @@ export function hints(config: HintsConfig = {}): Hints { onOpen?.(entry.element, entry.hint, { config: currentConfig, hints: api }); } - function close() { - closePopover(); - } - function toggle(id: string | number) { if (activeId === `${id}`) { close(); @@ -488,7 +480,7 @@ export function hints(config: HintsConfig = {}): Hints { } if (activeId === entry.id) { - closePopover(); + close(); } dismissed.add(entry.id); @@ -535,15 +527,15 @@ export function hints(config: HintsConfig = {}): Hints { } function show() { - if (isVisible) { - // Idempotent: re-resolve elements that have appeared since. - refresh(); - return; + if (!isVisible) { + isVisible = true; + bindListeners(); } - isVisible = true; + // mountHints() skips what is already on the page, so calling show() again + // picks up hints whose elements have appeared since. mountHints(); - bindListeners(); + refresh(); } function hide() { @@ -552,7 +544,7 @@ export function hints(config: HintsConfig = {}): Hints { } isVisible = false; - closePopover(); + close(); unmountHints(); teardown.forEach(off => off()); @@ -574,7 +566,7 @@ export function hints(config: HintsConfig = {}): Hints { return; } - closePopover(); + close(); unmountHints(); mountHints(); } diff --git a/packages/driver/src/overlay.ts b/packages/driver/src/overlay.ts index 3332b7b..f498587 100644 --- a/packages/driver/src/overlay.ts +++ b/packages/driver/src/overlay.ts @@ -150,7 +150,7 @@ function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement { return svg; } -// The full-screen dim with a rounded cutout, as a single evenodd path. Pure — +// The full-screen dim with a rounded cutout, as a single evenodd path. Pure: // the tour passes its stagePadding/stageRadius, the hints overlay its own. export function generateStageSvgPathString(stage: StageDefinition, options: { padding: number; radius: number }) { const windowX = window.innerWidth; diff --git a/packages/driver/src/popover.ts b/packages/driver/src/popover.ts index a961838..06487c7 100644 --- a/packages/driver/src/popover.ts +++ b/packages/driver/src/popover.ts @@ -53,7 +53,7 @@ export type PopoverDOM = { }; // Everything the primitive needs to render a popover, fully resolved by the -// caller — no config or state fallbacks happen at this level. +// caller; no config or state fallbacks happen at this level. export type PopoverRenderOptions = { title?: string; description?: string; diff --git a/packages/driver/src/step-popover.ts b/packages/driver/src/step-popover.ts index c8adae4..4ad089f 100644 --- a/packages/driver/src/step-popover.ts +++ b/packages/driver/src/step-popover.ts @@ -87,7 +87,7 @@ function resolveStepPopover(ctx: Context, element: Element, step: DriveStep): Po }, onRender: popoverDom => { - // Commit the popover to state before the user hook runs — the hook's + // Commit the popover to state before the user hook runs; the hook's // opts.state.popover has always pointed at the freshly rendered popover. ctx.setState("popover", popoverDom); diff --git a/packages/driver/tests/hints.test.ts b/packages/driver/tests/hints.test.ts index 7290362..89efdaf 100644 --- a/packages/driver/tests/hints.test.ts +++ b/packages/driver/tests/hints.test.ts @@ -107,17 +107,16 @@ describe("mounting beacons", () => { }); it("picks up an element that appears later on show()", () => { - const productHints = createHints({ hints: [{ element: "#late" }] }); + const productHints = createHints({ hints: [{ element: "#late" }, ...SAMPLE_HINTS] }); productHints.show(); - expect(beacons()).toHaveLength(0); + expect(beacons()).toHaveLength(2); const late = document.createElement("div"); late.id = "late"; document.body.appendChild(late); - productHints.hide(); productHints.show(); - expect(beacons()).toHaveLength(1); + expect(beacons()).toHaveLength(3); }); it("resolves an element node and a function element", () => {