import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift } from '@floating-ui/dom';
import { uid } from '../shared/dom';
import {
TOOLTIP_CLOSE_EVENT,
TOOLTIP_OPEN_EVENT,
type Placement,
type TooltipOptions,
} from './tooltip.types';
const SELECTORS = {
trigger: '[data-c42-tooltip-trigger]',
content: '[data-c42-tooltip-content]',
} as const;
/**
* Headless tooltip controller. Shows on hover/focus, hides on blur/leave/Escape,
* and positions the content with floating-ui. Applies no visual styles.
*
* Markup:
* ```html
*
*
* Helpful tip
*
* ```
*/
export class Tooltip {
private readonly root: HTMLElement;
private readonly trigger: HTMLElement;
private readonly content: HTMLElement;
private readonly placement: Placement;
private readonly offset: number;
private readonly openDelay: number;
private readonly closeDelay: number;
private open = false;
private timer: ReturnType | null = null;
private stopAutoUpdate: (() => void) | null = null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: TooltipOptions = {}) {
const trigger = root.querySelector(SELECTORS.trigger);
const content = root.querySelector(SELECTORS.content);
if (!trigger || !content) {
throw new Error('[42/tooltip] Needs a trigger and a content element.');
}
this.root = root;
this.trigger = trigger;
this.content = content;
this.placement = options.placement ?? 'top';
this.offset = options.offset ?? 8;
this.openDelay = options.openDelay ?? 0;
this.closeDelay = options.closeDelay ?? 0;
this.init();
}
private init(): void {
const contentId = this.content.id || uid('tooltip');
this.content.id = contentId;
this.content.setAttribute('role', 'tooltip');
this.trigger.setAttribute('aria-describedby', contentId);
this.content.setAttribute('hidden', '');
this.content.dataset.state = 'closed';
this.trigger.dataset.state = 'closed';
const listeners: Array<[HTMLElement, string, EventListener]> = [
[this.trigger, 'mouseenter', () => this.scheduleOpen()],
[this.trigger, 'mouseleave', () => this.scheduleClose()],
[this.trigger, 'focus', () => this.show()],
[this.trigger, 'blur', () => this.hide()],
[this.trigger, 'keydown', (event) => this.onKeydown(event as KeyboardEvent)],
[this.content, 'mouseenter', () => this.scheduleOpen()],
[this.content, 'mouseleave', () => this.scheduleClose()],
];
for (const [el, type, handler] of listeners) {
el.addEventListener(type, handler);
this.cleanups.push(() => el.removeEventListener(type, handler));
}
}
private onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && this.open) {
this.hide();
}
}
private clearTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
private scheduleOpen(): void {
this.clearTimer();
if (this.openDelay > 0) {
this.timer = setTimeout(() => this.show(), this.openDelay);
} else {
this.show();
}
}
private scheduleClose(): void {
this.clearTimer();
if (this.closeDelay > 0) {
this.timer = setTimeout(() => this.hide(), this.closeDelay);
} else {
this.hide();
}
}
show(): void {
this.clearTimer();
if (this.open) {
return;
}
this.open = true;
this.content.removeAttribute('hidden');
this.content.dataset.state = 'open';
this.trigger.dataset.state = 'open';
this.stopAutoUpdate = autoUpdate(this.trigger, this.content, () => void this.position());
this.root.dispatchEvent(new CustomEvent(TOOLTIP_OPEN_EVENT, { bubbles: true }));
}
hide(): void {
this.clearTimer();
if (!this.open) {
return;
}
this.open = false;
this.content.setAttribute('hidden', '');
this.content.dataset.state = 'closed';
this.trigger.dataset.state = 'closed';
this.stopAutoUpdate?.();
this.stopAutoUpdate = null;
this.root.dispatchEvent(new CustomEvent(TOOLTIP_CLOSE_EVENT, { bubbles: true }));
}
private async position(): Promise {
const { x, y } = await computePosition(this.trigger, this.content, {
placement: this.placement,
middleware: [offsetMiddleware(this.offset), flip(), shift({ padding: 5 })],
});
Object.assign(this.content.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' });
}
get isOpen(): boolean {
return this.open;
}
on(event: string, handler: (event: E) => void): () => void {
const listener = handler as EventListener;
this.root.addEventListener(event, listener);
const off = (): void => this.root.removeEventListener(event, listener);
this.cleanups.push(off);
return off;
}
destroy(): void {
this.hide();
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
}