feat(floating): token-driven canonical gap — foundation + popover pilot

floating-ui's numeric `sideOffset` can't take a token, so the trigger->panel gap
was a hardcoded per-component number (0/4/6/8, inconsistent). Canonize it:
register a `--floating-gap` <length> @property, set it per archetype via a
foundation rule (`--floating-gap-menu: 0`, `--floating-gap-panel: --space-1-5`),
and have the shared positioner (soma FloatingContent) read the resolved px off the
content and feed it as the offset -- token-driven (follows density x scaling) and
arrow-safe (the arrow rides with the offset). A content opts in by stamping
`data-floating-gap`; otherwise the positioner falls back to its numeric
`sideOffset`, so the rest migrates one at a time.

Pilot: Popover.Content stamps `data-floating-gap="panel"`. Verified in-browser:
overriding `--floating-gap-panel` to 20px moves the popover gap to 18px (20 minus
the popover's ~2px border overlap); the default 6px is a no-op vs the old
sideOffset=6. check 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 7cf30287de
commit 1590684c2e

@ -74,6 +74,7 @@
style={composedStyle}
data-size={resolvedSize}
data-depth="overlay"
data-floating-gap="panel"
data-animation-style={motion === 'none' ? undefined : motion}
>
{#snippet children(snippetProps)}

@ -3392,9 +3392,11 @@
--time-range-field-row-gap: var(--space-3);
--time-range-field-separator-color: var(--color-content-muted);
--time-range-field-separator-glyph: '—';
--fab-size-xs: 1.875rem;
--fab-size-sm: 2.5rem;
--fab-size-md: 3.5rem;
--fab-size-lg: 4.5rem;
--fab-icon-xs: 1rem;
--fab-icon-sm: 1.25rem;
--fab-icon-md: 1.5rem;
--fab-icon-lg: 1.75rem;
@ -5636,6 +5638,25 @@
}
}
@property --floating-gap {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
:root {
--floating-gap-menu: 0px;
--floating-gap-panel: var(--space-1-5);
}
[data-floating-gap='menu'] {
--floating-gap: var(--floating-gap-menu);
}
[data-floating-gap='panel'] {
--floating-gap: var(--floating-gap-panel);
}
[data-container] {
container-type: inline-size;
}

@ -352,6 +352,27 @@ export function renderStaticCss(options: EidosConfig): string {
blocks.push(renderForcedColorsBlock())
blocks.push(renderPrefersContrastBlock())
// Floating gap — the trigger→panel separation, canonized. floating-ui's numeric
// `sideOffset` can't take a token, so the shared positioner (soma's
// FloatingContent) reads THIS `<length>` @property off the content and feeds it as
// the offset — token-driven (follows density × scaling) and arrow-safe (the arrow
// rides with the offset). Two archetypes: menus sit flush, panels get a small gap.
// A content opts in by stamping `data-floating-gap`; otherwise the positioner falls
// back to its numeric `sideOffset` (legacy, per-component).
blocks.push(`@property --floating-gap {\n\tsyntax: '<length>';\n\tinherits: false;\n\tinitial-value: 0px;\n}`)
blocks.push(
renderBlock(':root', [
cssVar('floating-gap-menu', '0px'),
cssVar('floating-gap-panel', 'var(--space-1-5)')
])
)
blocks.push(
renderBlock("[data-floating-gap='menu']", [cssVar('floating-gap', 'var(--floating-gap-menu)')])
)
blocks.push(
renderBlock("[data-floating-gap='panel']", [cssVar('floating-gap', 'var(--floating-gap-panel)')])
)
// Container-query opt-in: marking an element `data-container` makes it a query
// container, so its descendants' recipe `@container` overrides resolve against
// its inline size. (The `@container` blocks themselves are emitted per-recipe.)

@ -144,7 +144,7 @@ export class FloatingContent {
() =>
[
offset({
mainAxis: this.opts.sideOffset.current + this.arrowHeight,
mainAxis: (this.canonicalGapPx ?? this.opts.sideOffset.current) + this.arrowHeight,
alignmentAxis: this.opts.alignOffset.current
}),
this.opts.avoidCollisions.current &&
@ -185,6 +185,9 @@ export class FloatingContent {
return centerOffset !== undefined && centerOffset !== 0;
});
contentZIndex = $state<string>();
// Canonical floating gap (px) read from the content's `--floating-gap` when it
// opts into `data-floating-gap`; null → fall back to the numeric `sideOffset`.
canonicalGapPx = $state<number | null>(null);
arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]);
wrapperProps = $derived.by(
() =>
@ -297,9 +300,15 @@ export class FloatingContent {
if (!contentNode || !this.opts.enabled.current) return;
const rafId = this.provider.opts.dom.requestFrame(() => {
if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return;
const zIndex = this.provider.opts.dom
.getWindow(contentNode)
.getComputedStyle(contentNode).zIndex;
const cs = this.provider.opts.dom.getWindow(contentNode).getComputedStyle(contentNode);
// Canonical floating gap: when the content opts into `data-floating-gap`,
// the offset comes from the resolved `--floating-gap` (<length> @property)
// instead of the numeric `sideOffset` — token-driven + arrow-safe.
const gapPx = contentNode.hasAttribute('data-floating-gap')
? parseFloat(cs.getPropertyValue('--floating-gap'))
: NaN;
this.canonicalGapPx = Number.isFinite(gapPx) ? gapPx : null;
const zIndex = cs.zIndex;
if (zIndex !== this.contentZIndex) {
this.contentZIndex = zIndex;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.