import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider'; import { createAttrs, registerContract } from '../../attrs'; import { readableActive, state, type Active, type ActiveProps, type StateProps } from '../../reactive'; import type { OnChangeFn, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '../../types'; import { KEYS } from '../../keyboard'; import { Presence } from '../../layers/presence.svelte'; import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; import { SafePolygon } from '../../layers/floating/safe-polygon'; import { FloatingProvider, FloatingContent, FloatingArrow, FloatingAnchor, type Side, type Align, type Boundary } from '../../layers/floating'; import { tooltipMorfo } from '../../../morfo/components/tooltip'; const attrs = createAttrs(tooltipMorfo); registerContract(tooltipMorfo); export type TooltipState = 'closed' | 'delayed-open' | 'instant-open'; // ── Group Provider (coordinates skip-delay between sibling tooltips) ───────── interface TooltipGroupOpts { delayDuration: Active; skipDelayDuration: Active; disableHoverableContent: Active; disabled: Active; } export class TooltipGroupProvider { static readonly ctx = context('TooltipGroup'); static get(): TooltipGroupProvider | undefined { return this.ctx.getOr(undefined) as TooltipGroupProvider | undefined; } static require(): TooltipGroupProvider { return this.ctx.get(); } static create(opts: TooltipGroupOpts) { return TooltipGroupProvider.ctx.set(new TooltipGroupProvider(opts)); } readonly opts: TooltipGroupOpts; /** Timestamp of last tooltip close — used for skip-delay. */ lastCloseTime = 0; private constructor(opts: TooltipGroupOpts) { this.opts = opts; } /** Returns true if we're within the skip-delay window (instant open). */ isWithinSkipDelay(): boolean { return Date.now() - this.lastCloseTime < this.opts.skipDelayDuration.current; } markClosed() { this.lastCloseTime = Date.now(); } } // ── Root (per-tooltip instance) ────────────────────────────────────────────── interface TooltipOpts extends ProviderOpts, StateProps<{ open: boolean }>, ActiveProps<{ delayDuration: number | undefined; closeDelay: number; disableHoverableContent: boolean | undefined; disabled: boolean | undefined; onOpenChangeComplete: OnChangeFn; }> {} export class TooltipProvider extends Provider { static readonly ctx = context('Tooltip'); static get(): TooltipProvider | undefined { return this.ctx.getOr(undefined) as TooltipProvider | undefined; } static require(): TooltipProvider { return this.ctx.get(); } static create(opts: TooltipOpts) { return new TooltipProvider(opts); } readonly group: TooltipGroupProvider | undefined; readonly floatingProvider: FloatingProvider; readonly contentPresence: Presence; private internalTooltipState = $state('closed'); private openTimeout: ReturnType | null = null; private closeTimeout: ReturnType | null = null; private contentRef = state(null); /** * Reactive tooltip state that syncs with the `open` prop. * When `open` is set programmatically (not via hover/focus), data-state updates accordingly. */ get tooltipState(): TooltipState { // If open is true but internal state is closed, it was set programmatically if (this.opts.open.current && this.internalTooltipState === 'closed') { return 'instant-open'; } // If open is false, always report closed if (!this.opts.open.current) { return 'closed'; } return this.internalTooltipState; } // tracked IDs and refs contentId = state(''); triggerId = state(''); triggerRef = state(null); private constructor(opts: TooltipOpts) { super(opts, 'Tooltip', 'provider', attrs.provider, TooltipProvider.ctx); this.group = TooltipGroupProvider.get(); this.floatingProvider = FloatingProvider.create(); this.contentPresence = new Presence({ open: opts.open, ref: this.contentRef, onComplete: (open) => opts.onOpenChangeComplete.current(open) }); // SafePolygon: prevents tooltip from closing when pointer traverses // the gap between trigger and content (hoverable tooltips) new SafePolygon({ enabled: () => opts.open.current && !this.hoverableDisabled, triggerNode: () => this.triggerRef.current, contentNode: () => this.contentRef.current, onPointerExit: () => this.handleClose(), buffer: 2, transitIntentTimeout: 200 }); } /** Effective delay (per-tooltip override → group default). */ get delay(): number { return this.opts.delayDuration.current ?? this.group?.opts.delayDuration.current ?? 700; } /** Effective disabled state. */ get isDisabled(): boolean { return (this.opts.disabled.current ?? this.group?.opts.disabled.current) || false; } /** Effective disableHoverableContent. */ get hoverableDisabled(): boolean { return ( (this.opts.disableHoverableContent.current ?? this.group?.opts.disableHoverableContent.current) || false ); } handleOpen(instant = false) { if (this.isDisabled) return; this.clearTimeouts(); if (instant || this.group?.isWithinSkipDelay()) { this.internalTooltipState = 'instant-open'; this.opts.open.current = true; } else { this.internalTooltipState = 'delayed-open'; this.openTimeout = setTimeout(() => { this.internalTooltipState = 'instant-open'; this.opts.open.current = true; }, this.delay); } } handleClose() { this.clearTimeouts(); const closeDelay = this.opts.closeDelay.current; if (closeDelay > 0) { this.closeTimeout = setTimeout(() => { this.doClose(); }, closeDelay); } else { this.doClose(); } } private doClose() { this.internalTooltipState = 'closed'; this.opts.open.current = false; this.group?.markClosed(); } /** Cancel pending close (when pointer enters content). */ cancelClose() { if (this.closeTimeout !== null) { clearTimeout(this.closeTimeout); this.closeTimeout = null; } } private clearTimeouts() { if (this.openTimeout !== null) { clearTimeout(this.openTimeout); this.openTimeout = null; } if (this.closeTimeout !== null) { clearTimeout(this.closeTimeout); this.closeTimeout = null; } } setContentRef(el: HTMLElement | null) { this.contentRef.current = el; } } // ── Trigger ────────────────────────────────────────────────────────────────── interface TooltipTriggerOpts extends WithRefOpts {} export class TooltipTriggerProvider extends Provider { static create(opts: TooltipTriggerOpts) { return new TooltipTriggerProvider(opts); } readonly provider: TooltipProvider; private constructor(opts: TooltipTriggerOpts) { // A30: mirror trigger element to provider via `onRefChange`. super(opts, 'Tooltip', 'trigger', attrs.trigger, undefined, (el) => { this.provider.triggerRef.current = el; }); this.provider = TooltipProvider.require(); this.provider.triggerId.current = opts.id.current; FloatingAnchor.create({ id: opts.id, ref: readableActive(() => opts.ref.current) }); } readonly onpointerenter = () => { this.provider.handleOpen(); }; readonly onpointerleave = () => { this.provider.handleClose(); }; readonly onfocus = () => { this.provider.handleOpen(true); }; readonly onblur = () => { this.provider.handleClose(); }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, tabindex: 0, 'aria-describedby': this.provider.opts.open.current ? this.provider.contentId.current || undefined : undefined, 'data-state': this.provider.tooltipState, onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave, onfocus: this.onfocus, onblur: this.onblur } as const) ); } // ── Content ────────────────────────────────────────────────────────────────── interface TooltipContentOpts extends WithRefOpts, ActiveProps<{ 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'; dir: Direction; style: StyleProperties | null | undefined | string; ariaLabel: string | undefined; onEscapeKeydown: (e: KeyboardEvent) => void; escapeKeydownBehavior: DismissalBehavior; }> {} export class TooltipContentProvider extends Provider { static create(opts: TooltipContentOpts) { return new TooltipContentProvider(opts); } readonly provider: TooltipProvider; readonly floating: FloatingContent; readonly dismissal: Dismissal; private constructor(opts: TooltipContentOpts) { super(opts, 'Tooltip', 'content', attrs.content, undefined, (el) => { this.provider.setContentRef(el); }); this.provider = TooltipProvider.require(); this.provider.contentId.current = opts.id.current; // Floating this.floating = FloatingContent.create({ id: opts.id, wrapperId: readableActive(() => `${opts.id.current}-wrapper`), side: opts.side, sideOffset: opts.sideOffset, align: opts.align, alignOffset: opts.alignOffset, arrowPadding: opts.arrowPadding, avoidCollisions: opts.avoidCollisions, collisionBoundary: opts.collisionBoundary, collisionPadding: opts.collisionPadding, sticky: opts.sticky, hideWhenDetached: opts.hideWhenDetached, updatePositionStrategy: opts.updatePositionStrategy, strategy: opts.strategy, onPlaced: readableActive(() => () => {}), dir: opts.dir, style: opts.style, enabled: readableActive(() => this.provider.opts.open.current), customAnchor: readableActive(() => null) }); // Dismissal (escape only, no interact-outside for tooltip) this.dismissal = Dismissal.use({ ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); if (!e.defaultPrevented) this.provider.handleClose(); }), escapeKeydownBehavior: opts.escapeKeydownBehavior, onInteractOutside: readableActive(() => () => {}), interactOutsideBehavior: readableActive(() => 'ignore' as const), onFocusOutside: readableActive(() => () => {}), isValidEvent: readableActive(() => () => false) }); } 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 onpointerenter = () => { if (!this.provider.hoverableDisabled) { this.provider.cancelClose(); } }; readonly onpointerleave = () => { this.provider.handleClose(); }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'tooltip' as const, 'aria-label': this.opts.ariaLabel.current || undefined, 'data-state': this.provider.tooltipState, ...this.floating.props, ...this.provider.contentPresence.transitionAttrs, ...this.dismissal.props, onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave } as const) ); } // ── Arrow ──────────────────────────────────────────────────────────────────── interface TooltipArrowOpts extends WithRefOpts {} export class TooltipArrowProvider extends Provider { static create(opts: TooltipArrowOpts) { return new TooltipArrowProvider(opts); } readonly floatingArrow: FloatingArrow; private constructor(opts: TooltipArrowOpts) { super(opts, 'Tooltip', 'arrow', attrs.arrow); this.floatingArrow = FloatingArrow.create(opts); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, ...this.floatingArrow.props } as const) ); }