mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-10-31 03:52:13 +08:00
Fix hint positioning in transformed dialogs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user