Files
bitvid/js/ui/utils/positionFloatingPanel.js
T

430 lines
11 KiB
JavaScript

import {
applyDynamicStyles,
clearAllDynamicStyles,
} from "../styleSystem.js";
const DEFAULT_OPTIONS = {
placement: "bottom",
alignment: "start",
offset: 8,
flip: true,
viewportPadding: 12,
strategy: "fixed",
rtl: null,
onUpdate: null,
};
const OPPOSITE_PLACEMENT = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
function normalizeNumber(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function resolveDirection(anchor, documentRef, explicitRtl) {
if (typeof explicitRtl === "boolean") {
return explicitRtl;
}
const docDir =
documentRef?.documentElement?.dir || documentRef?.body?.dir || "";
if (docDir) {
return docDir.toLowerCase() === "rtl";
}
if (anchor && documentRef?.defaultView?.getComputedStyle) {
try {
const direction = documentRef.defaultView.getComputedStyle(anchor).direction;
return direction === "rtl";
} catch (error) {
// Ignore getComputedStyle failures (e.g., detached elements in jsdom).
}
}
return false;
}
function clamp(value, min, max) {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
function computeAlignmentX({
alignment,
anchorRect,
panelWidth,
rtl,
}) {
switch (alignment) {
case "center": {
return anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
}
case "end": {
return rtl
? anchorRect.left
: anchorRect.right - panelWidth;
}
case "start":
default: {
return rtl
? anchorRect.right - panelWidth
: anchorRect.left;
}
}
}
function computeAlignmentY({ alignment, anchorRect, panelHeight }) {
switch (alignment) {
case "center": {
return anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
}
case "end": {
return anchorRect.bottom - panelHeight;
}
case "start":
default: {
return anchorRect.top;
}
}
}
function computePosition({
placement,
alignment,
anchorRect,
panelSize,
offset,
rtl,
}) {
const [panelWidth, panelHeight] = panelSize;
switch (placement) {
case "top": {
return {
top: anchorRect.top - panelHeight - offset,
left: computeAlignmentX({ alignment, anchorRect, panelWidth, rtl }),
};
}
case "left": {
return {
top: computeAlignmentY({ alignment, anchorRect, panelHeight }),
left: anchorRect.left - panelWidth - offset,
};
}
case "right": {
return {
top: computeAlignmentY({ alignment, anchorRect, panelHeight }),
left: anchorRect.right + offset,
};
}
case "bottom":
default: {
return {
top: anchorRect.bottom + offset,
left: computeAlignmentX({ alignment, anchorRect, panelWidth, rtl }),
};
}
}
}
function computeTransformOrigin({ placement, alignment, rtl }) {
let originY = "center";
let originX = "center";
if (placement === "top") {
originY = "bottom";
} else if (placement === "bottom") {
originY = "top";
} else if (placement === "left") {
originX = "right";
} else if (placement === "right") {
originX = "left";
}
if (placement === "top" || placement === "bottom") {
if (alignment === "start") {
originX = rtl ? "right" : "left";
} else if (alignment === "end") {
originX = rtl ? "left" : "right";
} else {
originX = "center";
}
} else if (placement === "left" || placement === "right") {
if (alignment === "start") {
originY = "top";
} else if (alignment === "end") {
originY = "bottom";
} else {
originY = "center";
}
}
return `${originX} ${originY}`;
}
function normalizePlacement(value) {
const normalized = typeof value === "string" ? value.toLowerCase() : "";
if (normalized === "top" || normalized === "bottom") {
return normalized;
}
if (normalized === "left" || normalized === "right") {
return normalized;
}
return "bottom";
}
function normalizeAlignment(value) {
const normalized = typeof value === "string" ? value.toLowerCase() : "";
if (normalized === "center" || normalized === "end") {
return normalized;
}
return "start";
}
function getViewportSize(windowRef, documentRef) {
const width = windowRef?.innerWidth || documentRef?.documentElement?.clientWidth || 0;
const height = windowRef?.innerHeight || documentRef?.documentElement?.clientHeight || 0;
return { width, height };
}
function resolvePanelSize(panel, previousSize) {
const rect = typeof panel.getBoundingClientRect === "function"
? panel.getBoundingClientRect()
: { width: 0, height: 0 };
let width = rect.width || panel.offsetWidth || previousSize?.width || 0;
let height = rect.height || panel.offsetHeight || previousSize?.height || 0;
if (!width && previousSize?.width) {
width = previousSize.width;
}
if (!height && previousSize?.height) {
height = previousSize.height;
}
return { width, height };
}
export function positionFloatingPanel(anchor, panel, options = {}) {
const safeAnchor = anchor || null;
const safePanel = panel || null;
if (!safeAnchor || !safePanel) {
return {
update() {},
destroy() {},
};
}
const documentRef = safeAnchor.ownerDocument || safePanel.ownerDocument || globalThis.document;
const windowRef = documentRef?.defaultView || globalThis;
const config = {
...DEFAULT_OPTIONS,
...options,
};
config.placement = normalizePlacement(config.placement);
config.alignment = normalizeAlignment(config.alignment);
config.offset = clamp(normalizeNumber(config.offset, DEFAULT_OPTIONS.offset), 0, Number.MAX_SAFE_INTEGER);
config.viewportPadding = clamp(
normalizeNumber(config.viewportPadding, DEFAULT_OPTIONS.viewportPadding),
0,
Number.MAX_SAFE_INTEGER,
);
config.strategy = config.strategy === "absolute" ? "absolute" : "fixed";
const rtl = resolveDirection(safeAnchor, documentRef, config.rtl);
const state = {
lastSize: { width: 0, height: 0 },
cleanup: [],
};
const applyPosition = ({ top, left, placement }) => {
safePanel.dataset.placement = placement;
const transformOrigin = computeTransformOrigin({
placement,
alignment: config.alignment,
rtl,
});
applyDynamicStyles(
safePanel,
{
position: config.strategy,
top: `${top}px`,
left: `${left}px`,
right: "auto",
bottom: "auto",
"--floating-transform-origin": transformOrigin,
},
{ slot: "floating-position" },
);
if (typeof config.onUpdate === "function") {
config.onUpdate({ top, left, placement, alignment: config.alignment, rtl });
}
};
const update = () => {
if (!safeAnchor.isConnected || !safePanel.isConnected) {
return;
}
const anchorRect = safeAnchor.getBoundingClientRect();
const { width: panelWidth, height: panelHeight } = resolvePanelSize(
safePanel,
state.lastSize,
);
if (!panelWidth && !panelHeight) {
return;
}
state.lastSize = { width: panelWidth, height: panelHeight };
const viewport = getViewportSize(windowRef, documentRef);
const placementsToTry = [config.placement];
if (config.flip !== false) {
const opposite = OPPOSITE_PLACEMENT[config.placement];
if (opposite) {
placementsToTry.push(opposite);
}
if (config.placement === "bottom" || config.placement === "top") {
placementsToTry.push("right", "left");
} else {
placementsToTry.push("bottom", "top");
}
}
let chosenPlacement = config.placement;
let chosenPosition = computePosition({
placement: chosenPlacement,
alignment: config.alignment,
anchorRect,
panelSize: [panelWidth, panelHeight],
offset: config.offset,
rtl,
});
const fitsViewport = ({ top, left }) => {
const withinVertical =
top >= config.viewportPadding &&
top + panelHeight <= viewport.height - config.viewportPadding;
const withinHorizontal =
left >= config.viewportPadding &&
left + panelWidth <= viewport.width - config.viewportPadding;
return withinVertical && withinHorizontal;
};
for (const placement of placementsToTry) {
const position = computePosition({
placement,
alignment: config.alignment,
anchorRect,
panelSize: [panelWidth, panelHeight],
offset: config.offset,
rtl,
});
if (fitsViewport(position)) {
chosenPlacement = placement;
chosenPosition = position;
break;
}
}
let { top, left } = chosenPosition;
const maxTop = Math.max(
config.viewportPadding,
viewport.height - config.viewportPadding - panelHeight,
);
const maxLeft = Math.max(
config.viewportPadding,
viewport.width - config.viewportPadding - panelWidth,
);
top = clamp(top, config.viewportPadding, maxTop);
left = clamp(left, config.viewportPadding, maxLeft);
if (config.strategy === "absolute") {
const offsetParent = safePanel.offsetParent || safePanel.parentElement || documentRef.body;
const parentRect = offsetParent?.getBoundingClientRect
? offsetParent.getBoundingClientRect()
: { top: 0, left: 0 };
const scrollTop = offsetParent?.scrollTop || 0;
const scrollLeft = offsetParent?.scrollLeft || 0;
top = top - parentRect.top + scrollTop;
left = left - parentRect.left + scrollLeft;
}
applyPosition({ top, left, placement: chosenPlacement });
};
const handleScroll = () => update();
const handleResize = () => update();
if (typeof windowRef?.addEventListener === "function") {
windowRef.addEventListener("scroll", handleScroll, true);
windowRef.addEventListener("resize", handleResize);
state.cleanup.push(() => {
windowRef.removeEventListener("scroll", handleScroll, true);
windowRef.removeEventListener("resize", handleResize);
});
}
if (typeof windowRef?.ResizeObserver === "function") {
const resizeObserver = new windowRef.ResizeObserver(() => update());
try {
resizeObserver.observe(safeAnchor);
resizeObserver.observe(safePanel);
} catch (error) {
// Ignore observation errors for detached elements.
}
state.cleanup.push(() => {
resizeObserver.disconnect();
});
}
// Schedule an initial measurement on the next frame to avoid layout thrash
// when the caller opens the panel synchronously.
if (typeof windowRef?.requestAnimationFrame === "function") {
windowRef.requestAnimationFrame(() => update());
} else {
update();
}
return {
update,
destroy() {
while (state.cleanup.length) {
const fn = state.cleanup.pop();
try {
fn();
} catch (error) {
// Ignore cleanup errors to avoid breaking consumer teardown flows.
}
}
clearAllDynamicStyles(safePanel);
},
};
}
export function destroyFloatingPanel(positioner) {
if (positioner && typeof positioner.destroy === "function") {
positioner.destroy();
}
}
export function updateFloatingPanel(positioner) {
if (positioner && typeof positioner.update === "function") {
positioner.update();
}
}
export default positionFloatingPanel;