/** * 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; /** Ref to the Content element. Presence listens to its mount/unmount. */ contentRef: State; /** * 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 | 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` 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 } { 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 }; }