Fix hint positioning in transformed dialogs

This commit is contained in:
Kamran Ahmed
2026-10-18 12:55:11 +01:00
parent ca44ea064a
commit 80e8b363ec
6 changed files with 132 additions and 13 deletions
+4
View File
@@ -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;
+10 -4
View File
@@ -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;
+24
View File
@@ -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(
+2 -2
View File
@@ -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);
}
+40 -1
View File
@@ -83,8 +83,47 @@ export function isElementVisible(el: HTMLElement) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
const dialogsWithLayer = new WeakSet<Element>();
// A modal <dialog> renders in the top layer, above everything in <body>, 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<HTMLElement>(":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<HTMLElement>(":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();
}
}
+52 -6
View File
@@ -984,18 +984,64 @@ describe("multiple instances", () => {
});
describe("modal dialogs", () => {
const DIALOG_HTML = `<dialog open><button id="in-dialog">Save</button></dialog>`;
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 <body>.
it("mounts the beacon, overlay and popover inside the target's open dialog", () => {
document.body.insertAdjacentHTML("beforeend", `<dialog open><button id="in-dialog">Save</button></dialog>`);
describe("with the popover API", () => {
let showPopover: ReturnType<typeof vi.fn>;
beforeEach(() => {
showPopover = vi.fn();
Object.assign(HTMLElement.prototype, { showPopover, hidePopover: vi.fn() });
});
afterEach(() => {
delete (HTMLElement.prototype as Partial<HTMLElement>).showPopover;
delete (HTMLElement.prototype as Partial<HTMLElement>).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 <body> outside a dialog", () => {