Merge pull request #604 from PR0M3TH3AN/codex/update-popover-tests-and-documentation-2b2kpf

test: add alignment checks for popover panels
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-16 17:05:49 -04:00
commit d1dd816c10
5 files changed
+301 -8

No files matched your search

+5
View File
@@ -167,3 +167,8 @@ Then open `http://localhost:8000/views/dev/popover-demo.html` to interact with a
sample video card and modal wired to the popover engine. The page ensures the
overlay root exists, demonstrates Tailwind overlay helpers, and exercises both
card and modal action menus without relying on production data.
- ✅ **Anchored alignment check:** Open the video-card “More” menu and the
modal action menu. Each panel’s right edge should stay flush with the trigger
button. Playwright now verifies this via `tests/e2e/popover.spec.ts`, but QA
should still confirm the alignment while running the manual demo.
+93
View File
@@ -35,6 +35,52 @@ test.describe("popover layout scenarios", () => {
});
}
async function getPanelWithTriggerMetrics(panel: Locator, trigger: Locator) {
const triggerHandle = await trigger.elementHandle();
if (!triggerHandle) {
throw new Error("Trigger element is not attached");
}
try {
return await panel.evaluate((node, triggerElement) => {
const rect = node.getBoundingClientRect();
const triggerRect = triggerElement.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,
},
triggerRect: {
top: triggerRect.top,
left: triggerRect.left,
right: triggerRect.right,
bottom: triggerRect.bottom,
width: triggerRect.width,
height: triggerRect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
inlineMaxWidth: node.style.maxWidth,
tokenMaxWidth: tokenValue,
};
}, triggerHandle);
} finally {
await triggerHandle.dispose();
}
}
function assertWithinViewport(metrics: {
rect: { top: number; left: number; right: number; bottom: number };
viewport: { width: number; height: number };
@@ -95,3 +141,50 @@ test.describe("popover layout scenarios", () => {
expect(metrics.tokenMaxWidth.length).toBeGreaterThan(0);
});
});
test.describe("popover demo alignments", () => {
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/views/dev/popover-demo.html", { waitUntil: "networkidle" });
});
test("aligns the video card menu with the trigger's right edge", async ({ page }) => {
const trigger = page.locator("[data-demo-card-more]");
await trigger.click();
const panel = page.locator('[data-component="overlay-root"] [data-popover-state="open"]');
await expect(panel).toBeVisible();
const metrics = await getPanelWithTriggerMetrics(panel, trigger);
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement).toBe("bottom-end");
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
const tolerance = 1.5;
expect(Math.abs(metrics.rect.right - metrics.triggerRect.right)).toBeLessThanOrEqual(tolerance);
});
test("aligns the modal gear menu with the trigger's right edge", async ({ page }) => {
await page.locator("[data-demo-open-modal]").click();
await expect(page.locator("[data-demo-modal]"))
.toBeVisible({ timeout: 5000 });
const trigger = page.locator("[data-demo-modal-more]");
await trigger.click();
const panel = page.locator('[data-component="overlay-root"] [data-popover-state="open"]');
await expect(panel).toBeVisible();
const metrics = await getPanelWithTriggerMetrics(panel, trigger);
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement).toBe("bottom-end");
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
const tolerance = 1.5;
expect(Math.abs(metrics.rect.right - metrics.triggerRect.right)).toBeLessThanOrEqual(tolerance);
});
});
+60 -3
View File
@@ -1,11 +1,15 @@
import test, { beforeEach, afterEach } from "node:test";
import test, { beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import createPopover from "../js/ui/overlay/popoverEngine.js";
import { register } from "node:module";
let dom;
let documentRef;
let windowRef;
let createPopover;
let computePositionStub;
let autoUpdateStub;
let mockedPosition;
class StubObserver {
constructor() {
@@ -32,7 +36,18 @@ function attachFocus(element, activeElementRef) {
};
}
beforeEach(() => {
function setMockedPosition(overrides = {}) {
mockedPosition = {
x: 0,
y: 0,
placement: "bottom-start",
middlewareData: {},
strategy: "fixed",
...overrides,
};
}
beforeEach(async () => {
dom = new JSDOM(
`<!DOCTYPE html><html><body data-ds="new"><div id="root"></div></body></html>`,
{
@@ -65,6 +80,38 @@ beforeEach(() => {
if (!global.PointerEvent) {
global.PointerEvent = windowRef.PointerEvent || windowRef.Event;
}
windowRef.scrollTo = () => {};
documentRef.documentElement.style.setProperty(
"--popover-inline-safe-max",
"calc(100vw - var(--space-xl))",
);
documentRef.documentElement.style.setProperty(
"--overlay-panel-padding-block",
"240px",
);
setMockedPosition();
computePositionStub = mock.fn(async () => ({ ...mockedPosition }));
autoUpdateStub = mock.fn((reference, floating, update) => {
if (typeof update === "function") {
update();
}
return () => {};
});
globalThis.__floatingUiMock = {
arrow: (...args) => ({ name: "arrow", args }),
autoUpdate: autoUpdateStub,
computePosition: computePositionStub,
flip: (...args) => ({ name: "flip", args }),
offset: (...args) => ({ name: "offset", args }),
shift: (...args) => ({ name: "shift", args }),
};
register(new URL("./ui/mocks/floating-ui-test-loader.mjs", import.meta.url));
({ default: createPopover } = await import("../js/ui/overlay/popoverEngine.js"));
});
afterEach(() => {
@@ -75,12 +122,18 @@ afterEach(() => {
delete global.Node;
delete global.getComputedStyle;
delete global.ResizeObserver;
delete globalThis.__floatingUiMock;
mock.restoreAll();
if (dom) {
dom.window.close();
}
dom = null;
documentRef = null;
windowRef = null;
createPopover = null;
computePositionStub = null;
autoUpdateStub = null;
mockedPosition = null;
});
test("flips placement when bottom placement would collide with viewport", async () => {
@@ -105,6 +158,8 @@ test("flips placement when bottom placement would collide with viewport", async
height: 40,
});
setMockedPosition({ x: 96.3, y: 80.7, placement: "top-start" });
const popover = createPopover(
trigger,
({ container }) => {
@@ -154,6 +209,8 @@ test("flips placement when bottom placement would collide with viewport", async
assert.equal(panel.dataset.state, "open");
assert.equal(panel.dataset.popoverPlacement, "top-start");
assert.equal(panel.style.position, "fixed");
assert.equal(panel.style.left, "96px");
assert.equal(panel.style.top, "81px");
assert.equal(panel.style.maxWidth, "calc(100vw - var(--space-xl))");
const top = Number.parseInt(panel.style.top, 10);
@@ -0,0 +1,28 @@
const MOCK_URL = "floating-ui:mock";
export function resolve(specifier, context, defaultResolve) {
if (
specifier === "@floating-ui/dom" ||
specifier.endsWith("vendor/floating-ui.dom.bundle.min.js")
) {
return { url: MOCK_URL, shortCircuit: true };
}
return defaultResolve(specifier, context, defaultResolve);
}
export async function load(url, context, defaultLoad) {
if (url === MOCK_URL) {
const source = `const mock = globalThis.__floatingUiMock || {};
export const arrow = mock.arrow;
export const autoUpdate = mock.autoUpdate;
export const computePosition = mock.computePosition;
export const flip = mock.flip;
export const offset = mock.offset;
export const shift = mock.shift;
`;
return { format: "module", source, shortCircuit: true };
}
return defaultLoad(url, context, defaultLoad);
}
+115 -5
View File
@@ -1,11 +1,15 @@
import test, { beforeEach, afterEach } from "node:test";
import test, { beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import createPopover from "../../js/ui/overlay/popoverEngine.js";
import { register } from "node:module";
let dom;
let documentRef;
let windowRef;
let createPopover;
let computePositionStub;
let autoUpdateStub;
let mockedPosition;
class StubObserver {
constructor() {
@@ -23,7 +27,18 @@ function setupBoundingClientRect(element, rect) {
});
}
beforeEach(() => {
function setMockedPosition(overrides = {}) {
mockedPosition = {
x: 0,
y: 0,
placement: "bottom-start",
middlewareData: {},
strategy: "fixed",
...overrides,
};
}
beforeEach(async () => {
dom = new JSDOM(
`<!DOCTYPE html><html><body><div id="app"><button id="trigger">Open</button></div></body></html>`,
{
@@ -59,6 +74,29 @@ beforeEach(() => {
if (!global.PointerEvent) {
global.PointerEvent = windowRef.PointerEvent || windowRef.Event;
}
setMockedPosition();
computePositionStub = mock.fn(async () => ({ ...mockedPosition }));
autoUpdateStub = mock.fn((reference, floating, update) => {
if (typeof update === "function") {
update();
}
return () => {};
});
globalThis.__floatingUiMock = {
arrow: (...args) => ({ name: "arrow", args }),
autoUpdate: autoUpdateStub,
computePosition: computePositionStub,
flip: (...args) => ({ name: "flip", args }),
offset: (...args) => ({ name: "offset", args }),
shift: (...args) => ({ name: "shift", args }),
};
register(new URL("./mocks/floating-ui-test-loader.mjs", import.meta.url));
({ default: createPopover } = await import("../../js/ui/overlay/popoverEngine.js"));
});
afterEach(() => {
@@ -70,12 +108,18 @@ afterEach(() => {
delete global.getComputedStyle;
delete global.ResizeObserver;
delete global.IntersectionObserver;
delete globalThis.__floatingUiMock;
mock.restoreAll();
if (dom) {
dom.window.close();
}
dom = null;
documentRef = null;
windowRef = null;
createPopover = null;
computePositionStub = null;
autoUpdateStub = null;
mockedPosition = null;
});
test("opens a popover in the overlay root and positions the panel", async () => {
@@ -93,6 +137,8 @@ test("opens a popover in the overlay root and positions the panel", async () =>
height: 40,
});
setMockedPosition({ x: 172.6, y: 248.4, placement: "bottom-start" });
const popover = createPopover(
trigger,
({ container }) => {
@@ -131,8 +177,8 @@ test("opens a popover in the overlay root and positions the panel", async () =>
assert.equal(panel.dataset.state, "open");
assert.equal(panel.dataset.popoverPlacement, "bottom-start");
assert.equal(panel.style.position, "fixed");
assert.equal(panel.style.left, "100px");
assert.equal(panel.style.top, "148px");
assert.equal(panel.style.left, "173px");
assert.equal(panel.style.top, "248px");
assert.equal(panel.getAttribute("role"), "menu");
assert.equal(documentRef.activeElement, panel);
assert.equal(trigger.getAttribute("aria-expanded"), "true");
@@ -145,6 +191,65 @@ test("opens a popover in the overlay root and positions the panel", async () =>
popover.destroy();
});
test("positions bottom-end panels flush with the trigger's right edge", async () => {
const trigger = documentRef.getElementById("trigger");
trigger.setAttribute("tabindex", "0");
setupBoundingClientRect(trigger, {
x: 240,
y: 180,
top: 180,
bottom: 220,
left: 240,
right: 340,
width: 100,
height: 40,
});
const panelWidth = 220;
setMockedPosition({
x: 120.3,
y: 264.7,
placement: "bottom-end",
});
const popover = createPopover(
trigger,
({ container }) => {
const panel = documentRef.createElement("div");
panel.id = "popover-bottom-end";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 140,
left: 0,
right: panelWidth,
width: panelWidth,
height: 140,
});
container.appendChild(panel);
return panel;
},
{ document: documentRef, placement: "bottom-end" },
);
await popover.open();
const panel = documentRef.getElementById("popover-bottom-end");
assert.ok(panel);
assert.equal(panel.dataset.popoverPlacement, "bottom-end");
assert.equal(panel.style.left, "120px");
assert.equal(panel.style.top, "265px");
const triggerRight = Math.round(trigger.getBoundingClientRect().right);
const panelRight = Number.parseInt(panel.style.left, 10) + panel.offsetWidth;
assert.equal(panelRight, triggerRight);
popover.destroy();
});
test("closes on outside pointer events and restores focus", async () => {
const trigger = documentRef.getElementById("trigger");
trigger.setAttribute("tabindex", "0");
@@ -626,6 +731,11 @@ test("applies token-based sizing and arrow positioning", async () => {
},
);
setMockedPosition({
placement: "bottom",
middlewareData: { arrow: { x: 12.4, y: 8.6 } },
});
await popover.open();
const panel = documentRef.getElementById("panel-with-arrow");