mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
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:
5 files changed
+301
-8
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user