mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 01:12:39 +00:00
Merge pull request #592 from PR0M3TH3AN/codex/add-popover-engine-implementation
Add Floating UI popover engine
This commit is contained in:
5 files changed
+858
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+26
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
`<!DOCTYPE html><html><body><div id="app"><button id="trigger">Open</button></div></body></html>`,
|
||||
{
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user