Implement internal popover positioning

This commit is contained in:
thePR0M3TH3AN committed 2025-10-16 12:00:59 -04:00
1 parent 7ef16cd004
commit 5a2077595a
3 files changed
+239 -79

No files matched your search

+239 -52
View File
@@ -1,11 +1,3 @@
import {
computePosition,
offset,
flip,
shift,
autoUpdate,
arrow as arrowMiddleware,
} from "@floating-ui/dom";
import logger from "../../utils/logger.js";
import {
getPopupOffsetPx,
@@ -17,12 +9,6 @@ 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;
@@ -82,32 +68,61 @@ function resolveArrow(option, panel) {
return null;
}
function applyArrowStyles(arrowElement, placement, middlewareData) {
if (!arrowElement) {
function applyArrowStyles({
arrowElement,
side,
panelLeft,
panelTop,
panelWidth,
panelHeight,
anchorRect,
}) {
if (!arrowElement || !side || !anchorRect) {
return;
}
const arrowData = middlewareData?.arrow || {};
const arrowRect =
typeof arrowElement.getBoundingClientRect === "function"
? arrowElement.getBoundingClientRect()
: null;
const arrowWidth = arrowRect?.width ?? arrowElement.offsetWidth ?? 0;
const arrowHeight = arrowRect?.height ?? arrowElement.offsetHeight ?? 0;
arrowElement.style.left =
arrowData.x !== undefined && arrowData.x !== null ? `${arrowData.x}px` : "";
arrowElement.style.top =
arrowData.y !== undefined && arrowData.y !== null ? `${arrowData.y}px` : "";
const anchorCenterX = anchorRect.left + anchorRect.width / 2;
const anchorCenterY = anchorRect.top + anchorRect.height / 2;
arrowElement.style.left = "";
arrowElement.style.right = "";
arrowElement.style.top = "";
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`;
if (side === "top" || side === "bottom") {
let arrowLeft = anchorCenterX - panelLeft - arrowWidth / 2;
arrowLeft = Math.min(
Math.max(arrowLeft, 0),
Math.max(panelWidth - arrowWidth, 0),
);
arrowElement.style.left = `${Math.round(arrowLeft)}px`;
if (side === "bottom") {
arrowElement.style.top = `${-Math.round(arrowHeight)}px`;
} else {
arrowElement.style.bottom = `${-Math.round(arrowHeight)}px`;
}
} else if (side === "left" || side === "right") {
let arrowTop = anchorCenterY - panelTop - arrowHeight / 2;
arrowTop = Math.min(
Math.max(arrowTop, 0),
Math.max(panelHeight - arrowHeight, 0),
);
arrowElement.style.top = `${Math.round(arrowTop)}px`;
if (side === "right") {
arrowElement.style.left = `${-Math.round(arrowWidth)}px`;
} else {
arrowElement.style.right = `${-Math.round(arrowWidth)}px`;
}
}
arrowElement.dataset.popoverArrowSide = side || "";
arrowElement.dataset.popoverArrowSide = side;
}
function containsTarget(root, target) {
@@ -127,6 +142,85 @@ function setExpandedAttribute(trigger, value) {
trigger.setAttribute("aria-expanded", value ? "true" : "false");
}
function createAutoUpdate({ documentRef, anchor, panel, update }) {
if (!documentRef || !anchor || !panel || typeof update !== "function") {
return null;
}
const cleanupFns = [];
const safeUpdate = () => {
try {
const result = update();
if (result && typeof result.then === "function") {
result.catch((error) => {
logger.dev.warn("[popover] auto-update failed", error);
});
}
} catch (error) {
logger.dev.warn("[popover] auto-update failed", error);
}
};
const view = documentRef.defaultView || globalThis;
if (view && typeof view.addEventListener === "function") {
const resizeHandler = () => safeUpdate();
view.addEventListener("resize", resizeHandler);
cleanupFns.push(() => view.removeEventListener("resize", resizeHandler));
const scrollHandler = () => safeUpdate();
view.addEventListener("scroll", scrollHandler, true);
cleanupFns.push(() => view.removeEventListener("scroll", scrollHandler, true));
}
const scrollTarget = documentRef.scrollingElement || documentRef;
if (scrollTarget && typeof scrollTarget.addEventListener === "function") {
const docScrollHandler = () => safeUpdate();
scrollTarget.addEventListener("scroll", docScrollHandler, true);
cleanupFns.push(() =>
scrollTarget.removeEventListener("scroll", docScrollHandler, true),
);
}
const observers = [];
if (typeof ResizeObserver === "function") {
try {
const anchorObserver = new ResizeObserver(safeUpdate);
anchorObserver.observe(anchor);
observers.push(anchorObserver);
} catch (error) {
logger.dev.warn("[popover] anchor resize observer failed", error);
}
if (panel !== anchor) {
try {
const panelObserver = new ResizeObserver(safeUpdate);
panelObserver.observe(panel);
observers.push(panelObserver);
} catch (error) {
logger.dev.warn("[popover] panel resize observer failed", error);
}
}
}
return () => {
cleanupFns.forEach((cleanup) => {
try {
cleanup();
} catch (error) {
logger.dev.warn("[popover] auto-update cleanup failed", error);
}
});
observers.forEach((observer) => {
try {
observer.disconnect();
} catch (error) {
logger.dev.warn("[popover] observer disconnect failed", error);
}
});
};
}
export function createPopover(trigger, render, options = {}) {
const anchor = isElement(trigger) ? trigger : null;
const documentRef = resolveDocument(anchor, options.document);
@@ -231,35 +325,125 @@ export function createPopover(trigger, render, options = {}) {
}
async function updatePosition() {
if (!anchor || !panel) {
if (!anchor || !panel || !documentRef) {
return;
}
try {
const middleware = [
offset(gap),
flip({ padding: viewportPadding }),
shift({ padding: viewportPadding }),
];
if (arrowElement) {
middleware.push(arrowMiddleware({ element: arrowElement }));
const anchorRect =
typeof anchor.getBoundingClientRect === "function"
? anchor.getBoundingClientRect()
: null;
if (!anchorRect) {
return;
}
const { x, y, placement: resolvedPlacement, middlewareData } =
await computePosition(anchor, panel, {
placement,
strategy,
middleware,
});
const view = documentRef.defaultView || globalThis;
const docEl = documentRef.documentElement || null;
const viewportWidth =
docEl?.clientWidth ?? view?.innerWidth ?? panel.offsetWidth ?? 0;
const viewportHeight =
docEl?.clientHeight ?? view?.innerHeight ?? panel.offsetHeight ?? 0;
const panelRect =
typeof panel.getBoundingClientRect === "function"
? panel.getBoundingClientRect()
: null;
const panelWidth = panelRect?.width ?? panel.offsetWidth ?? 0;
const panelHeight = panelRect?.height ?? panel.offsetHeight ?? 0;
let [side, alignment = "start"] = placement.split("-");
if (!side || !["top", "bottom", "left", "right"].includes(side)) {
side = "bottom";
}
if (!["start", "end", "center"].includes(alignment)) {
alignment = "start";
}
const effectiveGap = Number.isFinite(gap) ? gap : 0;
const safePadding = Number.isFinite(viewportPadding) ? viewportPadding : 0;
function clamp(value, min, max, fallback) {
if (!Number.isFinite(value)) {
return fallback ?? min;
}
if (min > max) {
return fallback ?? (min + max) / 2;
}
return Math.min(Math.max(value, min), max);
}
if (side === "bottom") {
const fitsBelow =
anchorRect.bottom + effectiveGap + panelHeight <=
viewportHeight - safePadding;
const fitsAbove =
anchorRect.top - effectiveGap - panelHeight >= safePadding;
if (!fitsBelow && fitsAbove) {
side = "top";
}
} else if (side === "top") {
const fitsAbove =
anchorRect.top - effectiveGap - panelHeight >= safePadding;
const fitsBelow =
anchorRect.bottom + effectiveGap + panelHeight <=
viewportHeight - safePadding;
if (!fitsAbove && fitsBelow) {
side = "bottom";
}
}
let top = 0;
let left = 0;
if (side === "top") {
top = anchorRect.top - panelHeight - effectiveGap;
} else if (side === "bottom") {
top = anchorRect.bottom + effectiveGap;
} else if (side === "left") {
top = anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
} else if (side === "right") {
top = anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
}
if (side === "left") {
left = anchorRect.left - panelWidth - effectiveGap;
} else if (side === "right") {
left = anchorRect.right + effectiveGap;
} else if (alignment === "end") {
left = anchorRect.right - panelWidth;
} else if (alignment === "center") {
left = anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
} else {
left = anchorRect.left;
}
const minTop = safePadding;
const maxTop = viewportHeight - safePadding - panelHeight;
const minLeft = safePadding;
const maxLeft = viewportWidth - safePadding - panelWidth;
const fallbackTop = viewportHeight / 2 - panelHeight / 2;
const fallbackLeft = viewportWidth / 2 - panelWidth / 2;
top = clamp(top, minTop, maxTop, fallbackTop);
left = clamp(left, minLeft, maxLeft, fallbackLeft);
panel.style.position = strategy;
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
panel.dataset.popoverPlacement = resolvedPlacement;
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
panel.dataset.popoverPlacement = `${side}-${alignment}`;
if (arrowElement) {
applyArrowStyles(arrowElement, resolvedPlacement, middlewareData);
applyArrowStyles({
arrowElement,
side,
panelLeft: left,
panelTop: top,
panelWidth,
panelHeight,
anchorRect,
});
}
} catch (error) {
logger.user.error("[popover] positioning failed", error);
@@ -336,8 +520,11 @@ export function createPopover(trigger, render, options = {}) {
autoUpdateCleanup = null;
}
autoUpdateCleanup = autoUpdate(anchor, panelElement, updatePosition, {
animationFrame: false,
autoUpdateCleanup = createAutoUpdate({
documentRef,
anchor,
panel: panelElement,
update: () => updatePosition(),
});
await updatePosition();
-26
View File
@@ -9,7 +9,6 @@
"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"
},
@@ -709,31 +708,6 @@
"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",
-1
View File
@@ -40,7 +40,6 @@
},
"license": "GPL-3.0-or-later",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"path": "^0.12.7",
"webtorrent": "^1.9.7"
}