From 80e8b363ec2e330bd9b08faf6bcc3784c191093f Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Sun, 18 Oct 2026 02:06:13 +0100 Subject: [PATCH] Fix hint positioning in transformed dialogs --- packages/driver/src/hints.css | 4 ++ packages/driver/src/hints.ts | 14 +++++-- packages/driver/src/popover.css | 24 ++++++++++++ packages/driver/src/popover.ts | 4 +- packages/driver/src/utils.ts | 41 +++++++++++++++++++- packages/driver/tests/hints.test.ts | 58 ++++++++++++++++++++++++++--- 6 files changed, 132 insertions(+), 13 deletions(-) diff --git a/packages/driver/src/hints.css b/packages/driver/src/hints.css index 94f6f00..0fc92c4 100644 --- a/packages/driver/src/hints.css +++ b/packages/driver/src/hints.css @@ -16,6 +16,10 @@ transform: translate(-50%, -50%); } +.driver-layer .driver-hint { + pointer-events: auto; +} + /* The open hint's beacon rises above the overlay, next to its popover. */ .driver-hint[aria-expanded="true"] { z-index: 999999998; diff --git a/packages/driver/src/hints.ts b/packages/driver/src/hints.ts index 637f39c..4b7b29e 100644 --- a/packages/driver/src/hints.ts +++ b/packages/driver/src/hints.ts @@ -9,7 +9,7 @@ import { } from "./popover"; import { generateStageSvgPathString } from "./stage"; import { ARROW_CORNER_INSET, PositionOptions, repositionPopover } from "./position"; -import { layerFor, resolveElement } from "./utils"; +import { layerFor, removeFromLayer, resolveElement } from "./utils"; import "./hints.css"; // The cutout around the active hint's element, mirroring the tour's defaults. @@ -208,6 +208,10 @@ export function hints(config: HintsConfig = {}): Hints { entry.observer = new IntersectionObserver(([intersection]) => { entry.beacon.classList.toggle("driver-hint-hidden", !intersection.isIntersecting); + if (intersection.isIntersecting) { + layerFor(entry.element); + } + if (!intersection.isIntersecting && activeId === entry.id) { close(); } @@ -249,7 +253,7 @@ export function hints(config: HintsConfig = {}): Hints { function unmountHints() { mounted.forEach(entry => { entry.observer?.disconnect(); - entry.beacon.remove(); + removeFromLayer(entry.beacon); }); mounted = []; @@ -403,7 +407,9 @@ export function hints(config: HintsConfig = {}): Hints { } function removeOverlay() { - overlay?.remove(); + if (overlay) { + removeFromLayer(overlay); + } overlay = undefined; } @@ -508,7 +514,7 @@ export function hints(config: HintsConfig = {}): Hints { dismissed.add(entry.id); entry.observer?.disconnect(); - entry.beacon.remove(); + removeFromLayer(entry.beacon); mounted = mounted.filter(mountedEntry => mountedEntry !== entry); const onDismiss = entry.hint.onDismiss || currentConfig.onDismiss; diff --git a/packages/driver/src/popover.css b/packages/driver/src/popover.css index f3c0e45..9bfcdf4 100644 --- a/packages/driver/src/popover.css +++ b/packages/driver/src/popover.css @@ -3,6 +3,30 @@ opened by a tour step or a hint. Keep tour-only rules (overlay, stage, body classes) out of here. */ +.driver-layer { + position: fixed; + inset: 0; + width: auto; + height: auto; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + overflow: visible; + pointer-events: none; +} + +.driver-layer::backdrop { + display: none; +} + +.driver-layer .driver-popover { + pointer-events: auto; +} + .driver-popover { all: unset; font-family: var( diff --git a/packages/driver/src/popover.ts b/packages/driver/src/popover.ts index 62ed19e..6113134 100644 --- a/packages/driver/src/popover.ts +++ b/packages/driver/src/popover.ts @@ -1,7 +1,7 @@ import type { DriverHook, HookOpts } from "./context"; import { destroyDriverClick, onDriverClick } from "./click"; import { PositionOptions, repositionPopover } from "./position"; -import { bringInView, getFocusableElements, layerFor } from "./utils"; +import { bringInView, getFocusableElements, layerFor, removeFromLayer } from "./utils"; export type Side = "top" | "right" | "bottom" | "left"; export type Alignment = "start" | "center" | "end"; @@ -316,5 +316,5 @@ export function destroyPopover(popover: PopoverDOM | undefined) { } destroyDriverClick(popover.wrapper); - popover.wrapper.parentElement?.removeChild(popover.wrapper); + removeFromLayer(popover.wrapper); } diff --git a/packages/driver/src/utils.ts b/packages/driver/src/utils.ts index 8cb8df6..5f5e5e7 100644 --- a/packages/driver/src/utils.ts +++ b/packages/driver/src/utils.ts @@ -83,8 +83,47 @@ export function isElementVisible(el: HTMLElement) { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); } +const dialogsWithLayer = new WeakSet(); + // A modal renders in the top layer, above everything in , so // UI for an element inside one has to live in that dialog to be visible. export function layerFor(element: Element): Element { - return element.closest("dialog[open]") ?? document.body; + const dialog = element.closest("dialog[open]"); + if (!dialog) { + return document.body; + } + + if (!("showPopover" in dialog)) { + return dialog; + } + + let layer = dialog.querySelector(":scope > .driver-layer"); + if (!layer) { + layer = document.createElement("div"); + layer.className = "driver-layer"; + layer.setAttribute("popover", "manual"); + dialog.appendChild(layer); + } + + if (!dialogsWithLayer.has(dialog)) { + dialogsWithLayer.add(dialog); + dialog.addEventListener("close", () => { + dialog.querySelector(":scope > .driver-layer:popover-open")?.hidePopover(); + }); + } + + if (!layer.matches(":popover-open")) { + layer.showPopover(); + } + + return layer; +} + +export function removeFromLayer(node: Element) { + const layer = node.parentElement; + node.remove(); + + if (layer?.classList.contains("driver-layer") && !layer.childElementCount) { + layer.remove(); + } } diff --git a/packages/driver/tests/hints.test.ts b/packages/driver/tests/hints.test.ts index f0d7bb7..afb30ff 100644 --- a/packages/driver/tests/hints.test.ts +++ b/packages/driver/tests/hints.test.ts @@ -984,18 +984,64 @@ describe("multiple instances", () => { }); describe("modal dialogs", () => { + const DIALOG_HTML = ``; + const DIALOG_HINT: DriverHint = { element: "#in-dialog", id: "d", popover: { title: "In dialog" } }; + // A modal dialog renders in the top layer, above anything left in . - it("mounts the beacon, overlay and popover inside the target's open dialog", () => { - document.body.insertAdjacentHTML("beforeend", ``); + describe("with the popover API", () => { + let showPopover: ReturnType; + + beforeEach(() => { + showPopover = vi.fn(); + Object.assign(HTMLElement.prototype, { showPopover, hidePopover: vi.fn() }); + }); + + afterEach(() => { + delete (HTMLElement.prototype as Partial).showPopover; + delete (HTMLElement.prototype as Partial).hidePopover; + }); + + it("mounts the beacon, overlay and popover in a top-layer host inside the dialog", () => { + document.body.insertAdjacentHTML("beforeend", DIALOG_HTML); + const dialog = document.querySelector("dialog")!; + + const h = createHints({ overlay: true, hints: [DIALOG_HINT] }); + h.show(); + h.open("d"); + + const layer = dialog.querySelector(":scope > .driver-layer")!; + expect(layer.getAttribute("popover")).toBe("manual"); + expect(showPopover).toHaveBeenCalled(); + expect(beacons()[0].parentElement).toBe(layer); + expect(document.querySelector(".driver-hint-overlay")!.parentElement).toBe(layer); + expect(popoverEl()!.parentElement).toBe(layer); + expect(dialog.querySelectorAll(".driver-layer")).toHaveLength(1); + }); + + it("removes the host once its UI is gone", () => { + document.body.insertAdjacentHTML("beforeend", DIALOG_HTML); + const dialog = document.querySelector("dialog")!; + + const h = createHints({ hints: [DIALOG_HINT] }); + h.show(); + h.open("d"); + h.hide(); + + expect(dialog.querySelector(".driver-layer")).toBeNull(); + }); + }); + + it("mounts inside the dialog itself without the popover API", () => { + document.body.insertAdjacentHTML("beforeend", DIALOG_HTML); const dialog = document.querySelector("dialog")!; - const h = createHints({ overlay: true, hints: [{ element: "#in-dialog", id: "d", popover: { title: "In dialog" } }] }); + const h = createHints({ overlay: true, hints: [DIALOG_HINT] }); h.show(); h.open("d"); - expect(dialog.contains(beacons()[0])).toBe(true); - expect(dialog.contains(document.querySelector(".driver-hint-overlay"))).toBe(true); - expect(dialog.contains(popoverEl())).toBe(true); + expect(beacons()[0].parentElement).toBe(dialog); + expect(document.querySelector(".driver-hint-overlay")!.parentElement).toBe(dialog); + expect(popoverEl()!.parentElement).toBe(dialog); }); it("keeps mounting in outside a dialog", () => {