"use client"; import { AnimatePresence, motion, useReducedMotion, type Variants, } from "motion/react"; import { cloneElement, isValidElement, type ReactElement, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; type Side = "top" | "right" | "bottom" | "left"; export interface TooltipProps { content: ReactNode; children: ReactElement; side?: Side; /** Delay before showing (ms). Default 120. */ delay?: number; className?: string; /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */ wrapperClassName?: string; } // Gap between trigger and tooltip, in px. const GAP = 8; // Centering transform for the fixed-positioned anchor point, per side. const anchorTransform: Record = { top: "translate(-50%, -100%)", bottom: "translate(-50%, 0)", left: "translate(-100%, -50%)", right: "translate(0, -50%)", }; const transformOrigin: Record = { top: "center bottom", bottom: "center top", left: "right center", right: "left center", }; // Offset is in the direction *away* from the trigger — content originates near // the trigger and rises into resting position. const offsetFrom: Record = { top: { y: 8 }, bottom: { y: -8 }, left: { x: 8 }, right: { x: -8 }, }; function buildVariants(side: Side): Variants { const o = offsetFrom[side]; return { initial: { opacity: 0, scale: 0.9, filter: "blur(5px)", x: o.x ?? 0, y: o.y ?? 0, }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", x: 0, y: 0, transition: { type: "spring", stiffness: 380, damping: 30, mass: 0.7, opacity: { duration: 0.14, ease: EASE_OUT }, filter: { duration: 0.18, ease: EASE_OUT }, }, }, exit: { opacity: 0, scale: 0.94, filter: "blur(3px)", x: (o.x ?? 0) * 0.6, y: (o.y ?? 0) * 0.6, transition: { duration: 0.12, ease: EASE_OUT }, }, }; } const REDUCED_VARIANTS: Variants = { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } }, exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }, }; // Once any tooltip has just closed, neighbouring tooltips open without the // initial delay — moving along a toolbar feels instant after the first one. const WARM_WINDOW_MS = 300; let lastHiddenAt = 0; export function Tooltip({ content, children, side = "top", delay = 120, className, wrapperClassName, }: TooltipProps) { const [open, setOpen] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number } | null>( null, ); const id = useId(); const timer = useRef | null>(null); const anchorRef = useRef(null); const reduce = useReducedMotion(); const canHover = useHoverCapable(); // Anchor point in viewport coords, on the edge of the trigger facing `side`. // Position:fixed means these viewport coords place the tooltip directly, so // it escapes every ancestor's stacking context and overflow. const place = useCallback(() => { const el = anchorRef.current; if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const point: Record = { top: { top: r.top - GAP, left: cx }, bottom: { top: r.bottom + GAP, left: cx }, left: { top: cy, left: r.left - GAP }, right: { top: cy, left: r.right + GAP }, }; setCoords(point[side]); }, [side]); const show = useCallback(() => { if (!canHover) return; if (timer.current) clearTimeout(timer.current); const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS; timer.current = setTimeout( () => { place(); setOpen(true); }, warm ? 0 : delay, ); }, [canHover, delay, place]); const hide = useCallback(() => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } if (open) lastHiddenAt = Date.now(); setOpen(false); }, [open]); // Keep the tooltip pinned to the trigger while it's open and the page scrolls // or resizes (fixed coords are viewport-relative). useEffect(() => { if (!open) return; const onMove = () => place(); window.addEventListener("scroll", onMove, true); window.addEventListener("resize", onMove); return () => { window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); }; }, [open, place]); const variants = useMemo( () => (reduce ? REDUCED_VARIANTS : buildVariants(side)), [reduce, side], ); if (!isValidElement(children)) return children; const trigger = cloneElement( children as ReactElement>, { onMouseEnter: show, onMouseLeave: hide, onFocus: show, onBlur: hide, "aria-describedby": id, }, ); return ( <> {trigger} {typeof document !== "undefined" ? createPortal( {open && coords ? ( {content} ) : null} , document.body, ) : null} ); }