import test 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"; 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)); } 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 }, ); const { window } = dom; const { document } = window; window.innerWidth = 360; window.innerHeight = 320; const trigger = document.getElementById("trigger"); const panel = document.getElementById("panel"); trigger.getBoundingClientRect = () => ({ top: 260, bottom: 300, left: 100, right: 140, 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 styles = createFloatingPanelStyles(panel); const positioner = positionFloatingPanel(trigger, panel, { offset: 8, viewportPadding: 12, styles, }); panel.hidden = false; panel.dataset.state = "open"; positioner.update(); 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"); positioner.destroy(); assert.equal(panel.dataset.floatingPanel, undefined); assert.equal(trigger.dataset.floatingAnchor, undefined); const ruleAfterDestroy = getScopeRule(document, scopeId); assert.equal(ruleAfterDestroy, null); }); test("respects RTL alignment and clamps within the viewport", () => { const dom = new JSDOM( `
`, { pretendToBeVisual: true, url: "https://example.com" }, ); const { window } = dom; const { document } = window; window.innerWidth = 320; window.innerHeight = 240; document.documentElement.dir = "rtl"; document.body.dir = "rtl"; const trigger = document.getElementById("trigger"); const panel = document.getElementById("panel"); trigger.getBoundingClientRect = () => ({ top: 32, bottom: 72, left: 220, right: 260, width: 40, height: 40, }); panel.getBoundingClientRect = () => ({ top: 0, 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 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 styles = createFloatingPanelStyles(panel); const positioner = positionFloatingPanel(trigger, panel, { offset: 12, styles, }); 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 }, ); const { window } = dom; const { document } = window; window.innerWidth = 640; window.innerHeight = 480; const trigger = document.getElementById("trigger"); const panel = document.getElementById("panel"); 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; });