You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
103 lines
4.1 KiB
103 lines
4.1 KiB
/**
|
|
* Floating "shell" helpers — small abstractions over the byte-identical
|
|
* boilerplate that 7 popover-based soma providers (popover, dropdown-menu,
|
|
* context-menu, combobox, select, tooltip, link-preview) used to inline:
|
|
*
|
|
* 1. **Root-side** triad: every root provider calls `FloatingProvider.create({ dom })`
|
|
* then constructs a `contentPresence` with the open state + content ref +
|
|
* onOpenChangeComplete callback. The 5-line block was byte-identical
|
|
* across the 7 providers.
|
|
*
|
|
* 2. **Content-side** `wrapperProps`: every Content provider exposes a
|
|
* `$derived` field that re-emits the floating layer's wrapperProps
|
|
* with `pointer-events: 'auto'` forced onto the style object. The
|
|
* 7-line block was identical in 6 of 7 (tooltip parameterises the
|
|
* pointer-events value).
|
|
*
|
|
* The helpers do NOT abstract FocusScope / Dismissal / ScrollLock /
|
|
* TextSelection — those layers genuinely diverge per provider
|
|
* (different `trap` resolution, different `isValidEvent` closures,
|
|
* different close routing — see Round 3 audit analysis).
|
|
*
|
|
* Saves ~150 lines net across the 7 consumers without introducing
|
|
* configuration flags.
|
|
*/
|
|
|
|
import type { ActiveDom } from '$adom';
|
|
import type { Active, State } from '$libs/reactive';
|
|
import type { OnChangeFn } from '../../types';
|
|
import { FloatingProvider, type FloatingContent } from './floating.svelte';
|
|
import { Presence } from '../presence.svelte';
|
|
|
|
export interface FloatingShellRootOpts {
|
|
/** DOM service — every floating root piggybacks on the soma ActiveDom. */
|
|
dom: ActiveDom;
|
|
/** The provider's `open` state — drives the content presence transitions. */
|
|
open: Active<boolean>;
|
|
/** Ref to the Content element. Presence listens to its mount/unmount. */
|
|
contentRef: State<HTMLElement | null>;
|
|
/**
|
|
* Called when the open/close presence transition completes (animation
|
|
* end). Optional — when omitted, presence still runs but the callback
|
|
* is a no-op.
|
|
*/
|
|
onOpenChangeComplete?: Active<OnChangeFn<boolean> | undefined>;
|
|
}
|
|
|
|
export interface FloatingShellRoot {
|
|
floatingProvider: FloatingProvider;
|
|
contentPresence: Presence;
|
|
}
|
|
|
|
/**
|
|
* Initialise the shared `FloatingProvider + contentPresence` triad. The
|
|
* resulting handles are stored on the consumer's class (`this.floatingProvider`,
|
|
* `this.contentPresence`) — the helper just removes the boilerplate.
|
|
*/
|
|
export function createFloatingShellRoot(opts: FloatingShellRootOpts): FloatingShellRoot {
|
|
const floatingProvider = FloatingProvider.create({ dom: opts.dom });
|
|
|
|
const contentPresence = new Presence({
|
|
dom: opts.dom,
|
|
open: opts.open,
|
|
ref: opts.contentRef,
|
|
onComplete: opts.onOpenChangeComplete
|
|
? (open) => opts.onOpenChangeComplete!.current?.(open)
|
|
: undefined
|
|
});
|
|
|
|
return { floatingProvider, contentPresence };
|
|
}
|
|
|
|
/**
|
|
* Build the wrapper props the Content provider exposes on `data-*-content`'s
|
|
* wrapper. Merges the floating layer's own wrapperProps (positioning + role
|
|
* + transition attrs) with `pointer-events` forced ON so portaled content
|
|
* captures clicks.
|
|
*
|
|
* Tooltip overrides `pointerEvents` to toggle on `hoverableDisabled`; the
|
|
* rest pass `'auto'` (default).
|
|
*
|
|
* Returns the spread + an explicit `style: Record<string, unknown>` shape
|
|
* so Content components can pass it to `styleToString` directly without TS
|
|
* narrowing tripping on the upstream `string | Record<...>` union.
|
|
*/
|
|
export function buildFloatingShellWrapperProps(
|
|
floating: FloatingContent,
|
|
pointerEvents: 'auto' | 'none' = 'auto'
|
|
): FloatingContent['wrapperProps'] & { style: Record<string, unknown> } {
|
|
const baseStyle = floating.wrapperProps.style;
|
|
// The spread + conditional object widens the inferred type beyond
|
|
// what the floating wrapperProps contract narrows (e.g. `transform`
|
|
// goes from required-string to optional). At runtime the keys are
|
|
// always present — we cast at the boundary so consumers downstream
|
|
// keep the strict shape.
|
|
return {
|
|
...floating.wrapperProps,
|
|
style: {
|
|
...(typeof baseStyle === 'object' && baseStyle !== null ? baseStyle : {}),
|
|
'pointer-events': pointerEvents
|
|
}
|
|
} as FloatingContent['wrapperProps'] & { style: Record<string, unknown> };
|
|
}
|