diff --git a/docs/popover-scenarios.html b/docs/popover-scenarios.html new file mode 100644 index 00000000..607c51cb --- /dev/null +++ b/docs/popover-scenarios.html @@ -0,0 +1,252 @@ + + + + + + Popover Scenarios | bitvid + + + +
+
+

Popover positioning scenarios

+

+ 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. +

+
+ +
+
+

Grid edge cases

+

+ The rightmost card hugs the viewport edge to verify collision handling. +

+
+
+
+

Top left

+

Baseline placement without collisions.

+ +
+
+

Bottom right

+

+ Forces the panel to flip when it would overflow the viewport. +

+
+ +
+
+
+

Center

+

Acts as a control for viewport padding.

+
+ +
+
+
+
+ +
+
+

Video modal integration

+

+ Mimics the production video modal so we can verify popovers respect the overlay root. +

+
+ + +
+ +
+
+

Scroll container

+

+ Ensures auto-update keeps popovers aligned when the trigger lives in overflow regions. +

+
+
+
+

+ Scroll to the bottom to find the trigger used in automated tests. +

+
+ +
+
+
+
+
+ + + + + diff --git a/playwright.config.ts b/playwright.config.ts index 01c31f82..843b554a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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" }]] diff --git a/tests/e2e/popover.spec.ts b/tests/e2e/popover.spec.ts new file mode 100644 index 00000000..378fd6f7 --- /dev/null +++ b/tests/e2e/popover.spec.ts @@ -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); + }); +}); diff --git a/tests/position-floating-panel.test.mjs b/tests/position-floating-panel.test.mjs index 9a69c8e9..8afe224c 100644 --- a/tests/position-floating-panel.test.mjs +++ b/tests/position-floating-panel.test.mjs @@ -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( - ` -
- - -
- `, - { 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( + `
`, + { + 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( - ` -
- - -
- `, - { 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( - ` -
- - -
- `, - { 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( - ` -
- - -
- `, - { 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(); });