Refactor floating panel positioning tokens

This commit is contained in:
thePR0M3TH3AN committed 2025-10-14 07:48:58 -04:00
1 parent d44e327d42
commit 8e4e9c270e
4 files changed
+149 -75

No files matched your search

+75
View File
@@ -160,6 +160,81 @@
@apply font-mono;
}
[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;
transform-origin: var(--floating-transform-origin-inline)
var(--floating-transform-origin-block);
}
[data-floating-panel][data-floating-strategy="absolute"] {
position: absolute;
}
[data-floating-panel] {
top: var(--floating-fallback-top);
left: var(--floating-fallback-left);
right: auto;
bottom: auto;
}
[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;
}
+8
View File
@@ -265,6 +265,14 @@ panelEl.dataset.state = "open";
positioner.update();
```
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】
Recommended container pattern:
- Wrap the trigger and panel in `.popover` so the markup stays discoverable and inherits spacing tokens.
+55 -59
View File
@@ -1,8 +1,3 @@
import {
applyDynamicStyles,
clearAllDynamicStyles,
} from "../styleSystem.js";
const DEFAULT_OPTIONS = {
placement: "bottom",
alignment: "start",
@@ -14,6 +9,20 @@ const DEFAULT_OPTIONS = {
onUpdate: null,
};
const PANEL_DATA_KEYS = [
"floatingPanel",
"floatingPlacement",
"floatingAlignment",
"floatingStrategy",
"floatingDir",
"floatingMode",
];
const PANEL_STYLE_PROPS = [
"--floating-fallback-top",
"--floating-fallback-left",
];
const OPPOSITE_PLACEMENT = {
top: "bottom",
bottom: "top",
@@ -133,41 +142,6 @@ function computePosition({
}
}
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") {
@@ -210,6 +184,21 @@ function resolvePanelSize(panel, previousSize) {
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;
@@ -239,30 +228,27 @@ export function positionFloatingPanel(anchor, panel, options = {}) {
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: [],
};
safeAnchor.dataset.floatingAnchor = "true";
safePanel.dataset.floatingPanel = "true";
safePanel.dataset.floatingAlignment = config.alignment;
safePanel.dataset.floatingStrategy = config.strategy;
safePanel.dataset.floatingDir = rtl ? "rtl" : "ltr";
safePanel.dataset.floatingMode = anchorSupported ? "anchor" : "fallback";
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" },
);
safePanel.dataset.floatingPlacement = placement;
safePanel.style.position = config.strategy;
safePanel.style.removeProperty("right");
safePanel.style.removeProperty("bottom");
safePanel.style.setProperty("--floating-fallback-top", `${top}px`);
safePanel.style.setProperty("--floating-fallback-left", `${left}px`);
if (typeof config.onUpdate === "function") {
config.onUpdate({ top, left, placement, alignment: config.alignment, rtl });
}
@@ -409,7 +395,17 @@ export function positionFloatingPanel(anchor, panel, options = {}) {
// Ignore cleanup errors to avoid breaking consumer teardown flows.
}
}
clearAllDynamicStyles(safePanel);
PANEL_STYLE_PROPS.forEach((prop) => {
safePanel.style.removeProperty(prop);
});
safePanel.style.removeProperty("right");
safePanel.style.removeProperty("bottom");
safePanel.style.removeProperty("position");
safePanel.style.position = "";
PANEL_DATA_KEYS.forEach((key) => {
delete safePanel.dataset[key];
});
delete safeAnchor.dataset.floatingAnchor;
},
};
}
+11 -16
View File
@@ -2,7 +2,6 @@ import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import positionFloatingPanel from "../js/ui/utils/positionFloatingPanel.js";
import { getAppliedDynamicStyles } from "../js/ui/styleSystem.js";
test("flips to top when bottom placement collides with viewport", () => {
const dom = new JSDOM(
@@ -54,13 +53,14 @@ test("flips to top when bottom placement collides with viewport", () => {
panel.dataset.state = "open";
positioner.update();
assert.equal(panel.dataset.placement, "top");
const styles = getAppliedDynamicStyles(panel, { slot: "floating-position" });
assert.ok(styles);
assert.equal(styles.top, "132px");
assert.equal(styles.left, "100px");
assert.equal(panel.dataset.floatingPlacement, "top");
assert.equal(panel.style.position, "fixed");
assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "132px");
assert.equal(panel.style.getPropertyValue("--floating-fallback-left"), "100px");
positioner.destroy();
assert.equal(panel.dataset.floatingPanel, undefined);
assert.equal(trigger.dataset.floatingAnchor, undefined);
});
test("respects RTL alignment and clamps within the viewport", () => {
@@ -116,10 +116,9 @@ test("respects RTL alignment and clamps within the viewport", () => {
positioner.update();
// In RTL mode, start alignment hugs the right edge of the trigger.
const rtlStyles = getAppliedDynamicStyles(panel, { slot: "floating-position" });
assert.ok(rtlStyles);
assert.equal(rtlStyles.left, "140px");
assert.equal(panel.dataset.placement, "bottom");
assert.equal(panel.dataset.floatingPlacement, "bottom");
assert.equal(panel.dataset.floatingDir, "rtl");
assert.equal(panel.style.getPropertyValue("--floating-fallback-left"), "140px");
positioner.destroy();
});
@@ -183,16 +182,12 @@ test("updates when scroll containers move the trigger", async () => {
panel.dataset.state = "open";
positioner.update();
const initialStyles = getAppliedDynamicStyles(panel, { slot: "floating-position" });
assert.ok(initialStyles);
assert.equal(initialStyles.top, "152px");
assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "152px");
assert.ok(scrollHandler, "scroll listener should be registered");
anchorTop = 60;
scrollHandler?.({ type: "scroll" });
const updatedStyles = getAppliedDynamicStyles(panel, { slot: "floating-position" });
assert.ok(updatedStyles);
assert.equal(updatedStyles.top, "112px");
assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "112px");
positioner.destroy();
window.addEventListener = originalAddEventListener;