import { type Middleware, type Placement, arrow, autoUpdate, flip, hide, limitShift, offset, shift, size } from '@floating-ui/dom'; import { attachRef, type RefAttachment } from '$libs/reactive'; import { cssToStyleObj, styleToString } from '$soma/css'; import type { ActiveDom } from '$adom'; import { state, readableActive, type Active, type ActiveProps } from '$libs/reactive'; import { isNotNull } from '$soma/types'; import { useId } from '$soma/id'; import { context, type SomaContext } from '$soma/provider'; import type { Arrayable, Direction, StyleProperties } from '$soma/types'; import type { WithRefOpts } from '$soma/provider'; import { ElementSize, watch } from 'runed'; import { useFloating } from './use-floating.svelte'; import type { Measurable, UseFloatingReturn } from './types'; import { OPPOSITE_SIDE, type Align, type Boundary, type Side } from './placement'; // ── FloatingProvider ───────────────────────────────────────────────────────── export interface FloatingProviderOpts { dom: ActiveDom; } export class FloatingProvider { static readonly ctx = context('FloatingProvider'); static create(opts: FloatingProviderOpts) { return FloatingProvider.ctx.set(new FloatingProvider(opts)); } readonly opts: FloatingProviderOpts; anchorNode = state(null); customAnchorNode = state(null); triggerNode: Active = state(null); private constructor(opts: FloatingProviderOpts) { this.opts = opts; $effect(() => { if (this.customAnchorNode.current) { if (typeof this.customAnchorNode.current === 'string') { const el = this.opts.dom.query(this.customAnchorNode.current); this.anchorNode.current = el; } else { this.anchorNode.current = this.customAnchorNode.current; } } else { this.anchorNode.current = this.triggerNode.current; } }); } } // ── FloatingContent ────────────────────────────────────────────────────────── export interface FloatingContentOpts extends ActiveProps<{ id: string; wrapperId: string; side: Side; sideOffset: number; align: Align; alignOffset: number; arrowPadding: number; avoidCollisions: boolean; collisionBoundary: Arrayable; collisionPadding: number | Partial>; sticky: 'partial' | 'always'; hideWhenDetached: boolean; updatePositionStrategy: 'optimized' | 'always'; strategy: 'fixed' | 'absolute'; onPlaced: () => void; dir: Direction; style: StyleProperties | null | undefined | string; enabled: boolean; customAnchor: string | HTMLElement | null | Measurable; }> {} export class FloatingContent { static readonly ctx = context('FloatingContent'); static create(opts: FloatingContentOpts) { return FloatingContent.ctx.set(new FloatingContent(opts, FloatingProvider.ctx.get())); } readonly opts: FloatingContentOpts; readonly provider: FloatingProvider; // nodes contentRef = state(null); wrapperRef = state(null); arrowRef = state(null); readonly contentAttachment: RefAttachment = attachRef(this.contentRef); readonly wrapperAttachment: RefAttachment = attachRef(this.wrapperRef); readonly arrowAttachment: RefAttachment = attachRef(this.arrowRef); // ids arrowId = state(useId()); transformedStyle = $derived.by(() => { if (typeof this.opts.style === 'string') return cssToStyleObj(this.opts.style); if (!this.opts.style) return {}; }); updatePositionStrategy = undefined as unknown as FloatingContentOpts['updatePositionStrategy']; arrowSize = new ElementSize(() => this.arrowRef.current ?? undefined); arrowWidth = $derived(this.arrowSize?.width ?? 0); arrowHeight = $derived(this.arrowSize?.height ?? 0); desiredPlacement = $derived.by( () => (this.opts.side?.current + (this.opts.align.current !== 'center' ? `-${this.opts.align.current}` : '')) as Placement ); boundary = $derived.by(() => Array.isArray(this.opts.collisionBoundary.current) ? this.opts.collisionBoundary.current : [this.opts.collisionBoundary.current] ); hasExplicitBoundaries = $derived(this.boundary.length > 0); detectOverflowOptions = $derived.by(() => ({ padding: this.opts.collisionPadding.current, boundary: this.boundary.filter(isNotNull), altBoundary: this.hasExplicitBoundaries })); availableWidth = $state(undefined); availableHeight = $state(undefined); anchorWidth = $state(undefined); anchorHeight = $state(undefined); middleware: Middleware[] = $derived.by( () => [ offset({ mainAxis: this.opts.sideOffset.current + this.arrowHeight, alignmentAxis: this.opts.alignOffset.current }), this.opts.avoidCollisions.current && shift({ mainAxis: true, crossAxis: false, limiter: this.opts.sticky.current === 'partial' ? limitShift() : undefined, ...this.detectOverflowOptions }), this.opts.avoidCollisions.current && flip({ ...this.detectOverflowOptions }), size({ ...this.detectOverflowOptions, apply: ({ rects, availableWidth, availableHeight }) => { const { width: anchorWidth, height: anchorHeight } = rects.reference; this.availableWidth = availableWidth; this.availableHeight = availableHeight; this.anchorWidth = anchorWidth; this.anchorHeight = anchorHeight; } }), this.arrowRef.current && arrow({ element: this.arrowRef.current, padding: this.opts.arrowPadding.current }), transformOrigin({ arrowWidth: this.arrowWidth, arrowHeight: this.arrowHeight }), this.opts.hideWhenDetached.current && hide({ strategy: 'referenceHidden', ...this.detectOverflowOptions }) ].filter(Boolean) as Middleware[] ); floating: UseFloatingReturn; placedSide = $derived.by(() => getSideFromPlacement(this.floating.placement.current)); placedAlign = $derived.by(() => getAlignFromPlacement(this.floating.placement.current)); arrowX = $derived.by(() => this.floating.middlewareData.current.arrow?.x); arrowY = $derived.by(() => this.floating.middlewareData.current.arrow?.y); cannotCenterArrow = $derived.by(() => { const centerOffset = this.floating.middlewareData.current.arrow?.centerOffset; return centerOffset !== undefined && centerOffset !== 0; }); contentZIndex = $state(); arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]); wrapperProps = $derived.by( () => ({ id: this.opts.wrapperId.current, 'data-soma-floating-wrapper': '', style: { ...this.floating.floatingStyles.current, transform: this.floating.isPositioned.current ? this.floating.floatingStyles.current.transform : 'translate(0, -200%)', minWidth: 'max-content', zIndex: this.contentZIndex && this.contentZIndex !== 'auto' ? this.contentZIndex : undefined, '--soma-floating-transform-origin': `${this.floating.middlewareData.current.transformOrigin?.x} ${this.floating.middlewareData.current.transformOrigin?.y}`, '--soma-floating-available-width': `${this.availableWidth}px`, '--soma-floating-available-height': `${this.availableHeight}px`, '--soma-floating-anchor-width': `${this.anchorWidth}px`, '--soma-floating-anchor-height': `${this.anchorHeight}px`, ...(this.floating.middlewareData.current.hide?.referenceHidden && { visibility: 'hidden', 'pointer-events': 'none' }), ...this.transformedStyle }, dir: this.opts.dir.current, ...this.wrapperAttachment }) as const ); props = $derived.by( () => ({ 'data-side': this.placedSide, 'data-align': this.placedAlign, style: styleToString({ ...this.transformedStyle }), ...this.contentAttachment }) as const ); arrowStyle = $derived({ position: 'absolute', left: this.arrowX !== undefined ? `${this.arrowX}px` : undefined, top: this.arrowY !== undefined ? `${this.arrowY}px` : undefined, [this.arrowBaseSide]: 0, 'transform-origin': { top: '', right: '0 0', bottom: 'center 0', left: '100% 0' }[this.placedSide], transform: { top: 'translateY(100%)', right: 'translateY(50%) rotate(90deg) translateX(-50%)', bottom: 'rotate(180deg)', left: 'translateY(50%) rotate(-90deg) translateX(50%)' }[this.placedSide], visibility: this.cannotCenterArrow ? ('hidden' as const) : undefined }); private constructor(opts: FloatingContentOpts, provider: FloatingProvider) { this.opts = opts; this.provider = provider; this.updatePositionStrategy = opts.updatePositionStrategy; if (opts.customAnchor) { this.provider.customAnchorNode.current = opts.customAnchor.current; } watch( () => opts.customAnchor.current, (customAnchor) => { this.provider.customAnchorNode.current = customAnchor; } ); this.floating = useFloating({ strategy: () => this.opts.strategy.current, placement: () => this.desiredPlacement, middleware: () => this.middleware, reference: this.provider.anchorNode, whileElementsMounted: (...args) => { const cleanup = autoUpdate(...args, { animationFrame: this.updatePositionStrategy?.current === 'always' }); return cleanup; }, open: () => this.opts.enabled.current, sideOffset: () => this.opts.sideOffset.current, alignOffset: () => this.opts.alignOffset.current }); $effect(() => { if (!this.floating.isPositioned.current) return; this.opts.onPlaced?.current(); }); watch( () => this.contentRef.current, (contentNode) => { 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; if (zIndex !== this.contentZIndex) { this.contentZIndex = zIndex; } }, contentNode); return () => { this.provider.opts.dom.cancelFrame(rafId, contentNode); }; } ); $effect(() => { this.floating.floating.current = this.wrapperRef.current; }); } } // ── FloatingArrow ──────────────────────────────────────────────────────────── interface FloatingArrowOpts extends WithRefOpts {} export class FloatingArrow { static create(opts: FloatingArrowOpts) { return new FloatingArrow(opts, FloatingContent.ctx.get()); } readonly opts: FloatingArrowOpts; readonly content: FloatingContent; constructor(opts: FloatingArrowOpts, content: FloatingContent) { this.opts = opts; this.content = content; } readonly props = $derived.by( () => ({ id: this.opts.id.current, style: this.content.arrowStyle, 'data-side': this.content.placedSide, ...this.content.arrowAttachment }) as const ); } // ── FloatingAnchor ─────────────────────────────────────────────────────────── interface FloatingAnchorOpts extends ActiveProps<{ id: string; virtualEl?: Measurable | null; ref: Measurable | HTMLElement | null; }> {} export class FloatingAnchor { static create(opts: FloatingAnchorOpts) { return new FloatingAnchor(opts, FloatingProvider.ctx.get()); } readonly opts: FloatingAnchorOpts; readonly provider: FloatingProvider; constructor(opts: FloatingAnchorOpts, provider: FloatingProvider) { this.opts = opts; this.provider = provider; if (opts.virtualEl && opts.virtualEl.current) { provider.triggerNode = readableActive(() => opts.virtualEl!.current ?? null); } else { provider.triggerNode = opts.ref; } } } // ── Helpers ────────────────────────────────────────────────────────────────── function transformOrigin(options: { arrowWidth: number; arrowHeight: number }): Middleware { return { name: 'transformOrigin', options, fn(data) { const { placement, rects, middlewareData } = data; const centerOffset = middlewareData.arrow?.centerOffset; const cannotCenterArrow = centerOffset !== undefined && centerOffset !== 0; const isArrowHidden = cannotCenterArrow; const arrowWidth = isArrowHidden ? 0 : options.arrowWidth; const arrowHeight = isArrowHidden ? 0 : options.arrowHeight; const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement); const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[placedAlign]; const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2; const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2; let x = ''; let y = ''; if (placedSide === 'bottom') { x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`; y = `${-arrowHeight}px`; } else if (placedSide === 'top') { x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`; y = `${rects.floating.height + arrowHeight}px`; } else if (placedSide === 'right') { x = `${-arrowHeight}px`; y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`; } else if (placedSide === 'left') { x = `${rects.floating.width + arrowHeight}px`; y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`; } return { data: { x, y } }; } }; } function getSideAndAlignFromPlacement(placement: Placement) { const [side, align = 'center'] = placement.split('-'); return [side as Side, align as Align] as const; } export function getSideFromPlacement(placement: Placement) { return getSideAndAlignFromPlacement(placement)[0]; } export function getAlignFromPlacement(placement: Placement) { return getSideAndAlignFromPlacement(placement)[1]; }