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 = []; } }