From 40224ad0f8e7618e4c1a73687739721e321b5882 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:05:04 -0400 Subject: [PATCH] test: add alignment checks for popover panels --- docs/menus.md | 5 + tests/e2e/popover.spec.ts | 93 ++++++++++++++++ tests/position-floating-panel.test.mjs | 63 ++++++++++- tests/ui/mocks/floating-ui-test-loader.mjs | 28 +++++ tests/ui/popoverEngine.test.mjs | 120 ++++++++++++++++++++- 5 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 tests/ui/mocks/floating-ui-test-loader.mjs diff --git a/docs/menus.md b/docs/menus.md index 9e4c47b3..b12e1746 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -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. diff --git a/tests/e2e/popover.spec.ts b/tests/e2e/popover.spec.ts index 378fd6f7..6da5be3a 100644 --- a/tests/e2e/popover.spec.ts +++ b/tests/e2e/popover.spec.ts @@ -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); + }); +}); diff --git a/tests/position-floating-panel.test.mjs b/tests/position-floating-panel.test.mjs index 27a9ef72..8e56345c 100644 --- a/tests/position-floating-panel.test.mjs +++ b/tests/position-floating-panel.test.mjs @@ -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( `
`, { @@ -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); diff --git a/tests/ui/mocks/floating-ui-test-loader.mjs b/tests/ui/mocks/floating-ui-test-loader.mjs new file mode 100644 index 00000000..d24dbcb9 --- /dev/null +++ b/tests/ui/mocks/floating-ui-test-loader.mjs @@ -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); +} diff --git a/tests/ui/popoverEngine.test.mjs b/tests/ui/popoverEngine.test.mjs index cc653ad1..c7cbee3b 100644 --- a/tests/ui/popoverEngine.test.mjs +++ b/tests/ui/popoverEngine.test.mjs @@ -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( `
`, { @@ -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");