mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Merge pull request #599 from PR0M3TH3AN/codex/remove-old-floating-panel-utilities
chore: drop legacy floating panel helpers
This commit is contained in:
4 files changed
+23
-681
No files matched your search
@@ -275,86 +275,6 @@
|
||||
@apply font-mono;
|
||||
}
|
||||
|
||||
[data-floating-anchor="true"] {
|
||||
anchor-name: --floating-panel;
|
||||
}
|
||||
|
||||
[data-floating-panel] {
|
||||
--floating-transform-origin-inline: 50%;
|
||||
--floating-transform-origin-block: 50%;
|
||||
--floating-fallback-top: auto;
|
||||
--floating-fallback-left: auto;
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
top: var(--floating-fallback-top);
|
||||
left: var(--floating-fallback-left);
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
transform-origin: var(--floating-transform-origin-inline)
|
||||
var(--floating-transform-origin-block);
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-strategy="absolute"] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-mode="anchor"] {
|
||||
position-anchor: --floating-panel;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="top"] {
|
||||
--floating-transform-origin-block: 100%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="bottom"] {
|
||||
--floating-transform-origin-block: 0%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="left"] {
|
||||
--floating-transform-origin-inline: 100%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="right"] {
|
||||
--floating-transform-origin-inline: 0%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="top"][data-floating-alignment="start"],
|
||||
[data-floating-panel][data-floating-placement="bottom"][data-floating-alignment="start"],
|
||||
[data-floating-panel][data-floating-placement="top"][data-floating-alignment="end"],
|
||||
[data-floating-panel][data-floating-placement="bottom"][data-floating-alignment="end"] {
|
||||
--floating-transform-origin-inline: 50%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-dir="ltr"][data-floating-placement="top"][data-floating-alignment="start"],
|
||||
[data-floating-panel][data-floating-dir="ltr"][data-floating-placement="bottom"][data-floating-alignment="start"] {
|
||||
--floating-transform-origin-inline: 0%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-dir="ltr"][data-floating-placement="top"][data-floating-alignment="end"],
|
||||
[data-floating-panel][data-floating-dir="ltr"][data-floating-placement="bottom"][data-floating-alignment="end"] {
|
||||
--floating-transform-origin-inline: 100%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-dir="rtl"][data-floating-placement="top"][data-floating-alignment="start"],
|
||||
[data-floating-panel][data-floating-dir="rtl"][data-floating-placement="bottom"][data-floating-alignment="start"] {
|
||||
--floating-transform-origin-inline: 100%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-dir="rtl"][data-floating-placement="top"][data-floating-alignment="end"],
|
||||
[data-floating-panel][data-floating-dir="rtl"][data-floating-placement="bottom"][data-floating-alignment="end"] {
|
||||
--floating-transform-origin-inline: 0%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="left"][data-floating-alignment="start"],
|
||||
[data-floating-panel][data-floating-placement="right"][data-floating-alignment="start"] {
|
||||
--floating-transform-origin-block: 0%;
|
||||
}
|
||||
|
||||
[data-floating-panel][data-floating-placement="left"][data-floating-alignment="end"],
|
||||
[data-floating-panel][data-floating-placement="right"][data-floating-alignment="end"] {
|
||||
--floating-transform-origin-block: 100%;
|
||||
}
|
||||
|
||||
.popover {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
+23
-22
@@ -15,12 +15,11 @@ The design system is now the canonical styling path across bitvid—there is no
|
||||
Inline styles are blocked in production builds, so runtime components that need bespoke coordinates or measurements must route their overrides through `js/designSystem/dynamicStyles.js`.
|
||||
|
||||
- `registerScope(id, selectors)` creates (or reuses) a constructable stylesheet when supported, allocates a unique identifier, and injects empty rules scoped to `[data-ds-style-id="<id>"]`. Callers attach the attribute to any element that should inherit those rules.
|
||||
- `setVariables(id, tokens)` updates CSS custom properties on the registered scope. Pass CSS variable names (`"--floating-fallback-top"`) and string or number values—`setVariables` handles serialization and removes entries when they resolve to empty strings.
|
||||
- `setVariables(id, tokens)` updates CSS custom properties on the registered scope. Pass CSS variable names (for example, `"--ds-metric-probe-length"`) and string or number values—`setVariables` handles serialization and removes entries when they resolve to empty strings.
|
||||
- `releaseScope(id)` removes the generated rules and frees the identifier. Always invoke it from component teardown paths to avoid leaking rules between renders.
|
||||
|
||||
The floating panel utility and design-system metrics both lean on this helper:
|
||||
Design-system metrics lean on this helper to keep runtime measurements inline-style free:
|
||||
|
||||
- `createFloatingPanelStyles` assigns each popover a unique scope and publishes fallback coordinates through CSS variables instead of mutating `.style`. Positioning logic reads and writes the same API, and teardown releases the scope before removing dataset attributes.
|
||||
- `readDesignTokenAsPixels` measures non-`px`/`rem` tokens with an offscreen probe that ships in core CSS (`.ds-metric-probe`). The probe is registered through the helper so dynamic lengths are fed into `--ds-metric-probe-length` without touching inline styles.
|
||||
|
||||
When introducing a new runtime feature that requires dynamic styling:
|
||||
@@ -319,7 +318,7 @@ Logo glyphs now rely on dedicated selectors inside the SVG: apply `.bv-logo__acc
|
||||
|
||||
### Popovers
|
||||
|
||||
Use `.popover` as the relative anchor for floating content. Nest `.popover__panel` for the floating surface—it’s positioned with the shared helper, inherits the shared focus ring, and animates with opacity/scale tokens. Toggle `[data-state="open"]` on the panel to switch between the collapsed (`opacity-0 scale-95`) and expanded (`opacity-100 scale-100`) states.
|
||||
Use `.popover` as the relative anchor for floating content. Nest `.popover__panel` for the floating surface—it’s positioned with the shared popover engine, inherits the shared focus ring, and animates with opacity/scale tokens. Toggle `[data-state="open"]` on the panel to switch between the collapsed (`opacity-0 scale-95`) and expanded (`opacity-100 scale-100`) states.
|
||||
|
||||
```html
|
||||
<div class="popover">
|
||||
@@ -330,36 +329,38 @@ Use `.popover` as the relative anchor for floating content. Nest `.popover__pane
|
||||
</div>
|
||||
```
|
||||
|
||||
Use the shared positioning helper (`js/ui/utils/positionFloatingPanel.js`) instead of manual utility offsets. It calculates placement, flips when the surface collides with the viewport, and clamps to an optional padding gutter—all while listening for scroll/resize events and using `ResizeObserver` when available. Call it with the trigger and panel elements, then invoke `positioner.update()` once the panel is visible:
|
||||
Use the shared popover engine (`js/ui/overlay/popoverEngine.js`) instead of manual offsets. The helper attaches panels to the overlay root, flips placement when they collide with the viewport, and toggles `aria` + `data-popover-state` attributes as visibility changes. Width and height clamps can be sourced from design tokens (`maxWidthToken`, `maxHeightToken`) so surfaces inherit theme-specific limits without inline constants. 【F:js/ui/overlay/popoverEngine.js†L252-L266】【F:js/ui/overlay/popoverEngine.js†L633-L706】【F:css/tailwind.source.css†L358-L399】
|
||||
|
||||
```js
|
||||
import positionFloatingPanel from "../utils/positionFloatingPanel.js";
|
||||
import createPopover from "../overlay/popoverEngine.js";
|
||||
|
||||
const positioner = positionFloatingPanel(triggerEl, panelEl, {
|
||||
placement: "bottom", // top | bottom | left | right
|
||||
alignment: "end", // start | center | end (RTL aware)
|
||||
offset: 8,
|
||||
viewportPadding: 16,
|
||||
const popover = createPopover(triggerEl, ({ container }) => {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "popover__panel card";
|
||||
panel.dataset.state = "closed";
|
||||
panel.innerHTML = "<p class=\"text-sm text-muted\">Filters</p>";
|
||||
container.appendChild(panel);
|
||||
return panel;
|
||||
}, {
|
||||
placement: "bottom-start",
|
||||
maxWidthToken: "--popover-inline-safe-max",
|
||||
});
|
||||
|
||||
panelEl.hidden = false;
|
||||
panelEl.dataset.state = "open";
|
||||
positioner.update();
|
||||
await popover.open();
|
||||
panel.dataset.state = "open"; // reveal animation hooks
|
||||
|
||||
// Later, close the surface and restore focus.
|
||||
panel.dataset.state = "closed";
|
||||
popover.close();
|
||||
```
|
||||
|
||||
The helper tags the trigger and surface with floating metadata so CSS tokens can express transform-origins and fallback coordina
|
||||
tes without bespoke inline styles. Panels receive `data-floating-panel="true"` plus placement, alignment, strategy, and directio
|
||||
n hooks (`data-floating-placement`, `data-floating-alignment`, `data-floating-strategy`, `data-floating-dir`). The script updates
|
||||
CSS custom properties `--floating-fallback-top`/`--floating-fallback-left` on each measurement so `css/tailwind.source.css` can
|
||||
control layout purely through tokens. Browsers that support CSS anchor positioning flip `data-floating-mode` to `anchor`, but th
|
||||
e same fallback properties keep older engines aligned. 【F:js/ui/utils/positionFloatingPanel.js†L238-L251】【F:css/tailwind.sourc
|
||||
e.css†L163-L236】
|
||||
The render callback runs once the engine has created its portal container. Return the panel (and optional arrow) element so the helper can manage focus targets, `aria-controls`, and collision handling. Consumers remain responsible for their own visual state toggles (`data-state`) so Tailwind transitions continue to apply. Use `popover.update()` when the trigger or panel size changes, and call `popover.destroy()` during teardown to drop listeners and portals. 【F:js/ui/overlay/popoverEngine.js†L420-L583】【F:js/ui/overlay/popoverEngine.js†L888-L1014】
|
||||
|
||||
Recommended container pattern:
|
||||
|
||||
- Wrap the trigger and panel in `.popover` so the markup stays discoverable and inherits spacing tokens.
|
||||
- Leave the wrapper’s overflow visible; the helper defaults to `position: fixed`, so floating surfaces escape scroll containers without getting clipped. Opt into `{ strategy: "absolute" }` if you intentionally want the panel constrained.
|
||||
- When tearing down a component, call `positioner.destroy()` to drop scroll/resize listeners.
|
||||
- When tearing down a component, call `popover.destroy()` to drop scroll/resize listeners and remove the portal container.
|
||||
|
||||
The motion helpers defer to the global motion tokens so Task 5 can adjust timing globally without revisiting individual components. Animations respect `prefers-reduced-motion` by collapsing to opacity-only transitions.
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import {
|
||||
registerScope,
|
||||
setVariables,
|
||||
releaseScope,
|
||||
getScopeAttributeName,
|
||||
} from "../../designSystem/dynamicStyles.js";
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
placement: "bottom",
|
||||
alignment: "start",
|
||||
strategy: "fixed",
|
||||
direction: "ltr",
|
||||
mode: "fallback",
|
||||
fallbackTop: null,
|
||||
fallbackLeft: null,
|
||||
};
|
||||
|
||||
const FALLBACK_TOP_VAR = "--floating-fallback-top";
|
||||
const FALLBACK_LEFT_VAR = "--floating-fallback-left";
|
||||
const SCOPE_BASE_ID = "floating-panel";
|
||||
const SCOPE_SELECTORS = [":scope"];
|
||||
|
||||
function toCssPixel(value) {
|
||||
if (Number.isFinite(value)) {
|
||||
return `${value}px`;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value;
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function normalizeDirection(value) {
|
||||
return value === "rtl" ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
function ensureDatasetValue(element, key, value) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null || value === "") {
|
||||
delete element.dataset[key];
|
||||
return;
|
||||
}
|
||||
element.dataset[key] = value;
|
||||
}
|
||||
|
||||
function applyFallbackVariables(scopeId, variables) {
|
||||
if (!scopeId) {
|
||||
return;
|
||||
}
|
||||
setVariables(scopeId, variables);
|
||||
}
|
||||
|
||||
export function createFloatingPanelStyles(panel) {
|
||||
const safePanel = panel || null;
|
||||
const state = { ...DEFAULT_STATE };
|
||||
|
||||
const documentRef = safePanel?.ownerDocument || null;
|
||||
const scopeId = safePanel
|
||||
? registerScope(SCOPE_BASE_ID, SCOPE_SELECTORS, { documentRef })
|
||||
: null;
|
||||
|
||||
if (safePanel) {
|
||||
safePanel.dataset.floatingPanel = "true";
|
||||
if (scopeId) {
|
||||
safePanel.setAttribute(getScopeAttributeName(), scopeId);
|
||||
applyFallbackVariables(scopeId, {
|
||||
[FALLBACK_TOP_VAR]: "auto",
|
||||
[FALLBACK_LEFT_VAR]: "auto",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
element: safePanel,
|
||||
scopeId,
|
||||
setPlacement(value) {
|
||||
state.placement = typeof value === "string" && value ? value : DEFAULT_STATE.placement;
|
||||
ensureDatasetValue(safePanel, "floatingPlacement", state.placement);
|
||||
return state.placement;
|
||||
},
|
||||
getPlacement() {
|
||||
return state.placement;
|
||||
},
|
||||
setAlignment(value) {
|
||||
state.alignment = typeof value === "string" && value ? value : DEFAULT_STATE.alignment;
|
||||
ensureDatasetValue(safePanel, "floatingAlignment", state.alignment);
|
||||
return state.alignment;
|
||||
},
|
||||
getAlignment() {
|
||||
return state.alignment;
|
||||
},
|
||||
setStrategy(value) {
|
||||
state.strategy = value === "absolute" ? "absolute" : DEFAULT_STATE.strategy;
|
||||
ensureDatasetValue(safePanel, "floatingStrategy", state.strategy);
|
||||
return state.strategy;
|
||||
},
|
||||
getStrategy() {
|
||||
return state.strategy;
|
||||
},
|
||||
setDirection(value) {
|
||||
state.direction = normalizeDirection(value);
|
||||
ensureDatasetValue(safePanel, "floatingDir", state.direction);
|
||||
return state.direction;
|
||||
},
|
||||
getDirection() {
|
||||
return state.direction;
|
||||
},
|
||||
setMode(value) {
|
||||
state.mode = value === "anchor" ? "anchor" : DEFAULT_STATE.mode;
|
||||
ensureDatasetValue(safePanel, "floatingMode", state.mode);
|
||||
return state.mode;
|
||||
},
|
||||
getMode() {
|
||||
return state.mode;
|
||||
},
|
||||
setFallbackPosition({ top, left }) {
|
||||
state.fallbackTop = Number.isFinite(top) ? Number(top) : null;
|
||||
state.fallbackLeft = Number.isFinite(left) ? Number(left) : null;
|
||||
if (scopeId) {
|
||||
applyFallbackVariables(scopeId, {
|
||||
[FALLBACK_TOP_VAR]: toCssPixel(state.fallbackTop),
|
||||
[FALLBACK_LEFT_VAR]: toCssPixel(state.fallbackLeft),
|
||||
});
|
||||
}
|
||||
return { ...api.getFallbackPosition() };
|
||||
},
|
||||
getFallbackPosition() {
|
||||
return {
|
||||
top: state.fallbackTop,
|
||||
left: state.fallbackLeft,
|
||||
};
|
||||
},
|
||||
teardown() {
|
||||
if (scopeId) {
|
||||
applyFallbackVariables(scopeId, {
|
||||
[FALLBACK_TOP_VAR]: "auto",
|
||||
[FALLBACK_LEFT_VAR]: "auto",
|
||||
});
|
||||
releaseScope(scopeId);
|
||||
}
|
||||
if (!safePanel) {
|
||||
return;
|
||||
}
|
||||
safePanel.removeAttribute(getScopeAttributeName());
|
||||
delete safePanel.dataset.floatingPanel;
|
||||
delete safePanel.dataset.floatingPlacement;
|
||||
delete safePanel.dataset.floatingAlignment;
|
||||
delete safePanel.dataset.floatingStrategy;
|
||||
delete safePanel.dataset.floatingDir;
|
||||
delete safePanel.dataset.floatingMode;
|
||||
state.placement = DEFAULT_STATE.placement;
|
||||
state.alignment = DEFAULT_STATE.alignment;
|
||||
state.strategy = DEFAULT_STATE.strategy;
|
||||
state.direction = DEFAULT_STATE.direction;
|
||||
state.mode = DEFAULT_STATE.mode;
|
||||
state.fallbackTop = DEFAULT_STATE.fallbackTop;
|
||||
state.fallbackLeft = DEFAULT_STATE.fallbackLeft;
|
||||
},
|
||||
};
|
||||
|
||||
api.setPlacement(state.placement);
|
||||
api.setAlignment(state.alignment);
|
||||
api.setStrategy(state.strategy);
|
||||
api.setDirection(state.direction);
|
||||
api.setMode(state.mode);
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
export default createFloatingPanelStyles;
|
||||
@@ -1,407 +0,0 @@
|
||||
import { createFloatingPanelStyles } from "./floatingPanelStyles.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 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 };
|
||||
}
|
||||
|
||||
function detectAnchorSupport(windowRef) {
|
||||
const css = windowRef?.CSS;
|
||||
if (!css || typeof css.supports !== "function") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
css.supports("anchor-name: --floating-panel") &&
|
||||
css.supports("position-anchor: --floating-panel")
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 { styles: providedStyles, ...optionOverrides } = options || {};
|
||||
const config = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...optionOverrides,
|
||||
};
|
||||
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 anchorSupported = detectAnchorSupport(windowRef);
|
||||
|
||||
const state = {
|
||||
lastSize: { width: 0, height: 0 },
|
||||
cleanup: [],
|
||||
};
|
||||
|
||||
const styles =
|
||||
providedStyles && providedStyles.element === safePanel
|
||||
? providedStyles
|
||||
: createFloatingPanelStyles(safePanel);
|
||||
|
||||
safeAnchor.dataset.floatingAnchor = "true";
|
||||
styles.setAlignment(config.alignment);
|
||||
styles.setStrategy(config.strategy);
|
||||
styles.setDirection(rtl ? "rtl" : "ltr");
|
||||
styles.setMode(anchorSupported ? "anchor" : "fallback");
|
||||
styles.setPlacement(config.placement);
|
||||
|
||||
const applyPosition = ({ top, left, placement }) => {
|
||||
styles.setPlacement(placement);
|
||||
styles.setFallbackPosition({ top, left });
|
||||
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.
|
||||
}
|
||||
}
|
||||
styles.teardown();
|
||||
delete safeAnchor.dataset.floatingAnchor;
|
||||
},
|
||||
styles,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in new issue
Block a user