import { createSignal, createUniqueId, onCleanup, Show, type JSX, type Accessor, splitProps } from 'solid-js'; import { Portal } from 'solid-js/web'; import { type Placement } from '@floating-ui/dom'; import { cn } from '../utils/cn'; import { useChatConfig } from '../primitives/chat-config'; import { createPresence, usePosition, useDismiss, As } from './overlay'; /** Imperative open controller, handed to a parent (e.g. the kai-tooltip facade) * via `controllerRef` so it can drive/observe open state. */ export interface TooltipController { open: Accessor; setOpen: (v: boolean) => void; } export interface TooltipProps { content: string; children: JSX.Element; class?: string; openDelay?: number; /** Delay (ms) before it hides after the pointer leaves. Defaults to 0 (hides * immediately). */ closeDelay?: number; /** Preferred placement (post flip/shift). Defaults to `'top'`. */ placement?: Placement; /** Initial open state on mount (uncontrolled seed). */ defaultOpen?: boolean; /** When true, hover/focus (and `show()`) never opens the tooltip. */ disabled?: boolean; /** Receive the open controller (open accessor + setOpen) once mounted. */ controllerRef?: (api: TooltipController) => void; dismissOnClick?: boolean; } export function Tooltip(props: TooltipProps) { const [local] = splitProps(props, ['content', 'children', 'class', 'openDelay', 'closeDelay', 'placement', 'defaultOpen', 'disabled', 'controllerRef', 'dismissOnClick']); const config = useChatConfig(); const id = createUniqueId(); const [open, setOpen] = createSignal(local.defaultOpen ?? false); const [triggerEl, setTriggerEl] = createSignal(); const [contentEl, setContentEl] = createSignal(); // Hand the open controller up so a facade (kai-tooltip) can drive/observe it. // setOpen is gated on `disabled` so the disclosure surface (show/hide/toggle) // can never force the tooltip open while disabled. local.controllerRef?.({ open, setOpen: (v) => setOpen(v && !local.disabled) }); let timer: number | undefined; // True for the span of a click (pointerdown → focus → click). Suppresses the // focus-show so clicking a hovered trigger DISMISSES it instead of flickering // hide→show→hide (pointerdown hid it, the click-induced focus re-showed it, // then click hid it again). Keyboard focus has no preceding pointerdown, so it // still shows the tooltip normally. let pointerDown = false; const [pointerInside, setPointerInside] = createSignal(false); const [focusInside, setFocusInside] = createSignal(false); const show = (delay = 0) => { if (local.disabled) return; clearTimeout(timer); if (delay) timer = window.setTimeout(() => setOpen(true), delay); else setOpen(true); }; const hide = () => { clearTimeout(timer); const delay = local.closeDelay ?? 0; if (delay) timer = window.setTimeout(() => setOpen(false), delay); else setOpen(false); }; const maybeHide = () => { if (!pointerInside() && !focusInside()) hide(); }; // Action-style tooltips should dismiss when their trigger is clicked: the pointer // never "leaves", so reset the inside flags and force-close until the next genuine // hover/focus. Opt out with dismissOnClick={false}. const dismiss = () => { if (local.dismissOnClick === false) return; setPointerInside(false); setFocusInside(false); hide(); }; onCleanup(() => clearTimeout(timer)); const presence = createPresence(open); const position = usePosition(triggerEl, contentEl, { placement: local.placement ?? 'top', gutter: 6, // Trigger removed from the DOM -> close so the tooltip portal doesn't orphan. onDisconnect: () => setOpen(false), }); useDismiss({ enabled: open, onDismiss: hide, refs: () => [triggerEl(), contentEl()] }); return ( <> { setPointerInside(true); show(local.openDelay ?? 600); }} onPointerLeave={() => { setPointerInside(false); maybeHide(); }} onPointerDown={() => { pointerDown = true; dismiss(); window.setTimeout(() => { pointerDown = false; }, 0); }} onClick={dismiss} onFocusIn={() => { setFocusInside(true); if (!pointerDown) show(); }} onFocusOut={(e: FocusEvent) => { const t = triggerEl(); if (t && t.contains(e.relatedTarget as Node)) return; setFocusInside(false); maybeHide(); }} > {local.children}
{ setContentEl(el); presence.setRef(el); }} id={id} role="tooltip" data-expanded={presence.state() === 'open' ? '' : undefined} data-closed={presence.state() === 'closed' ? '' : undefined} style={{ position: 'fixed', left: `${position.pos().x}px`, top: `${position.pos().y}px`, 'pointer-events': 'none', visibility: position.hidden() ? 'hidden' : 'visible' }} class={cn( 'z-50 rounded-md bg-foreground px-2.5 py-1 text-xs text-background shadow-md', 'animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95', local.class, )} > {local.content}
); }