Add popover regression coverage

This commit is contained in:
thePR0M3TH3AN committed 2025-10-16 13:52:06 -04:00
1 parent d9de74a446
commit 2fb2eea9f0
4 files changed
+658 -259

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Popover Scenarios | bitvid</title>
<link rel="stylesheet" href="../css/tailwind.generated.css" />
</head>
<body class="min-h-screen bg-page text-text" data-ds="new">
<main class="mx-auto flex max-w-6xl flex-col gap-xl px-lg py-xl">
<header class="space-y-sm">
<h1 class="text-2xl font-semibold">Popover positioning scenarios</h1>
<p class="max-w-3xl text-sm text-muted">
Interactive harness that exercises the popover engine near the edges of the
viewport, inside modals, and within scroll containers. Playwright scenarios
rely on the data attributes surfaced here.
</p>
</header>
<section class="space-y-md" data-test-section="grid">
<div class="space-y-xs">
<h2 class="text-xl font-semibold">Grid edge cases</h2>
<p class="text-sm text-muted">
The rightmost card hugs the viewport edge to verify collision handling.
</p>
</div>
<div class="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
<article class="card space-y-sm p-md">
<h3 class="text-lg font-semibold">Top left</h3>
<p class="text-sm text-muted">Baseline placement without collisions.</p>
<button
class="btn-ghost"
type="button"
data-test-trigger="grid-top-left"
aria-label="Open top-left menu"
>
Menu
</button>
</article>
<article class="card space-y-sm p-md" data-test-card="grid-bottom-right">
<h3 class="text-lg font-semibold">Bottom right</h3>
<p class="text-sm text-muted">
Forces the panel to flip when it would overflow the viewport.
</p>
<div class="flex justify-end">
<button
class="btn-ghost"
type="button"
data-test-trigger="grid-bottom-right"
aria-label="Open bottom-right menu"
>
Menu
</button>
</div>
</article>
<article class="card space-y-sm p-md">
<h3 class="text-lg font-semibold">Center</h3>
<p class="text-sm text-muted">Acts as a control for viewport padding.</p>
<div class="flex justify-center">
<button
class="btn-ghost"
type="button"
data-test-trigger="grid-center"
aria-label="Open center menu"
>
Menu
</button>
</div>
</article>
</div>
</section>
<section class="space-y-md" data-test-section="modal">
<div class="space-y-xs">
<h2 class="text-xl font-semibold">Video modal integration</h2>
<p class="text-sm text-muted">
Mimics the production video modal so we can verify popovers respect the overlay root.
</p>
</div>
<button class="btn w-40" type="button" data-test-open-modal>
Open modal
</button>
<div
class="fixed inset-0 z-overlay-modal hidden items-center justify-center bg-overlay/60 backdrop-blur"
role="dialog"
aria-modal="true"
aria-labelledby="testModalTitle"
data-test-modal
>
<div class="bv-modal__panel flex max-h-modal-shell flex-col gap-md p-lg">
<header class="flex items-center justify-between gap-sm">
<div>
<p class="text-xs uppercase tracking-wide text-muted">Now playing</p>
<h3 id="testModalTitle" class="text-lg font-semibold">
Edge-of-viewport sample
</h3>
</div>
<button class="btn-ghost" type="button" data-test-close-modal>
Close
</button>
</header>
<div class="space-y-md">
<p class="text-sm text-muted">
Interact with the more menu to verify popovers inside scroll-locked contexts.
</p>
<div class="flex justify-end">
<button
class="btn-ghost"
type="button"
data-test-trigger="modal-menu"
aria-label="Open modal menu"
>
Menu
</button>
</div>
</div>
</div>
</div>
</section>
<section class="space-y-md" data-test-section="scroll">
<div class="space-y-xs">
<h2 class="text-xl font-semibold">Scroll container</h2>
<p class="text-sm text-muted">
Ensures auto-update keeps popovers aligned when the trigger lives in overflow regions.
</p>
</div>
<div
class="h-64 overflow-auto rounded-xl border border-border bg-surface-alt p-md"
data-test-scroll-region
>
<div class="space-y-lg" style="height: 720px">
<p class="text-sm text-muted">
Scroll to the bottom to find the trigger used in automated tests.
</p>
<div class="mt-96 flex justify-center">
<button
class="btn"
type="button"
data-test-trigger="scroll-bottom"
aria-label="Open scroll container menu"
>
Open menu
</button>
</div>
</div>
</div>
</section>
</main>
<script type="module">
import createPopover from "../js/ui/overlay/popoverEngine.js";
const ACTIVE_LABELS = ["Play", "Save", "Share", "Open in new tab"];
function buildMenu({ container, trigger, id }) {
const panel = container.ownerDocument.createElement("div");
panel.className = "popover__panel card w-64 max-w-popover-safe space-y-sm";
panel.dataset.testPanel = id;
const heading = container.ownerDocument.createElement("h4");
heading.className = "text-sm font-semibold text-text";
heading.textContent = trigger?.getAttribute("aria-label") || "Menu";
panel.appendChild(heading);
const menu = container.ownerDocument.createElement("div");
menu.className = "menu space-y-1";
ACTIVE_LABELS.forEach((label, index) => {
const item = container.ownerDocument.createElement("button");
item.className = "menu__item justify-between";
item.type = "button";
item.setAttribute("role", "menuitem");
item.textContent = label;
item.dataset.index = String(index);
menu.appendChild(item);
});
panel.appendChild(menu);
container.appendChild(panel);
return panel;
}
function attachPopover(trigger, { id }) {
const popover = createPopover(
trigger,
({ container }) => buildMenu({ container, trigger, id }),
{ document }
);
trigger.addEventListener("click", async (event) => {
event.preventDefault();
await popover.open();
});
return popover;
}
const triggers = [
{ selector: "[data-test-trigger=grid-top-left]", id: "grid-top-left" },
{ selector: "[data-test-trigger=grid-bottom-right]", id: "grid-bottom-right" },
{ selector: "[data-test-trigger=grid-center]", id: "grid-center" },
{ selector: "[data-test-trigger=modal-menu]", id: "modal-menu" },
{ selector: "[data-test-trigger=scroll-bottom]", id: "scroll-bottom" }
];
const popovers = new Map();
triggers.forEach(({ selector, id }) => {
const trigger = document.querySelector(selector);
if (trigger) {
popovers.set(id, attachPopover(trigger, { id }));
}
});
const modal = document.querySelector("[data-test-modal]");
const openModalBtn = document.querySelector("[data-test-open-modal]");
const closeModalBtn = document.querySelector("[data-test-close-modal]");
if (openModalBtn && modal) {
openModalBtn.addEventListener("click", () => {
modal.classList.remove("hidden");
modal.removeAttribute("hidden");
document.documentElement.classList.add("modal-open");
document.body.classList.add("modal-open");
});
}
if (closeModalBtn && modal) {
closeModalBtn.addEventListener("click", () => {
modal.classList.add("hidden");
modal.setAttribute("hidden", "");
document.documentElement.classList.remove("modal-open");
document.body.classList.remove("modal-open");
const modalPopover = popovers.get("modal-menu");
modalPopover?.close();
});
}
window.__popoverTestHarness = {
popovers,
open(id) {
return popovers.get(id)?.open();
},
close(id) {
return popovers.get(id)?.close();
}
};
</script>
<div id="uiOverlay" data-overlay-root aria-hidden="true"></div>
</body>
</html>
+2 -1
View File
@@ -5,7 +5,8 @@ const PORT = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "4173", 10);
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}`;
export default defineConfig({
testDir: "./tests/visual",
testDir: "./tests",
testMatch: ["visual/**/*.spec.ts", "e2e/**/*.spec.ts"],
timeout: 60_000,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from "@playwright/test";
import type { Locator } from "@playwright/test";
test.describe("popover layout scenarios", () => {
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" });
});
async function getPanelMetrics(locator: Locator) {
return locator.evaluate((node) => {
const rect = node.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tokenValue = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--popover-inline-safe-max")
.trim();
return {
rect: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
inlineMaxWidth: node.style.maxWidth,
tokenMaxWidth: tokenValue,
};
});
}
function assertWithinViewport(metrics: {
rect: { top: number; left: number; right: number; bottom: number };
viewport: { width: number; height: number };
}) {
const tolerance = 0.5;
expect(metrics.rect.left).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.top).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.right).toBeLessThanOrEqual(metrics.viewport.width + tolerance);
expect(metrics.rect.bottom).toBeLessThanOrEqual(metrics.viewport.height + tolerance);
}
test("keeps the bottom-right grid menu inside the viewport", async ({ page }) => {
await page.locator('[data-test-trigger="grid-bottom-right"]').click();
const panel = page.locator('[data-test-panel="grid-bottom-right"]');
await expect(panel).toHaveAttribute("data-popover-state", "open");
const metrics = await getPanelMetrics(panel);
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement.startsWith("top")).toBeTruthy();
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics.tokenMaxWidth.length).toBeGreaterThan(0);
});
test("opens the video modal menu without overflowing", async ({ page }) => {
await page.locator('[data-test-open-modal]').click();
await expect(page.locator('[data-test-modal]')).toBeVisible();
await page.locator('[data-test-trigger="modal-menu"]').click();
const panel = page.locator('[data-test-panel="modal-menu"]');
await expect(panel).toHaveAttribute("data-popover-state", "open");
const metrics = await getPanelMetrics(panel);
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement.includes("bottom")).toBeTruthy();
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics.tokenMaxWidth.length).toBeGreaterThan(0);
});
test("keeps scroll container menus in view after scrolling", async ({ page }) => {
const scrollRegion = page.locator('[data-test-scroll-region]');
await scrollRegion.evaluate((node) => {
node.scrollTop = node.scrollHeight;
});
await page.locator('[data-test-trigger="scroll-bottom"]').click();
const panel = page.locator('[data-test-panel="scroll-bottom"]');
await expect(panel).toHaveAttribute("data-popover-state", "open");
const metrics = await getPanelMetrics(panel);
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics.tokenMaxWidth.length).toBeGreaterThan(0);
});
});
+307 -258
View File
@@ -1,302 +1,351 @@
import test from "node:test";
import test, { beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import positionFloatingPanel from "../js/ui/utils/positionFloatingPanel.js";
import { createFloatingPanelStyles } from "../js/ui/utils/floatingPanelStyles.js";
import createPopover from "../js/ui/overlay/popoverEngine.js";
function getScopeRule(document, scopeId) {
const styleEl = document.querySelector('style[data-ds-dynamic="true"]');
const rules = [];
if (styleEl?.sheet?.cssRules) {
rules.push(...Array.from(styleEl.sheet.cssRules));
let dom;
let documentRef;
let windowRef;
class StubObserver {
constructor() {
this.observe = () => {};
this.unobserve = () => {};
this.disconnect = () => {};
}
const adoptedSheets = document.adoptedStyleSheets;
if (Array.isArray(adoptedSheets)) {
for (const sheet of adoptedSheets) {
if (sheet?.cssRules) {
rules.push(...Array.from(sheet.cssRules));
}
}
}
return rules.find((cssRule) => cssRule.selectorText === `[data-ds-style-id="${scopeId}"]`) || null;
}
test("flips to top when bottom placement collides with viewport", () => {
const dom = new JSDOM(
`
<div class="popover">
<button id="trigger">Open</button>
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
</div>
`,
{ pretendToBeVisual: true },
function setupBoundingClientRect(element, rect) {
element.getBoundingClientRect = () => ({ ...rect });
Object.defineProperties(element, {
offsetWidth: { value: rect.width, configurable: true },
offsetHeight: { value: rect.height, configurable: true },
});
}
function attachFocus(element, activeElementRef) {
element.focus = () => {
activeElementRef.active = element;
if (typeof element.setAttribute === "function") {
element.setAttribute("data-focused", "true");
}
};
}
beforeEach(() => {
dom = new JSDOM(
`<!DOCTYPE html><html><body data-ds="new"><div id="root"></div></body></html>`,
{
pretendToBeVisual: true,
url: "https://example.com",
},
);
const { window } = dom;
const { document } = window;
window.innerWidth = 360;
window.innerHeight = 320;
windowRef = dom.window;
documentRef = windowRef.document;
const trigger = document.getElementById("trigger");
const panel = document.getElementById("panel");
windowRef.innerWidth = 320;
windowRef.innerHeight = 320;
Object.defineProperty(documentRef.documentElement, "clientWidth", {
value: 320,
configurable: true,
});
Object.defineProperty(documentRef.documentElement, "clientHeight", {
value: 320,
configurable: true,
});
trigger.getBoundingClientRect = () => ({
global.window = windowRef;
global.document = documentRef;
global.HTMLElement = windowRef.HTMLElement;
global.Element = windowRef.Element;
global.Node = windowRef.Node;
global.getComputedStyle = windowRef.getComputedStyle.bind(windowRef);
global.ResizeObserver = StubObserver;
if (!global.PointerEvent) {
global.PointerEvent = windowRef.PointerEvent || windowRef.Event;
}
});
afterEach(() => {
delete global.window;
delete global.document;
delete global.HTMLElement;
delete global.Element;
delete global.Node;
delete global.getComputedStyle;
delete global.ResizeObserver;
if (dom) {
dom.window.close();
}
dom = null;
documentRef = null;
windowRef = null;
});
test("flips placement when bottom placement would collide with viewport", async () => {
const trigger = documentRef.createElement("button");
trigger.id = "trigger";
trigger.type = "button";
trigger.textContent = "Open";
trigger.setAttribute("tabindex", "0");
documentRef.body.appendChild(trigger);
const active = { active: null };
attachFocus(trigger, active);
setupBoundingClientRect(trigger, {
x: 180,
y: 260,
top: 260,
bottom: 300,
left: 100,
right: 140,
left: 180,
right: 220,
width: 40,
height: 40,
});
panel.getBoundingClientRect = () => ({
top: 0,
bottom: 120,
left: 0,
right: 200,
width: 200,
height: 120,
});
Object.defineProperties(panel, {
offsetWidth: { value: 200 },
offsetHeight: { value: 120 },
});
const popover = createPopover(
trigger,
({ container }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel card";
panel.dataset.testPanel = "collision";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 140,
left: 0,
right: 220,
width: 220,
height: 140,
});
const styles = createFloatingPanelStyles(panel);
panel.focus = () => {
active.active = panel;
};
const positioner = positionFloatingPanel(trigger, panel, {
offset: 8,
viewportPadding: 12,
styles,
});
const menu = documentRef.createElement("ul");
menu.className = "menu";
for (let index = 0; index < 2; index += 1) {
const item = documentRef.createElement("button");
item.className = "menu__item";
item.setAttribute("role", "menuitem");
item.textContent = `Action ${index + 1}`;
item.focus = () => {
active.active = item;
};
menu.appendChild(item);
}
panel.appendChild(menu);
container.appendChild(panel);
return panel;
},
{ document: documentRef },
);
panel.hidden = false;
panel.dataset.state = "open";
positioner.update();
const opened = await popover.open();
assert.equal(opened, true, "popover should open");
assert.equal(panel.dataset.floatingPlacement, "top");
assert.equal(styles.getStrategy(), "fixed");
assert.deepEqual(styles.getFallbackPosition(), { top: 132, left: 100 });
assert.equal(panel.dataset.floatingMode, "fallback");
const scopeId = panel.getAttribute("data-ds-style-id");
assert.ok(scopeId, "panel should receive a dynamic style scope id");
const rule = getScopeRule(document, scopeId);
assert.ok(rule, "scope rule should exist for the floating panel");
const ruleStyleTop = rule ? rule["style"] : null;
assert.ok(ruleStyleTop, "floating panel rule should expose a CSSStyleDeclaration");
assert.equal(ruleStyleTop.getPropertyValue("--floating-fallback-top"), "132px");
assert.equal(ruleStyleTop.getPropertyValue("--floating-fallback-left"), "100px");
const panel = documentRef.querySelector('[data-test-panel="collision"]');
assert.ok(panel, "panel should render");
assert.equal(panel.dataset.popoverState, "open");
assert.equal(panel.dataset.popoverPlacement, "top-start");
assert.equal(panel.style.position, "fixed");
assert.equal(panel.style.maxWidth, "calc(100vw - var(--space-xl))");
positioner.destroy();
assert.equal(panel.dataset.floatingPanel, undefined);
assert.equal(trigger.dataset.floatingAnchor, undefined);
const ruleAfterDestroy = getScopeRule(document, scopeId);
assert.equal(ruleAfterDestroy, null);
const top = Number.parseInt(panel.style.top, 10);
const left = Number.parseInt(panel.style.left, 10);
assert.ok(Number.isFinite(top));
assert.ok(Number.isFinite(left));
assert.ok(top >= 0, "top should stay within viewport");
assert.ok(left >= 0, "left should stay within viewport");
assert.ok(top + panel.offsetHeight <= windowRef.innerHeight);
assert.ok(left + panel.offsetWidth <= windowRef.innerWidth);
popover.destroy();
});
test("respects RTL alignment and clamps within the viewport", () => {
const dom = new JSDOM(
`
<div dir="rtl" class="popover">
<button id="trigger">Open</button>
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
</div>
`,
{ pretendToBeVisual: true, url: "https://example.com" },
test("closes previously open popovers without restoring focus", async () => {
const container = documentRef.getElementById("root");
const active = { active: null };
const createTrigger = (id, rectLeft) => {
const button = documentRef.createElement("button");
button.id = id;
button.type = "button";
button.textContent = id;
button.setAttribute("tabindex", "0");
attachFocus(button, active);
setupBoundingClientRect(button, {
x: rectLeft,
y: 80,
top: 80,
bottom: 120,
left: rectLeft,
right: rectLeft + 40,
width: 40,
height: 40,
});
container.appendChild(button);
return button;
};
const triggerA = createTrigger("triggerA", 40);
const triggerB = createTrigger("triggerB", 160);
let panelA;
let panelB;
const popoverA = createPopover(
triggerA,
({ container: portal }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel";
panel.dataset.testPanel = "panel-a";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 100,
left: 0,
right: 160,
width: 160,
height: 100,
});
panel.focus = () => {
active.active = panel;
};
portal.appendChild(panel);
panelA = panel;
return panel;
},
{ document: documentRef },
);
const { window } = dom;
const { document } = window;
window.innerWidth = 320;
window.innerHeight = 240;
document.documentElement.dir = "rtl";
document.body.dir = "rtl";
const popoverB = createPopover(
triggerB,
({ container: portal }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel";
panel.dataset.testPanel = "panel-b";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 100,
left: 0,
right: 160,
width: 160,
height: 100,
});
panel.focus = () => {
active.active = panel;
};
portal.appendChild(panel);
panelB = panel;
return panel;
},
{ document: documentRef },
);
const trigger = document.getElementById("trigger");
const panel = document.getElementById("panel");
await popoverA.open();
assert.equal(popoverA.isOpen(), true);
assert.equal(triggerA.getAttribute("aria-expanded"), "true");
assert.equal(panelA?.dataset.popoverState, "open");
trigger.getBoundingClientRect = () => ({
top: 32,
bottom: 72,
left: 220,
right: 260,
width: 40,
height: 40,
});
await popoverB.open();
assert.equal(popoverB.isOpen(), true);
assert.equal(triggerB.getAttribute("aria-expanded"), "true");
assert.equal(panelB?.dataset.popoverState, "open");
panel.getBoundingClientRect = () => ({
top: 0,
assert.equal(popoverA.isOpen(), false, "first popover should close");
assert.equal(triggerA.getAttribute("aria-expanded"), "false");
assert.equal(panelA?.dataset.popoverState, "closed");
assert.notEqual(active.active, triggerA, "focus should not jump back to first trigger");
popoverA.destroy();
popoverB.destroy();
});
test("restores focus to the trigger when closed", async () => {
const trigger = documentRef.createElement("button");
trigger.id = "restoreTrigger";
trigger.type = "button";
trigger.textContent = "Restore";
trigger.setAttribute("tabindex", "0");
documentRef.body.appendChild(trigger);
const active = { active: null };
attachFocus(trigger, active);
setupBoundingClientRect(trigger, {
x: 40,
y: 40,
top: 40,
bottom: 80,
left: 0,
right: 120,
width: 120,
height: 80,
});
Object.defineProperties(panel, {
offsetWidth: { value: 120 },
offsetHeight: { value: 80 },
});
const styles = createFloatingPanelStyles(panel);
const positioner = positionFloatingPanel(trigger, panel, {
alignment: "start",
viewportPadding: 16,
styles,
});
panel.hidden = false;
panel.dataset.state = "open";
positioner.update();
// In RTL mode, start alignment hugs the right edge of the trigger.
assert.equal(panel.dataset.floatingPlacement, "bottom");
assert.equal(panel.dataset.floatingDir, "rtl");
assert.deepEqual(styles.getFallbackPosition(), { top: 72 + 8, left: 140 });
positioner.destroy();
});
test("updates when scroll containers move the trigger", async () => {
const dom = new JSDOM(
`
<div class="popover">
<button id="trigger">Open</button>
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
</div>
`,
{ pretendToBeVisual: true },
);
const { window } = dom;
const { document } = window;
window.innerWidth = 480;
window.innerHeight = 360;
const trigger = document.getElementById("trigger");
const panel = document.getElementById("panel");
let anchorTop = 100;
trigger.getBoundingClientRect = () => ({
top: anchorTop,
bottom: anchorTop + 40,
left: 40,
right: 80,
width: 40,
height: 40,
});
panel.getBoundingClientRect = () => ({
top: 0,
bottom: 60,
left: 0,
right: 180,
width: 180,
height: 60,
});
Object.defineProperties(panel, {
offsetWidth: { value: 180 },
offsetHeight: { value: 60 },
});
let firstItem = null;
const popover = createPopover(
trigger,
({ container }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel";
panel.dataset.testPanel = "restore";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 80,
left: 0,
right: 160,
width: 160,
height: 80,
});
panel.focus = () => {
active.active = panel;
};
let scrollHandler = null;
const originalAddEventListener = window.addEventListener;
window.addEventListener = (type, listener, options) => {
if (type === "scroll" && options === true) {
scrollHandler = listener;
}
return originalAddEventListener.call(window, type, listener, options);
};
const menu = documentRef.createElement("ul");
menu.className = "menu";
const styles = createFloatingPanelStyles(panel);
firstItem = documentRef.createElement("button");
firstItem.className = "menu__item";
firstItem.setAttribute("role", "menuitem");
firstItem.textContent = "Play";
attachFocus(firstItem, active);
menu.appendChild(firstItem);
const positioner = positionFloatingPanel(trigger, panel, {
offset: 12,
styles,
});
const secondItem = documentRef.createElement("button");
secondItem.className = "menu__item";
secondItem.setAttribute("role", "menuitem");
secondItem.textContent = "Share";
attachFocus(secondItem, active);
menu.appendChild(secondItem);
panel.hidden = false;
panel.dataset.state = "open";
positioner.update();
assert.deepEqual(styles.getFallbackPosition(), { top: 152, left: 40 });
assert.ok(scrollHandler, "scroll listener should be registered");
const scopeId = panel.getAttribute("data-ds-style-id");
const rule = getScopeRule(document, scopeId);
assert.ok(rule, "dynamic rule should be present for scroll updates");
const fallbackRuleStyle = rule ? rule["style"] : null;
assert.ok(fallbackRuleStyle, "dynamic rule should be present for scroll updates");
assert.equal(fallbackRuleStyle.getPropertyValue("--floating-fallback-top"), "152px");
anchorTop = 60;
scrollHandler?.({ type: "scroll" });
assert.deepEqual(styles.getFallbackPosition(), { top: 112, left: 40 });
assert.equal(fallbackRuleStyle.getPropertyValue("--floating-fallback-top"), "112px");
positioner.destroy();
window.addEventListener = originalAddEventListener;
});
test("prefers anchor positioning when supported", () => {
const dom = new JSDOM(
`
<div class="popover">
<button id="trigger">Open</button>
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
</div>
`,
{ pretendToBeVisual: true },
panel.appendChild(menu);
container.appendChild(panel);
return panel;
},
{ document: documentRef },
);
const { window } = dom;
const { document } = window;
window.innerWidth = 640;
window.innerHeight = 480;
trigger.focus();
await popover.open();
assert.equal(popover.isOpen(), true);
assert.equal(active.active, firstItem);
const trigger = document.getElementById("trigger");
const panel = document.getElementById("panel");
const closed = popover.close();
assert.equal(closed, true);
assert.equal(popover.isOpen(), false);
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(active.active, trigger, "focus should return to trigger");
trigger.getBoundingClientRect = () => ({
top: 100,
bottom: 140,
left: 200,
right: 240,
width: 40,
height: 40,
});
panel.getBoundingClientRect = () => ({
top: 0,
bottom: 90,
left: 0,
right: 200,
width: 200,
height: 90,
});
Object.defineProperties(panel, {
offsetWidth: { value: 200 },
offsetHeight: { value: 90 },
});
const originalCSS = window.CSS;
window.CSS = {
supports: (value) =>
value === "anchor-name: --floating-panel" || value === "position-anchor: --floating-panel",
};
const positioner = positionFloatingPanel(trigger, panel, {
offset: 10,
});
panel.hidden = false;
panel.dataset.state = "open";
positioner.update();
assert.equal(panel.dataset.floatingMode, "anchor");
assert.equal(positioner.styles.getMode(), "anchor");
assert.equal(panel.dataset.floatingPlacement, "bottom");
assert.deepEqual(positioner.styles.getFallbackPosition(), { top: 150, left: 200 });
positioner.destroy();
window.CSS = originalCSS;
popover.destroy();
});