mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 02:42:28 +00:00
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:
1 parent
d8e1b12992
commit
1921356558
3 files changed
+231
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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().
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user