"use client"; import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; import { cloneElement, isValidElement, useId, useRef, useState, type ReactElement, type ReactNode, } from "react"; 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; } const wrapperClasses: Record = { top: "bottom-full left-1/2 mb-2 -translate-x-1/2", bottom: "top-full left-1/2 mt-2 -translate-x-1/2", left: "right-full top-1/2 mr-2 -translate-y-1/2", right: "left-full top-1/2 ml-2 -translate-y-1/2", }; 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: 10 }, bottom: { y: -10 }, left: { x: 10 }, right: { x: -10 }, }; function buildVariants(side: Side): Variants { const o = offsetFrom[side]; return { initial: { opacity: 0, scale: 0.85, filter: "blur(10px)", 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.22, ease: [0.16, 1, 0.3, 1] }, filter: { duration: 0.3, ease: [0.16, 1, 0.3, 1] }, }, }, exit: { opacity: 0, scale: 0.92, filter: "blur(6px)", x: (o.x ?? 0) * 0.6, y: (o.y ?? 0) * 0.6, transition: { duration: 0.14, ease: [0.16, 1, 0.3, 1] }, }, }; } const REDUCED_VARIANTS: Variants = { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.14, ease: [0.16, 1, 0.3, 1] } }, exit: { opacity: 0, transition: { duration: 0.1, ease: [0.16, 1, 0.3, 1] } }, }; export function Tooltip({ content, children, side = "top", delay = 120, className, wrapperClassName }: TooltipProps) { const [open, setOpen] = useState(false); const id = useId(); const timer = useRef | null>(null); const reduce = useReducedMotion(); const show = () => { if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setOpen(true), delay); }; const hide = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } setOpen(false); }; if (!isValidElement(children)) return children; const trigger = cloneElement(children as ReactElement>, { onMouseEnter: show, onMouseLeave: hide, onFocus: show, onBlur: hide, "aria-describedby": id, }); const variants = reduce ? REDUCED_VARIANTS : buildVariants(side); return ( {trigger} {open ? ( {content} ) : null} ); }