diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts index 3401dfba4..45f2a92dc 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts @@ -27,6 +27,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Side, type Align, type Boundary @@ -132,14 +134,14 @@ export class ComboboxProvider { syncAttrs: true }); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; // Single-mode auto-sync: keep `inputValue` mirroring the selected // value's label so the input reflects external `value` changes @@ -598,15 +600,7 @@ export class ComboboxContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onkeydown = (e: KeyboardEvent) => { const container = this.opts.ref.current; diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts index 4c4436d02..21e8b417b 100644 --- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts +++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts @@ -24,6 +24,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Measurable, type Side, type Align, @@ -99,14 +101,14 @@ export class ContextMenuProvider { context: ContextMenuProvider.ctx }); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; $effect(() => { return () => this.typeahead.destroy(); @@ -309,15 +311,7 @@ export class ContextMenuContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onkeydown = (e: KeyboardEvent) => { const container = this.opts.ref.current; @@ -991,15 +985,7 @@ export class ContextMenuSubContentProvider { readonly isPresent = $derived.by(() => this.sub.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onkeydown = (e: KeyboardEvent) => { const dir = this.sub.provider.opts.dir.current; diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index b8a17803e..75b368655 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -30,6 +30,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Side, type Align, type Boundary @@ -96,14 +98,14 @@ export class MenuProvider { context: MenuProvider.ctx }); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; // Cleanup typeahead timer on unmount $effect(() => { @@ -332,15 +334,7 @@ export class MenuContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onkeydown = (e: KeyboardEvent) => { const container = this.opts.ref.current; @@ -1079,15 +1073,7 @@ export class MenuSubContentProvider { readonly isPresent = $derived.by(() => this.sub.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onkeydown = (e: KeyboardEvent) => { const dir = this.sub.provider.opts.dir.current; diff --git a/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts b/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts index 9a889451b..abf4e9bcf 100644 --- a/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts +++ b/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts @@ -11,6 +11,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Measurable, type Side, type Align, @@ -74,14 +76,14 @@ export class LinkPreviewProvider { context: LinkPreviewProvider.ctx }); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; // SafePolygon: preserve the preview when the pointer crosses the gap // between the trigger anchor and the content. @@ -321,15 +323,7 @@ export class LinkPreviewContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onpointerenter = (e: PointerEvent) => { if (e.pointerType === 'touch') return; diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index 7e9d5eb3d..7df588a34 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -30,6 +30,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Side, type Align, type Boundary @@ -98,15 +100,18 @@ export class PopoverProvider { PopoverProvider.ctx.set(this); this.soma = Soma.require(); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; + // Overlay is popover-only — separate Presence not folded into the + // shared shell because the other 6 providers don't have an overlay. this.overlayPresence = new Presence({ dom: this.soma.dom, open: opts.open, @@ -509,15 +514,7 @@ export class PopoverContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); // `role`, `aria-labelledby`, `aria-modal`, `data-state` come from the // morfo's Content part via the SomaRuntime; the layered attrs (floating diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index d995bff57..2ffdd4995 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -33,6 +33,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Side, type Align, type Boundary @@ -138,14 +140,14 @@ export class SelectProvider { syncAttrs: true }); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; // Cleanup typeahead timer on unmount $effect(() => { @@ -535,15 +537,7 @@ export class SelectContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': 'auto' - } - })); + readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly onkeydown = (e: KeyboardEvent) => { const container = this.opts.ref.current; diff --git a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts index 2ca1ce385..0e29db26d 100644 --- a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts +++ b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts @@ -22,6 +22,8 @@ import { FloatingContent, FloatingArrow, FloatingAnchor, + createFloatingShellRoot, + buildFloatingShellWrapperProps, type Side, type Align, type Boundary @@ -149,14 +151,14 @@ export class TooltipProvider { }); this.group = TooltipGroupProvider.get(); - this.floatingProvider = FloatingProvider.create({ dom: this.soma.dom }); - - this.contentPresence = new Presence({ + const shell = createFloatingShellRoot({ dom: this.soma.dom, open: opts.open, - ref: this.contentRef, - onComplete: (open) => opts.onOpenChangeComplete.current(open) + contentRef: this.contentRef, + onOpenChangeComplete: opts.onOpenChangeComplete }); + this.floatingProvider = shell.floatingProvider; + this.contentPresence = shell.contentPresence; // SafePolygon: prevents tooltip from closing when pointer traverses // the gap between trigger and content (hoverable tooltips) @@ -465,15 +467,12 @@ export class TooltipContentProvider { readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); - readonly wrapperProps = $derived.by(() => ({ - ...this.floating.wrapperProps, - style: { - ...(typeof this.floating.wrapperProps.style === 'object' - ? this.floating.wrapperProps.style - : {}), - 'pointer-events': this.provider.hoverableDisabled ? 'none' : 'auto' - } - })); + readonly wrapperProps = $derived.by(() => + buildFloatingShellWrapperProps( + this.floating, + this.provider.hoverableDisabled ? 'none' : 'auto' + ) + ); readonly onpointerenter = () => { if (!this.provider.hoverableDisabled) { diff --git a/src/uix/soma/layers/floating/index.ts b/src/uix/soma/layers/floating/index.ts index d94743dc0..dab5baddc 100644 --- a/src/uix/soma/layers/floating/index.ts +++ b/src/uix/soma/layers/floating/index.ts @@ -31,3 +31,11 @@ export { useFloating } from './use-floating.svelte'; // ── Utilities ──────────────────────────────────────────────────────────────── export { getDPR, roundByDPR, getFloatingContentCSSVars, isReferenceHidden } from './utils'; export { SafePolygon, type SafePolygonOptions } from './safe-polygon'; + +// ── Shell helpers (popover-based providers boilerplate) ──────────────────── +export { + createFloatingShellRoot, + buildFloatingShellWrapperProps, + type FloatingShellRootOpts, + type FloatingShellRoot +} from './shell'; diff --git a/src/uix/soma/layers/floating/shell.ts b/src/uix/soma/layers/floating/shell.ts new file mode 100644 index 000000000..59e1c73c9 --- /dev/null +++ b/src/uix/soma/layers/floating/shell.ts @@ -0,0 +1,97 @@ +/** + * 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; + return { + ...floating.wrapperProps, + style: { + ...(typeof baseStyle === 'object' && baseStyle !== null ? baseStyle : {}), + 'pointer-events': pointerEvents + } + }; +}