diff --git a/js/ui/overlay/overlayRoot.js b/js/ui/overlay/overlayRoot.js new file mode 100644 index 00000000..fb5e1400 --- /dev/null +++ b/js/ui/overlay/overlayRoot.js @@ -0,0 +1,63 @@ +const OVERLAY_ROOT_ID = "uiOverlay"; +const OVERLAY_COMPONENT = "overlay-root"; + +function resolveDocument(documentRef) { + if (documentRef && documentRef.nodeType === 9) { + return documentRef; + } + if (typeof document !== "undefined" && document?.nodeType === 9) { + return document; + } + return null; +} + +function isElement(node) { + return Boolean(node && typeof node === "object" && node.nodeType === 1); +} + +function applyRootAttributes(element) { + if (!element) { + return; + } + + if (element.getAttribute("aria-hidden") !== "true") { + element.setAttribute("aria-hidden", "true"); + } + if (!element.hasAttribute("data-overlay-root")) { + element.setAttribute("data-overlay-root", ""); + } + if (element.dataset.component !== OVERLAY_COMPONENT) { + element.dataset.component = OVERLAY_COMPONENT; + } +} + +function ensureAttached(element, documentRef) { + if (!element?.isConnected) { + const doc = resolveDocument(documentRef); + if (doc?.body) { + doc.body.appendChild(element); + } else if (doc?.documentElement) { + doc.documentElement.appendChild(element); + } + } +} + +export function ensureOverlayRoot(documentRef = null) { + const doc = resolveDocument(documentRef); + if (!doc) { + return null; + } + + let root = doc.getElementById(OVERLAY_ROOT_ID); + if (!isElement(root)) { + root = doc.createElement("div"); + root.id = OVERLAY_ROOT_ID; + } + + applyRootAttributes(root); + ensureAttached(root, doc); + + return root; +} + +export default ensureOverlayRoot; diff --git a/js/ui/overlay/popoverEngine.js b/js/ui/overlay/popoverEngine.js new file mode 100644 index 00000000..327a0235 --- /dev/null +++ b/js/ui/overlay/popoverEngine.js @@ -0,0 +1,441 @@ +import { + computePosition, + offset, + flip, + shift, + autoUpdate, + arrow as arrowMiddleware, +} from "@floating-ui/dom"; +import logger from "../../utils/logger.js"; +import { + getPopupOffsetPx, + getPopupViewportPaddingPx, + readDesignToken, +} from "../../designSystem/metrics.js"; +import { ensureOverlayRoot } from "./overlayRoot.js"; + +const DEFAULT_PLACEMENT = "bottom-start"; +const DEFAULT_STRATEGY = "fixed"; +const DEFAULT_MAX_WIDTH_TOKEN = "--popover-inline-safe-max"; +const STATIC_SIDE_MAP = Object.freeze({ + top: "bottom", + right: "left", + bottom: "top", + left: "right", +}); + +let activePopoverInstance = null; + +function isElement(node) { + return Boolean(node && typeof node === "object" && node.nodeType === 1); +} + +function resolveDocument(trigger, explicitDocument) { + if (explicitDocument && explicitDocument.nodeType === 9) { + return explicitDocument; + } + if (trigger?.ownerDocument?.nodeType === 9) { + return trigger.ownerDocument; + } + if (typeof document !== "undefined" && document?.nodeType === 9) { + return document; + } + return null; +} + +function resolveNumber(value, fallback) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function resolveTokenValue(tokenName, documentRef) { + if (typeof tokenName !== "string" || tokenName.trim() === "") { + return ""; + } + return readDesignToken(tokenName, { documentRef }) || ""; +} + +function resolveArrow(option, panel) { + if (!option) { + return null; + } + + if (typeof option === "function") { + try { + return resolveArrow(option(panel), panel); + } catch (error) { + logger.user.error("[popover] arrow resolver failed", error); + return null; + } + } + + if (isElement(option)) { + return option; + } + + if (typeof option === "object") { + if (option.element) { + return resolveArrow(option.element, panel); + } + } + + return null; +} + +function applyArrowStyles(arrowElement, placement, middlewareData) { + if (!arrowElement) { + return; + } + + const arrowData = middlewareData?.arrow || {}; + + arrowElement.style.left = + arrowData.x !== undefined && arrowData.x !== null ? `${arrowData.x}px` : ""; + arrowElement.style.top = + arrowData.y !== undefined && arrowData.y !== null ? `${arrowData.y}px` : ""; + arrowElement.style.right = ""; + arrowElement.style.bottom = ""; + + const [side] = placement.split("-"); + const staticSide = STATIC_SIDE_MAP[side]; + + if (staticSide) { + const size = + staticSide === "top" || staticSide === "bottom" + ? arrowElement.offsetHeight || 0 + : arrowElement.offsetWidth || 0; + arrowElement.style[staticSide] = `${-Math.max(size, 0)}px`; + } + + arrowElement.dataset.popoverArrowSide = side || ""; +} + +function containsTarget(root, target) { + if (!root || !target) { + return false; + } + if (typeof root.contains === "function") { + return root.contains(target); + } + return false; +} + +function setExpandedAttribute(trigger, value) { + if (!trigger || typeof trigger.setAttribute !== "function") { + return; + } + trigger.setAttribute("aria-expanded", value ? "true" : "false"); +} + +export function createPopover(trigger, render, options = {}) { + const anchor = isElement(trigger) ? trigger : null; + const documentRef = resolveDocument(anchor, options.document); + + const portal = documentRef ? documentRef.createElement("div") : null; + if (portal) { + portal.dataset.popoverPortal = "true"; + } + + const placement = + typeof options.placement === "string" && options.placement + ? options.placement + : DEFAULT_PLACEMENT; + const strategy = DEFAULT_STRATEGY; + const gap = resolveNumber( + options.gap, + getPopupOffsetPx({ documentRef }) || 0, + ); + const viewportPadding = resolveNumber( + options.viewportPadding, + getPopupViewportPaddingPx({ documentRef }) || 0, + ); + const maxWidthToken = + typeof options.maxWidthToken === "string" && options.maxWidthToken + ? options.maxWidthToken + : DEFAULT_MAX_WIDTH_TOKEN; + const maxHeightToken = + typeof options.maxHeightToken === "string" && options.maxHeightToken + ? options.maxHeightToken + : null; + const restoreFocusOnClose = options.restoreFocusOnClose !== false; + + let panel = null; + let arrowElement = null; + let autoUpdateCleanup = null; + let isOpen = false; + let previousActiveElement = null; + + function applyPanelTokens() { + if (!panel) { + return; + } + + if (maxWidthToken) { + const widthValue = resolveTokenValue(maxWidthToken, documentRef); + panel.style.maxWidth = widthValue || ""; + } + + if (maxHeightToken) { + const heightValue = resolveTokenValue(maxHeightToken, documentRef); + panel.style.maxHeight = heightValue || ""; + } + } + + function ensurePanel() { + if (panel && isElement(panel)) { + return panel; + } + if (!documentRef || typeof render !== "function" || !portal) { + return null; + } + + let result; + try { + result = render({ + container: portal, + trigger: anchor, + document: documentRef, + close, + update: () => updatePosition(), + }); + } catch (error) { + logger.user.error("[popover] render failed", error); + return null; + } + + if (isElement(result)) { + panel = result; + } else if (result && isElement(result.panel)) { + panel = result.panel; + if (!arrowElement) { + arrowElement = resolveArrow(result.arrow, panel); + } + } else if (portal.firstElementChild && isElement(portal.firstElementChild)) { + panel = portal.firstElementChild; + } + + if (!panel) { + logger.user.error("[popover] render did not return a panel element"); + return null; + } + + if (!panel.isConnected) { + portal.appendChild(panel); + } + + arrowElement = resolveArrow(options.arrow, panel) || arrowElement; + + applyPanelTokens(); + + return panel; + } + + async function updatePosition() { + if (!anchor || !panel) { + return; + } + + try { + const middleware = [ + offset(gap), + flip({ padding: viewportPadding }), + shift({ padding: viewportPadding }), + ]; + + if (arrowElement) { + middleware.push(arrowMiddleware({ element: arrowElement })); + } + + const { x, y, placement: resolvedPlacement, middlewareData } = + await computePosition(anchor, panel, { + placement, + strategy, + middleware, + }); + + panel.style.position = strategy; + panel.style.left = `${x}px`; + panel.style.top = `${y}px`; + panel.dataset.popoverPlacement = resolvedPlacement; + + if (arrowElement) { + applyArrowStyles(arrowElement, resolvedPlacement, middlewareData); + } + } catch (error) { + logger.user.error("[popover] positioning failed", error); + } + } + + function handlePointerDown(event) { + const target = event?.target || null; + if (containsTarget(panel, target) || containsTarget(anchor, target)) { + return; + } + close(); + } + + function handleKeydown(event) { + if (event?.key === "Escape") { + close(); + } + } + + function bindGlobalHandlers() { + if (!documentRef) { + return; + } + documentRef.addEventListener("pointerdown", handlePointerDown, true); + documentRef.addEventListener("keydown", handleKeydown, true); + } + + function unbindGlobalHandlers() { + if (!documentRef) { + return; + } + documentRef.removeEventListener("pointerdown", handlePointerDown, true); + documentRef.removeEventListener("keydown", handleKeydown, true); + } + + async function open() { + if (!anchor || !documentRef) { + return false; + } + + const overlayRoot = ensureOverlayRoot(documentRef); + if (!overlayRoot) { + logger.user.error("[popover] unable to resolve overlay root"); + return false; + } + + const panelElement = ensurePanel(); + if (!panelElement) { + return false; + } + + if (!portal.isConnected) { + overlayRoot.appendChild(portal); + } + + if (activePopoverInstance && activePopoverInstance !== api) { + activePopoverInstance.close({ restoreFocus: false }); + } + + previousActiveElement = documentRef.activeElement || null; + + applyPanelTokens(); + + panelElement.hidden = false; + panelElement.setAttribute("aria-hidden", "false"); + panelElement.dataset.popoverState = "open"; + setExpandedAttribute(anchor, true); + + bindGlobalHandlers(); + + if (autoUpdateCleanup) { + autoUpdateCleanup(); + autoUpdateCleanup = null; + } + + autoUpdateCleanup = autoUpdate(anchor, panelElement, updatePosition, { + animationFrame: false, + }); + + await updatePosition(); + + isOpen = true; + activePopoverInstance = api; + return true; + } + + function close({ restoreFocus = restoreFocusOnClose } = {}) { + if (!isOpen) { + if (restoreFocus && restoreFocusOnClose) { + restoreTriggerFocus(); + } + return false; + } + + isOpen = false; + unbindGlobalHandlers(); + + if (autoUpdateCleanup) { + autoUpdateCleanup(); + autoUpdateCleanup = null; + } + + if (panel) { + panel.setAttribute("aria-hidden", "true"); + panel.hidden = true; + panel.dataset.popoverState = "closed"; + } + + setExpandedAttribute(anchor, false); + + if (restoreFocus && restoreFocusOnClose) { + restoreTriggerFocus(); + } + + if (activePopoverInstance === api) { + activePopoverInstance = null; + } + + previousActiveElement = null; + + return true; + } + + function restoreTriggerFocus() { + if (anchor && typeof anchor.focus === "function") { + try { + anchor.focus({ preventScroll: true }); + return; + } catch (error) { + anchor.focus(); + } + } + + if (previousActiveElement && typeof previousActiveElement.focus === "function") { + try { + previousActiveElement.focus({ preventScroll: true }); + } catch (error) { + previousActiveElement.focus(); + } + } + } + + function toggle() { + if (isOpen) { + close(); + return false; + } + return open(); + } + + function destroy() { + close({ restoreFocus: false }); + + if (panel && panel.parentNode === portal) { + portal.removeChild(panel); + } + if (portal?.parentNode) { + portal.parentNode.removeChild(portal); + } + + panel = null; + arrowElement = null; + } + + const api = { + open, + close, + toggle, + destroy, + update: updatePosition, + isOpen: () => isOpen, + getPanel: () => panel, + }; + + return api; +} + +export default createPopover; diff --git a/package-lock.json b/package-lock.json index 1c47cccc..03e05ddb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "GPL-3.0-or-later", "dependencies": { + "@floating-ui/dom": "^1.6.10", "path": "^0.12.7", "webtorrent": "^1.9.7" }, @@ -708,6 +709,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index f2f92d88..7496c05e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "license": "GPL-3.0-or-later", "dependencies": { + "@floating-ui/dom": "^1.6.10", "path": "^0.12.7", "webtorrent": "^1.9.7" } diff --git a/tests/ui/popoverEngine.test.mjs b/tests/ui/popoverEngine.test.mjs new file mode 100644 index 00000000..ff9dafdb --- /dev/null +++ b/tests/ui/popoverEngine.test.mjs @@ -0,0 +1,327 @@ +import test, { beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import createPopover from "../../js/ui/overlay/popoverEngine.js"; + +let dom; +let documentRef; +let windowRef; + +class StubObserver { + constructor() { + this.observe = () => {}; + this.unobserve = () => {}; + this.disconnect = () => {}; + } +} + +function setupBoundingClientRect(element, rect) { + element.getBoundingClientRect = () => ({ ...rect }); + Object.defineProperties(element, { + offsetWidth: { value: rect.width, configurable: true }, + offsetHeight: { value: rect.height, configurable: true }, + }); +} + +beforeEach(() => { + dom = new JSDOM( + `
`, + { + pretendToBeVisual: true, + url: "https://example.com", + }, + ); + + windowRef = dom.window; + documentRef = windowRef.document; + + windowRef.innerWidth = 1024; + windowRef.innerHeight = 768; + Object.defineProperty(documentRef.documentElement, "clientWidth", { + value: 1024, + configurable: true, + }); + Object.defineProperty(documentRef.documentElement, "clientHeight", { + value: 768, + configurable: true, + }); + + 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("opens a popover in the overlay root and positions the panel", async () => { + const trigger = documentRef.getElementById("trigger"); + trigger.setAttribute("tabindex", "0"); + + setupBoundingClientRect(trigger, { + x: 100, + y: 100, + top: 100, + bottom: 140, + left: 100, + right: 140, + width: 40, + height: 40, + }); + + const popover = createPopover( + trigger, + ({ container }) => { + const panel = documentRef.createElement("div"); + panel.id = "popover-panel"; + setupBoundingClientRect(panel, { + x: 0, + y: 0, + top: 0, + bottom: 120, + left: 0, + right: 200, + width: 200, + height: 120, + }); + container.appendChild(panel); + return panel; + }, + { document: documentRef }, + ); + + await popover.open(); + + const overlayRoot = documentRef.getElementById("uiOverlay"); + assert.ok(overlayRoot, "overlay root should be created"); + assert.equal(overlayRoot.getAttribute("aria-hidden"), "true"); + assert.equal(overlayRoot.dataset.component, "overlay-root"); + + const panel = documentRef.getElementById("popover-panel"); + assert.ok(panel, "panel should be rendered"); + assert.equal(panel.dataset.popoverState, "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(trigger.getAttribute("aria-expanded"), "true"); + + popover.close(); + assert.equal(panel.dataset.popoverState, "closed"); + assert.equal(trigger.getAttribute("aria-expanded"), "false"); + + popover.destroy(); +}); + +test("closes on outside pointer events and restores focus", async () => { + const trigger = documentRef.getElementById("trigger"); + trigger.setAttribute("tabindex", "0"); + + setupBoundingClientRect(trigger, { + x: 50, + y: 50, + top: 50, + bottom: 90, + left: 50, + right: 90, + width: 40, + height: 40, + }); + + const popover = createPopover( + trigger, + ({ container }) => { + const panel = documentRef.createElement("div"); + setupBoundingClientRect(panel, { + x: 0, + y: 0, + top: 0, + bottom: 60, + left: 0, + right: 160, + width: 160, + height: 60, + }); + container.appendChild(panel); + return panel; + }, + { document: documentRef }, + ); + + trigger.focus(); + await popover.open(); + + const other = documentRef.createElement("button"); + other.id = "outside"; + documentRef.body.appendChild(other); + other.focus(); + assert.equal(documentRef.activeElement, other); + + const event = new windowRef.Event("pointerdown", { bubbles: true, composed: true }); + other.dispatchEvent(event); + + assert.equal(popover.isOpen(), false); + assert.equal(documentRef.activeElement, trigger); + + popover.destroy(); +}); + +test("ensures only one popover is open at a time", async () => { + const trigger = documentRef.getElementById("trigger"); + trigger.setAttribute("tabindex", "0"); + setupBoundingClientRect(trigger, { + x: 20, + y: 20, + top: 20, + bottom: 60, + left: 20, + right: 60, + width: 40, + height: 40, + }); + + const secondTrigger = documentRef.createElement("button"); + secondTrigger.id = "second"; + secondTrigger.setAttribute("tabindex", "0"); + documentRef.body.appendChild(secondTrigger); + setupBoundingClientRect(secondTrigger, { + x: 220, + y: 20, + top: 20, + bottom: 60, + left: 220, + right: 260, + width: 40, + height: 40, + }); + + const makePanel = (id) => ({ container }) => { + const panel = documentRef.createElement("div"); + panel.id = id; + setupBoundingClientRect(panel, { + x: 0, + y: 0, + top: 0, + bottom: 80, + left: 0, + right: 160, + width: 160, + height: 80, + }); + container.appendChild(panel); + return panel; + }; + + const popoverA = createPopover(trigger, makePanel("panel-a"), { + document: documentRef, + }); + const popoverB = createPopover(secondTrigger, makePanel("panel-b"), { + document: documentRef, + }); + + await popoverA.open(); + assert.equal(popoverA.isOpen(), true); + + await popoverB.open(); + assert.equal(popoverB.isOpen(), true); + assert.equal(popoverA.isOpen(), false); + + const panelA = documentRef.getElementById("panel-a"); + const panelB = documentRef.getElementById("panel-b"); + assert.equal(panelA.dataset.popoverState, "closed"); + assert.equal(panelB.dataset.popoverState, "open"); + + popoverA.destroy(); + popoverB.destroy(); +}); + +test("applies token-based sizing and arrow positioning", async () => { + const trigger = documentRef.getElementById("trigger"); + trigger.setAttribute("tabindex", "0"); + setupBoundingClientRect(trigger, { + x: 120, + y: 120, + top: 120, + bottom: 160, + left: 120, + right: 160, + width: 40, + height: 40, + }); + + documentRef.documentElement.style.setProperty("--popover-inline-safe-max", "320px"); + documentRef.documentElement.style.setProperty("--overlay-panel-padding-block", "240px"); + + const popover = createPopover( + trigger, + ({ container }) => { + const panel = documentRef.createElement("div"); + panel.id = "panel-with-arrow"; + panel.className = "panel"; + setupBoundingClientRect(panel, { + x: 0, + y: 0, + top: 0, + bottom: 100, + left: 0, + right: 200, + width: 200, + height: 100, + }); + + const arrow = documentRef.createElement("div"); + arrow.className = "arrow"; + setupBoundingClientRect(arrow, { + x: 0, + y: 0, + top: 0, + bottom: 8, + left: 0, + right: 8, + width: 8, + height: 8, + }); + container.appendChild(panel); + panel.appendChild(arrow); + return { panel, arrow }; + }, + { + document: documentRef, + maxWidthToken: "--popover-inline-safe-max", + maxHeightToken: "--overlay-panel-padding-block", + arrow: (panel) => panel.querySelector(".arrow"), + }, + ); + + await popover.open(); + + const panel = documentRef.getElementById("panel-with-arrow"); + const arrow = panel.querySelector(".arrow"); + + assert.equal(panel.style.maxWidth, "320px"); + assert.equal(panel.style.maxHeight, "240px"); + assert.equal(arrow.dataset.popoverArrowSide, "bottom"); + assert.notEqual(arrow.style.top, ""); + + popover.destroy(); +});