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, 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; /** 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) => { 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; }, []); const Wrapper = block ? "div" : "span"; const cursorTooltip = ( {label} ); return ( {children} {anchorToCursor || followCursor ? ( mounted ? createPortal(cursorTooltip, document.body) : null ) : ( {label} )} ); }