import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' type TooltipSide = 'top' | 'bottom' | 'left' | 'right' type TooltipAlign = 'center' | 'start' | 'end' // Horizontal alignment only applies to top/bottom; left/right center vertically. function positionClasses(side: TooltipSide, align: TooltipAlign): string { if (side === 'left') return 'right-full top-1/2 mr-1.5 -translate-y-1/2' if (side === 'right') return 'left-full top-1/2 ml-1.5 -translate-y-1/2' const vertical = side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5' const horizontal = align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2' return `${vertical} ${horizontal}` } const BASE_CLASSES = 'pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg' const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100` /** * Lightweight custom tooltip — fades in (faster than the native one) after a * tunable hover `delay`, and hides instantly on leave or click. By default it * anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's * entry position; `followCursor` keeps tracking the pointer. */ export function Tooltip({ label, delay = 400, side = 'bottom', align = 'center', block = false, anchorToCursor = false, followCursor = false, disabled = false, className = '', children, }: { label: ReactNode /** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */ delay?: number side?: TooltipSide /** Horizontal alignment for top/bottom tooltips. */ align?: TooltipAlign /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ block?: boolean /** Position at the cursor when shown, without following subsequent movement. */ anchorToCursor?: boolean /** Track the cursor (fixed position) instead of anchoring to a side. */ followCursor?: boolean /** Render the wrapper but suppress tooltip display. */ disabled?: boolean /** Extra classes for the wrapper (e.g. layout). */ className?: string children: ReactNode }) { const [visible, setVisible] = useState(false) const [coords, setCoords] = useState({ x: 0, y: 0 }) const [mounted, setMounted] = useState(false) const timer = useRef | undefined>(undefined) const frame = useRef(undefined) const tooltipRef = useRef(null) // Resolve a viewport-safe position near the cursor: below-right by default, // but flipped above the cursor if it would overflow the bottom edge, and // clamped so it never runs off the right/left. const place = (clientX: number, clientY: number) => { const tip = tooltipRef.current const tipWidth = tip?.offsetWidth ?? 0 const tipHeight = tip?.offsetHeight ?? 24 const gap = 16 let top = clientY + gap if (top + tipHeight > window.innerHeight - 4) { top = clientY - gap - tipHeight } let left = clientX + 14 if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth if (left < 4) left = 4 setCoords({ x: left, y: top }) } const clear = () => { if (timer.current) clearTimeout(timer.current) timer.current = undefined if (frame.current !== undefined) cancelAnimationFrame(frame.current) frame.current = undefined } const show = (event: MouseEvent) => { if (disabled) return clear() if (anchorToCursor || followCursor) place(event.clientX, event.clientY) if (delay <= 0) { setVisible(true) return } timer.current = setTimeout(() => setVisible(true), delay) } const hide = () => { clear() setVisible(false) } const move = (event: MouseEvent) => { if (!followCursor) return const { clientX, clientY } = event if (frame.current !== undefined) cancelAnimationFrame(frame.current) frame.current = requestAnimationFrame(() => { frame.current = undefined place(clientX, clientY) }) } useEffect(() => { setMounted(true) return clear }, []) useEffect(() => { if (disabled) hide() }, [disabled]) const Wrapper = block ? 'div' : 'span' const cursorTooltip = ( {label} ) return ( {children} {disabled ? null : anchorToCursor || followCursor ? ( mounted ? ( createPortal(cursorTooltip, document.body) ) : null ) : ( {label} )} ) }