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.
svelte-kit-vice/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts

480 lines
13 KiB

import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import {
readableActive,
state,
type Active,
type ActiveProps,
type StateProps
} from '$libs/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';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
export type TooltipState = 'closed' | 'delayed-open' | 'instant-open';
// ── Group Provider (coordinates skip-delay between sibling tooltips) ─────────
interface TooltipGroupOpts {
delayDuration: Active<number>;
skipDelayDuration: Active<number>;
disableHoverableContent: Active<boolean>;
disabled: Active<boolean>;
}
export class TooltipGroupProvider {
static readonly ctx = context<TooltipGroupProvider>('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<boolean>;
}> {}
export class TooltipProvider {
readonly opts: TooltipOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<TooltipProvider>('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<TooltipState>('closed');
private openTimeout: ReturnType<typeof setTimeout> | null = null;
private closeTimeout: ReturnType<typeof setTimeout> | null = null;
private contentRef = state<HTMLElement | null>(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<HTMLElement | null>(null);
private constructor(opts: TooltipOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(tooltipMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: 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 {
readonly opts: TooltipTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TooltipTriggerOpts) {
return new TooltipTriggerProvider(opts);
}
readonly provider: TooltipProvider;
private constructor(opts: TooltipTriggerOpts) {
// A30: mirror trigger element to provider via `onRefChange`.
this.opts = opts;
this.provider = TooltipProvider.require();
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
states: {
'instant-open': () => this.provider.tooltipState
},
parts: {
content: () =>
this.provider.opts.open.current ? this.provider.contentId.current || undefined : undefined
},
owner: this,
onRefChange: (el) => {
this.provider.triggerRef.current = el;
},
syncAttrs: true
});
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.runtimePart.assert({
...this.runtimePart.props,
tabindex: 0,
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<Boundary>;
collisionPadding: number | Partial<Record<Side, number>>;
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 {
readonly opts: TooltipContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TooltipContentOpts) {
return new TooltipContentProvider(opts);
}
readonly provider: TooltipProvider;
readonly floating: FloatingContent;
readonly dismissal: Dismissal;
private constructor(opts: TooltipContentOpts) {
this.opts = opts;
this.provider = TooltipProvider.require();
this.runtimePart = this.provider.runtime.part('content', {
id: opts.id,
ref: opts.ref,
states: {
'instant-open': () => this.provider.tooltipState
},
props: {
ariaLabel: () => this.opts.ariaLabel.current
},
owner: this,
onRefChange: (el) => {
this.provider.setContentRef(el);
},
syncAttrs: true
});
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.runtimePart.assert({
...this.runtimePart.props,
...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 {
readonly opts: TooltipArrowOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: TooltipProvider;
static create(opts: TooltipArrowOpts) {
return new TooltipArrowProvider(opts);
}
readonly floatingArrow: FloatingArrow;
private constructor(opts: TooltipArrowOpts) {
this.opts = opts;
this.provider = TooltipProvider.require();
this.runtimePart = this.provider.runtime.part('arrow', {
id: opts.id,
ref: opts.ref,
owner: this
});
this.floatingArrow = FloatingArrow.create(opts);
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
...this.floatingArrow.props
} as const)
);
}

Powered by TurnKey Linux.