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.
480 lines
13 KiB
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)
|
|
);
|
|
}
|