Clean up the hints module

show() now mounts hints whose elements have appeared since the last
call, which the docs already promised but only a full hide()/show()
delivered; mountHints() skips what is already on the page, making
show() naturally idempotent and simpler.

The closePopover()/close() pair collapses into one function, the
scroll and resize listeners use requireRefresh directly instead of
wrapper closures, and config/dismissed become consts.

Comments get a pass: the HintsConfig overlay comment still described
the pre-cutout semantics (beacons above the overlay) and is corrected;
obvious comments are dropped, the load-bearing ones tightened, and em
dashes across source comments replaced per Kamran's preference.
This commit is contained in:
Kamran Ahmed
2026-07-16 22:25:21 +01:00
parent 95df19e079
commit 09484c7261
8 changed files with 54 additions and 73 deletions
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -45,6 +45,7 @@
overflow: hidden !important;
}
.driver-no-interaction, .driver-no-interaction * {
.driver-no-interaction,
.driver-no-interaction * {
pointer-events: none !important;
}
+14 -25
View File
@@ -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;
}
+31 -39
View File
@@ -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<string>();
let mounted: MountedHint[] = [];
let dismissed = new Set<string>();
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();
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+3 -4
View File
@@ -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", () => {