diff --git a/js/ui/overlay/popoverEngine.js b/js/ui/overlay/popoverEngine.js index 00285ebd..431e58c0 100644 --- a/js/ui/overlay/popoverEngine.js +++ b/js/ui/overlay/popoverEngine.js @@ -5,6 +5,11 @@ import { readDesignToken, } from "../../designSystem/metrics.js"; import { ensureOverlayRoot } from "./overlayRoot.js"; +import { + isEditableTarget, + movePanelIntoPortal, + restorePanelToOrigin, +} from "./popoverPanelDom.js"; import { arrow as arrowMiddleware, autoUpdate, @@ -245,6 +250,9 @@ export function createPopover(trigger, render, options = {}) { let autoUpdateCleanup = null; let isOpen = false; let previousActiveElement = null; + // Origin of a pre-existing app-owned panel relocated into the portal on open + // (see movePanelIntoPortal); used to put it back on close/destroy. + let panelOrigin = null; let menuItemIdCounter = 0; const menuHandlers = { keydown: null, @@ -582,6 +590,13 @@ export function createPopover(trigger, render, options = {}) { return; } + // Never hijack keys while the user types in a form field inside the panel + // (zap comment/amount, search boxes, etc.) — let characters and caret keys + // through instead of routing them to menu typeahead/navigation. + if (isEditableTarget(event.target)) { + return; + } + menuState.items = findMenuItems(menuState.panel); switch (event.key) { @@ -915,6 +930,13 @@ export function createPopover(trigger, render, options = {}) { overlayRoot.appendChild(portal); } + // Pull a pre-existing in-host panel out to the portal so fixed positioning is + // viewport-relative (fixes the zap/embed dialog anchoring to the modal edge). + const movedOrigin = movePanelIntoPortal(panel, portal); + if (movedOrigin) { + panelOrigin = movedOrigin; + } + if (activePopoverInstance && activePopoverInstance !== api) { activePopoverInstance.close({ restoreFocus: false }); } @@ -992,6 +1014,11 @@ export function createPopover(trigger, render, options = {}) { setExpandedAttribute(anchor, false); resetTypeaheadBuffer(); + // Return a relocated app-owned panel to its original DOM home (no-op for + // fresh portal-owned panels) so the host DOM stays intact and re-open works. + restorePanelToOrigin(panel, panelOrigin); + panelOrigin = null; + if (restoreFocus && restoreFocusOnClose) { restoreTriggerFocus(); } diff --git a/js/ui/overlay/popoverPanelDom.js b/js/ui/overlay/popoverPanelDom.js new file mode 100644 index 00000000..f73119e0 --- /dev/null +++ b/js/ui/overlay/popoverPanelDom.js @@ -0,0 +1,64 @@ +// Pure DOM helpers for the popover engine, extracted to keep popoverEngine.js +// under its size budget. + +function isElement(node) { + return Boolean(node) && typeof node === "object" && node.nodeType === 1; +} + +// True when the event target is a form control / editable element. The engine +// treats panels as ARIA menus (arrow-key nav + single-char typeahead, both +// preventDefault'd); that must NOT hijack keystrokes when the user is typing in a +// panel that contains inputs (e.g. the zap comment/amount fields) — otherwise +// letters and caret keys never reach the field and "you can't type" in the popup. +export function isEditableTarget(target) { + if (!isElement(target)) { + return false; + } + const tag = typeof target.tagName === "string" ? target.tagName.toLowerCase() : ""; + if (tag === "input" || tag === "textarea" || tag === "select") { + return true; + } + if (target.isContentEditable === true) { + return true; + } + const role = + typeof target.getAttribute === "function" ? target.getAttribute("role") : ""; + return role === "textbox" || role === "searchbox" || role === "combobox"; +} + +// Move a panel into the portal (body-level overlay root) for the duration of the +// open state, so `position: fixed` is viewport-relative rather than resolving +// against a transformed/contained in-modal ancestor. Fresh render-created panels +// are already in the portal (no-op); this only relocates pre-existing, app-owned +// panels. Returns the origin { parent, nextSibling } to restore later, or null. +export function movePanelIntoPortal(panel, portal) { + if (!panel || !portal || panel.parentNode === portal) { + return null; + } + const origin = panel.parentNode + ? { parent: panel.parentNode, nextSibling: panel.nextSibling } + : null; + portal.appendChild(panel); + return origin; +} + +// Put a relocated app-owned panel back exactly where it came from so the host +// (e.g. the video modal) keeps an intact DOM and the popover can be re-opened. +export function restorePanelToOrigin(panel, origin) { + if (!panel || !origin) { + return; + } + const { parent, nextSibling } = origin; + if (!parent || typeof parent.insertBefore !== "function") { + return; + } + try { + if (nextSibling && nextSibling.parentNode === parent) { + parent.insertBefore(panel, nextSibling); + } else { + parent.appendChild(panel); + } + } catch (error) { + // Best-effort: a failed restore must not break close(). + } +} diff --git a/tests/ui/popoverEngine.test.mjs b/tests/ui/popoverEngine.test.mjs index bbad1da7..7b8a1c5e 100644 --- a/tests/ui/popoverEngine.test.mjs +++ b/tests/ui/popoverEngine.test.mjs @@ -762,3 +762,143 @@ test("applies token-based sizing and arrow positioning", async () => { popover.destroy(); }); + +test("relocates a pre-existing in-host panel into the overlay portal on open and restores it on close", async () => { + // Regression for pre-launch TODO #3a: the in-modal zap/embed dialog is a + // pre-existing element returned verbatim by its render fn. It was never moved + // into the body-level portal, so its `position: fixed` resolved against a + // transformed/contained modal ancestor and the panel anchored to the modal edge + // instead of bottom-end under its trigger. The engine must relocate such panels + // into the portal on open (viewport-relative positioning) and restore them on + // close so the host DOM stays intact and re-open still works. + const trigger = documentRef.getElementById("trigger"); + setupBoundingClientRect(trigger, { + x: 100, + y: 100, + top: 100, + bottom: 140, + left: 100, + right: 140, + width: 40, + height: 40, + }); + + const modal = documentRef.createElement("div"); + modal.id = "host-modal"; + const preExistingPanel = documentRef.createElement("div"); + preExistingPanel.id = "zap-like-panel"; + setupBoundingClientRect(preExistingPanel, { + x: 0, + y: 0, + top: 0, + bottom: 120, + left: 0, + right: 200, + width: 200, + height: 120, + }); + const sibling = documentRef.createElement("span"); + modal.appendChild(preExistingPanel); + modal.appendChild(sibling); // gives the panel a nextSibling to restore before + documentRef.body.appendChild(modal); + + // render returns the pre-existing panel verbatim (does NOT append to container). + const popover = createPopover(trigger, () => preExistingPanel, { + document: documentRef, + }); + + assert.equal( + preExistingPanel.parentNode, + modal, + "panel starts in the host modal", + ); + + await popover.open(); + + const overlayRoot = documentRef.getElementById("uiOverlay"); + const portal = overlayRoot.querySelector('[data-popover-portal="true"]'); + assert.ok(portal, "portal should exist under the overlay root"); + assert.equal( + preExistingPanel.parentNode, + portal, + "panel should be relocated into the body-level portal while open", + ); + + popover.close({ restoreFocus: false }); + + assert.equal( + preExistingPanel.parentNode, + modal, + "panel should be restored to the host modal on close", + ); + assert.equal( + preExistingPanel.nextSibling, + sibling, + "panel should be restored to its original position (before its sibling)", + ); + + await popover.open(); + assert.equal( + preExistingPanel.parentNode, + portal, + "panel should relocate again on re-open (element not lost)", + ); + + popover.destroy(); + assert.equal( + preExistingPanel.parentNode, + modal, + "destroy should leave the app-owned panel back in its host, not remove it", + ); +}); + +test("typing in a form field inside a panel is not hijacked by menu typeahead", async () => { + // Regression for pre-launch #3a/#3: the engine treats panels as ARIA menus and + // preventDefault'd single-character + arrow keys. For a panel with inputs (the + // zap comment/amount fields) that swallowed every keystroke -> "can't type in + // the popup". Keys originating from an editable control must pass through. + const trigger = documentRef.getElementById("trigger"); + setupBoundingClientRect(trigger, { + x: 100, y: 100, top: 100, bottom: 140, left: 100, right: 140, width: 40, height: 40, + }); + + let panelRef; + let inputRef; + const popover = createPopover( + trigger, + ({ container }) => { + const panel = documentRef.createElement("div"); + panel.className = "popover__panel"; + setupBoundingClientRect(panel, { + x: 0, y: 0, top: 0, bottom: 120, left: 0, right: 200, width: 200, height: 120, + }); + const input = documentRef.createElement("textarea"); + panel.appendChild(input); + container.appendChild(panel); + panelRef = panel; + inputRef = input; + return panel; + }, + { document: documentRef }, + ); + + await popover.open(); + + // A printable character typed in the textarea must NOT be preventDefault'd. + const charEvent = new windowRef.KeyboardEvent("keydown", { key: "a", bubbles: true, cancelable: true }); + inputRef.dispatchEvent(charEvent); + assert.equal(charEvent.defaultPrevented, false, "character keys must reach the input"); + + // Caret movement (ArrowLeft) inside the textarea must also pass through. + const arrowEvent = new windowRef.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true }); + inputRef.dispatchEvent(arrowEvent); + assert.equal(arrowEvent.defaultPrevented, false, "caret keys must reach the input"); + + // Sanity: a non-editable target in the same panel is still handled as a menu + // (printable key is consumed for typeahead). + const menuKey = new windowRef.KeyboardEvent("keydown", { key: "x", bubbles: true, cancelable: true }); + panelRef.dispatchEvent(menuKey); + assert.equal(menuKey.defaultPrevented, true, "menu typeahead still applies to non-editable targets"); + + popover.destroy(); +});