import { AnimatePresence, motion } from 'framer-motion'; import { type ReactNode, cloneElement, isValidElement, useRef, useState, useCallback, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import styles from './Tooltip.module.css'; export interface TooltipProps { content: ReactNode; placement?: 'top' | 'bottom' | 'left' | 'right'; children: ReactNode; delay?: number; /** * Optional keyboard shortcut rendered as a row of keycaps under * the main label. Accepts a lowercase `+`-joined combo string * like `"ctrl+shift+m"` (the same format the KeybindStore uses). * Empty / undefined hides the row entirely. */ shortcut?: string; /** * When true the tooltip stays open while the cursor is over the * floating panel (not just the trigger), and clicks on the panel * bubble through to `onContentClick`. Used for reaction chips * where the tooltip doubles as a "click to view details" target. */ interactive?: boolean; /** * Fired when the user clicks the floating content. Only hooked * up when `interactive` is true. */ onContentClick?: () => void; } /** Split a KeybindStore-format combo into display tokens and label * each one so the Tooltip can render them as individual keycaps. */ function parseShortcut(combo: string): string[] { if (!combo) return []; return combo .split('+') .map((t) => t.trim()) .filter(Boolean) .map(formatShortcutToken); } function formatShortcutToken(token: string): string { // KeybindContext emits combos like `Ctrl+Shift+M` or `Ctrl+,` — so // normalize the incoming token to lowercase for switch matching, // but keep the original around for the default branch so // punctuation and single letters round-trip as uppercase keycaps. const lower = token.toLowerCase(); switch (lower) { case 'ctrl': case 'control': return 'CTRL'; case 'shift': return '⇧'; case 'alt': case 'option': return 'ALT'; case 'meta': case 'cmd': case 'command': return '⌘'; case 'arrowup': return '↑'; case 'arrowdown': return '↓'; case 'arrowleft': return '←'; case 'arrowright': return '→'; case 'escape': return 'ESC'; case 'enter': return '↵'; case 'tab': return 'TAB'; case ' ': case 'space': return 'SPACE'; default: return token.length === 1 ? token.toUpperCase() : token.toUpperCase(); } } export function Tooltip({ content, placement = 'top', children, delay = 300, shortcut, interactive = false, onContentClick, }: TooltipProps) { const [isOpen, setIsOpen] = useState(false); const referenceRef = useRef(null); const floatingRef = useRef(null); const openTimeoutRef = useRef>(undefined); // Close timer — separate from the open timer so we can cancel a // pending close when the cursor enters the floating panel. const closeTimeoutRef = useRef>(undefined); const [pos, setPos] = useState({ position: 'fixed', visibility: 'hidden' }); /** * Callback ref used when cloning the trigger. Stores the DOM node * AND forwards it to whatever ref the caller already had on the * child — without this forwarding the Tooltip would overwrite * refs like `pinsButtonRef` and break any `getBoundingClientRect` * logic on the consumer side. */ const makeSetRef = useCallback( (childRef: any) => (node: HTMLElement | null) => { referenceRef.current = node; if (!childRef) return; if (typeof childRef === 'function') { childRef(node); } else if (typeof childRef === 'object' && 'current' in childRef) { (childRef as { current: HTMLElement | null }).current = node; } }, [], ); // Compute position after the tooltip mounts and the reference is visible useLayoutEffect(() => { if (!isOpen || !referenceRef.current) return; const computePos = () => { const el = referenceRef.current; const floating = floatingRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const floatingRect = floating?.getBoundingClientRect(); const fw = floatingRect?.width || 0; const fh = floatingRect?.height || 0; const style: React.CSSProperties = { position: 'fixed', zIndex: 20000 }; if (placement === 'top') { style.left = rect.left + rect.width / 2 - fw / 2; style.top = rect.top - fh - 8; } else if (placement === 'bottom') { style.left = rect.left + rect.width / 2 - fw / 2; style.top = rect.bottom + 8; } else if (placement === 'left') { style.left = rect.left - fw - 8; style.top = rect.top + rect.height / 2 - fh / 2; } else { style.left = rect.right + 8; style.top = rect.top + rect.height / 2 - fh / 2; } setPos(style); }; // First render: measure floating element then position requestAnimationFrame(computePos); }, [isOpen, placement]); const handleMouseEnter = useCallback(() => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current); openTimeoutRef.current = setTimeout(() => setIsOpen(true), delay); }, [delay]); const handleMouseLeave = useCallback(() => { if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current); // Interactive mode: give the user a short grace window to // move from the trigger onto the floating panel. Non- // interactive tooltips still close instantly to match the // classic label behaviour. if (interactive) { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 150); } else { setIsOpen(false); } }, [interactive]); const handleFloatingEnter = useCallback(() => { if (!interactive) return; if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); }, [interactive]); const handleFloatingLeave = useCallback(() => { if (!interactive) return; if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 150); }, [interactive]); return ( <> {isValidElement(children) && cloneElement(children as React.ReactElement, { ref: makeSetRef((children as any).ref), onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onFocus: handleMouseEnter, onBlur: handleMouseLeave, })} {createPortal( {isOpen && ( { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); setIsOpen(false); onContentClick(); } : undefined } >
{content}
{shortcut && (
{parseShortcut(shortcut).map((token, i) => ( {token} ))}
)}
)}
, document.body, )} ); }