diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 78599da3..2ba38c5f 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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; } diff --git a/docs/design-system.md b/docs/design-system.md index c7ca6ed8..c3e8ac4f 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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. diff --git a/js/ui/utils/positionFloatingPanel.js b/js/ui/utils/positionFloatingPanel.js index fccc2dca..a4336203 100644 --- a/js/ui/utils/positionFloatingPanel.js +++ b/js/ui/utils/positionFloatingPanel.js @@ -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; }, }; } diff --git a/tests/position-floating-panel.test.mjs b/tests/position-floating-panel.test.mjs index 1eff4800..09b56170 100644 --- a/tests/position-floating-panel.test.mjs +++ b/tests/position-floating-panel.test.mjs @@ -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;