mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-10-30 19:27:40 +08:00
Cut the active element out of the hint overlay
The overlay is now the same spotlight the tour draws: an svg dim with a rounded cutout around the open hint's element (the path generator moves out of the tour's context into a pure function both share). The element stays visible and interactive inside the cutout — the svg lets pointer events through and only the dimmed path catches them, so clicking the dim still closes the hint. The other beacons now wait under the overlay instead of floating above it; only the open hint's beacon (via its aria-expanded state) and its popover rise above. The cutout tracks the element on scroll and resize. The arrow's drop-shadow halo is gone: it read as a dirty outline against light pages — tours never needed one because the arrow sits against the dark overlay, and hints now either touch the colored beacon (no overlay) or sit against the dim (overlay), so the plain white triangle works in both.
This commit is contained in:
@@ -16,6 +16,8 @@ ships, these entries move under a versioned heading.
|
||||
stays interactive. New `driver.js/hints` entry, ~5KB gzip.
|
||||
[Guide](/docs/hints). ([#363](https://github.com/nilbuild/driver.js/issues/363))
|
||||
- `--driver-hint-size` and `--driver-hint-color` CSS variables to style the beacons.
|
||||
- Optional hint overlay (`overlay`, `overlayColor`, `overlayOpacity`) — dims the
|
||||
page around the open hint's element, tour-style cutout included.
|
||||
- `sideEffects` field in package.json for better tree-shaking.
|
||||
|
||||
### Changed
|
||||
@@ -73,12 +75,10 @@ ships, these entries move under a versioned heading.
|
||||
|
||||
### Changed
|
||||
|
||||
- <span class="breaking-badge">Breaking</span> Popover footer buttons are now styled
|
||||
through the `driver-popover-footer-btn` class instead of the
|
||||
`.driver-popover-footer button` selector. Buttons you add through `onPopoverRender`
|
||||
no longer inherit the default styling automatically. Add the
|
||||
`driver-popover-footer-btn` class to opt in, or leave it off to apply your own
|
||||
styles. The built-in next/previous buttons carry the class out of the box.
|
||||
- <span class="breaking-badge">Breaking</span> Popover footer buttons are now styled through the
|
||||
`driver-popover-footer-btn` class instead of the `.driver-popover-footer button` selector. Buttons you add through
|
||||
`onPopoverRender` no longer inherit the default styling automatically. Add the `driver-popover-footer-btn` class to
|
||||
opt in, or leave it off to apply your own styles. The built-in next/previous buttons carry the class out of the box.
|
||||
([#555](https://github.com/nilbuild/driver.js/issues/555))
|
||||
- Custom buttons added through `onPopoverRender` now receive their own click
|
||||
events. Only the built-in next, previous, and close buttons have their default
|
||||
|
||||
@@ -126,9 +126,11 @@ Size and colour come from CSS variables:
|
||||
|
||||
## Dimming the page
|
||||
|
||||
Pass `overlay: true` to dim the page while a hint is open. The beacons and the
|
||||
popover stay above the overlay, so other hints remain clickable, and clicking
|
||||
the dimmed page closes the hint like any outside click:
|
||||
Pass `overlay: true` to dim the page while a hint is open — the hint's element
|
||||
is cut out of the dim, spotlighting it exactly like a tour step, and it stays
|
||||
interactive inside the cutout. Everything else, including the other beacons,
|
||||
sits under the overlay; clicking the dimmed page closes the hint like any
|
||||
outside click:
|
||||
|
||||
```js
|
||||
const productHints = hints({
|
||||
|
||||
@@ -147,8 +147,9 @@ const productHints = hints({
|
||||
## Dimming the page
|
||||
|
||||
With `overlay: true` the page dims while a hint is open — a focus mode for
|
||||
hints. The beacons stay above the overlay, so you can hop from one hint to the
|
||||
next; clicking the dimmed page closes the hint.
|
||||
hints. The hint's element is cut out of the dim and stays interactive, exactly
|
||||
like a tour step's highlight; the other beacons wait under the overlay.
|
||||
Clicking the dimmed page closes the hint.
|
||||
|
||||
<HintsSample
|
||||
idPrefix="overlay"
|
||||
@@ -159,7 +160,7 @@ next; clicking the dimmed page closes the hint.
|
||||
id: "export",
|
||||
popover: {
|
||||
title: "Focus mode",
|
||||
description: "The page behind is dimmed while you read this. Click the other beacon to hop over.",
|
||||
description: "The Export button is spotlighted and still clickable; the rest of the page is dimmed.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -167,8 +168,8 @@ next; clicking the dimmed page closes the hint.
|
||||
id: "summary",
|
||||
beacon: { side: "left", align: "center" },
|
||||
popover: {
|
||||
title: "Still reachable",
|
||||
description: "Beacons sit above the overlay, so switching hints never needs the overlay to go away.",
|
||||
title: "One at a time",
|
||||
description: "The other beacons wait under the dim. Close this hint to get back to them.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,12 +12,18 @@
|
||||
width: var(--driver-hint-size, 24px);
|
||||
height: var(--driver-hint-size, 24px);
|
||||
cursor: pointer;
|
||||
/* Below the popover, above the page. */
|
||||
z-index: 999999998;
|
||||
/* 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. */
|
||||
z-index: 999999996;
|
||||
/* The beacon is centered on the anchor point set inline by the script. */
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* The open hint's beacon rises above the overlay, next to its popover. */
|
||||
.driver-hint[aria-expanded="true"] {
|
||||
z-index: 999999998;
|
||||
}
|
||||
|
||||
.driver-hint-hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -69,20 +75,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The popover arrow is a white triangle that normally sits against a tour's
|
||||
darkened overlay. Hints usually have no overlay, so on a light page it
|
||||
would vanish and the popover would look detached from its beacon — a faint
|
||||
halo keeps it visible on any background. */
|
||||
.driver-hint-popover .driver-popover-arrow {
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.25)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
/* The opt-in overlay shown while a hint is open. Beacons and the popover sit
|
||||
above it, so hints stay clickable while the page behind is dimmed. */
|
||||
/* 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. */
|
||||
.driver-hint-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 999999997;
|
||||
pointer-events: none;
|
||||
animation: driver-hint-overlay-fade 200ms ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
Side,
|
||||
hidePopover,
|
||||
} from "./popover";
|
||||
import { generateStageSvgPathString } from "./overlay";
|
||||
import { ARROW_CORNER_INSET, ARROW_SIZE, PositionOptions, repositionPopover } from "./position";
|
||||
import { resolveElement } from "./utils";
|
||||
import "./hints.css";
|
||||
|
||||
// The cutout around the active hint's element, mirroring the tour's defaults.
|
||||
const OVERLAY_PADDING = 10;
|
||||
const OVERLAY_RADIUS = 5;
|
||||
|
||||
// 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";
|
||||
@@ -107,7 +112,7 @@ export function hints(config: HintsConfig = {}): Hints {
|
||||
|
||||
let activeId: string | undefined;
|
||||
let popover: PopoverDOM | undefined;
|
||||
let overlay: HTMLElement | undefined;
|
||||
let overlay: SVGSVGElement | undefined;
|
||||
let refreshTimeout: number | undefined;
|
||||
let isVisible = false;
|
||||
|
||||
@@ -322,16 +327,49 @@ export function hints(config: HintsConfig = {}): Hints {
|
||||
};
|
||||
}
|
||||
|
||||
function showOverlay() {
|
||||
function overlayPathFor(element: Element): string {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
return generateStageSvgPathString(
|
||||
{ x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
{ padding: OVERLAY_PADDING, radius: OVERLAY_RADIUS }
|
||||
);
|
||||
}
|
||||
|
||||
function showOverlay(entry: MountedHint) {
|
||||
if (!currentConfig.overlay || overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.className = "driver-hint-overlay";
|
||||
overlay.style.backgroundColor = currentConfig.overlayColor || "#000";
|
||||
overlay.style.opacity = `${currentConfig.overlayOpacity ?? 0.7}`;
|
||||
document.body.appendChild(overlay);
|
||||
// A full-screen dim with the hint's element cut out, so what the hint
|
||||
// talks about stays visible. Pointer events land on the path only: clicks
|
||||
// on the dim close the hint (via the outside-click listener) while the
|
||||
// element inside the cutout stays interactive.
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "driver-hint-overlay");
|
||||
svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
||||
svg.setAttribute("preserveAspectRatio", "xMinYMin slice");
|
||||
svg.style.fillRule = "evenodd";
|
||||
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", overlayPathFor(entry.element));
|
||||
path.style.fill = currentConfig.overlayColor || "#000";
|
||||
path.style.opacity = `${currentConfig.overlayOpacity ?? 0.7}`;
|
||||
path.style.pointerEvents = "auto";
|
||||
|
||||
svg.appendChild(path);
|
||||
document.body.appendChild(svg);
|
||||
|
||||
overlay = svg;
|
||||
}
|
||||
|
||||
function refreshOverlay(entry: MountedHint) {
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
||||
overlay.firstElementChild?.setAttribute("d", overlayPathFor(entry.element));
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
@@ -390,7 +428,7 @@ export function hints(config: HintsConfig = {}): Hints {
|
||||
|
||||
activeId = entry.id;
|
||||
entry.beacon.setAttribute("aria-expanded", "true");
|
||||
showOverlay();
|
||||
showOverlay(entry);
|
||||
popover = renderPopover(entry.beacon, popoverOptions(entry));
|
||||
|
||||
const onOpen = entry.hint.onOpen || currentConfig.onOpen;
|
||||
@@ -454,6 +492,7 @@ export function hints(config: HintsConfig = {}): Hints {
|
||||
}
|
||||
|
||||
repositionPopover(popover, active.beacon, popoverPosition(active));
|
||||
refreshOverlay(active);
|
||||
|
||||
// The beacon is gone from the screen, so the popover has nothing to hang
|
||||
// off; the IntersectionObserver closes it, this just avoids a flash.
|
||||
|
||||
@@ -102,7 +102,14 @@ function renderOverlay(ctx: Context, stagePosition: StageDefinition) {
|
||||
throw new Error("no path element found in stage svg");
|
||||
}
|
||||
|
||||
pathElement.setAttribute("d", generateStageSvgPathString(ctx, stagePosition));
|
||||
pathElement.setAttribute("d", generateStageSvgPathString(stagePosition, stageOptions(ctx)));
|
||||
}
|
||||
|
||||
function stageOptions(ctx: Context) {
|
||||
return {
|
||||
padding: ctx.getConfig("stagePadding") || 0,
|
||||
radius: ctx.getConfig("stageRadius") || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
|
||||
@@ -131,7 +138,7 @@ function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
|
||||
|
||||
const stagePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
stagePath.setAttribute("d", generateStageSvgPathString(ctx, stage));
|
||||
stagePath.setAttribute("d", generateStageSvgPathString(stage, stageOptions(ctx)));
|
||||
|
||||
stagePath.style.fill = ctx.getConfig("overlayColor") || "rgb(0,0,0)";
|
||||
stagePath.style.opacity = `${ctx.getConfig("overlayOpacity")}`;
|
||||
@@ -143,12 +150,14 @@ function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
|
||||
return svg;
|
||||
}
|
||||
|
||||
function generateStageSvgPathString(ctx: Context, stage: StageDefinition) {
|
||||
// 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;
|
||||
const windowY = window.innerHeight;
|
||||
|
||||
const stagePadding = ctx.getConfig("stagePadding") || 0;
|
||||
const stageRadius = ctx.getConfig("stageRadius") || 0;
|
||||
const stagePadding = options.padding;
|
||||
const stageRadius = options.radius;
|
||||
|
||||
const stageWidth = stage.width + stagePadding * 2;
|
||||
const stageHeight = stage.height + stagePadding * 2;
|
||||
|
||||
@@ -277,7 +277,8 @@ describe("popover arrow alignment", () => {
|
||||
});
|
||||
|
||||
describe("overlay", () => {
|
||||
const overlayEl = () => document.querySelector<HTMLElement>(".driver-hint-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 });
|
||||
@@ -320,8 +321,42 @@ describe("overlay", () => {
|
||||
productHints.show();
|
||||
productHints.open("intro");
|
||||
|
||||
expect(overlayEl()?.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect(overlayEl()?.style.opacity).toBe("0.4");
|
||||
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", () => {
|
||||
@@ -329,7 +364,8 @@ describe("overlay", () => {
|
||||
productHints.show();
|
||||
productHints.open("intro");
|
||||
|
||||
overlayEl()!.click();
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user