ui/popover: let panel inputs receive keys; portal in-modal panels

The shared engine treats every panel as an ARIA menu and preventDefault'd
single-char + arrow keys, so typing in a panel with inputs (the zap
comment/amount fields) was swallowed — "you can't type in the popup".
handleMenuKeydown now ignores editable targets.

Also relocate a pre-existing in-host panel (the in-modal zap dialog) into
the body-level portal on open so position:fixed is viewport-relative
instead of anchoring to the modal edge, restoring it on close. Pure DOM
helpers extracted to popoverPanelDom.js to keep the engine under its cap.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-23 14:53:38 -04:00
1 parent d8e1b12992
commit 1921356558
3 files changed
+231

No files matched your search

+27
View File
@@ -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();
}
+64
View File
@@ -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().
}
}
+140
View File
@@ -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();
});