"use client" import * as React from "react" import { createPortal } from "react-dom" import { cn } from "@/lib/utils" interface TooltipContextValue { open: boolean setOpen: (open: boolean) => void triggerRef: React.RefObject delayDuration: number } const TooltipContext = React.createContext(null) function useTooltip() { const context = React.useContext(TooltipContext) if (!context) { throw new Error("useTooltip must be used within a Tooltip") } return context } interface TooltipProviderProps { children: React.ReactNode delayDuration?: number } const TooltipProviderContext = React.createContext<{ delayDuration: number }>({ delayDuration: 0, }) function TooltipProvider({ children, delayDuration = 0, }: TooltipProviderProps) { return ( {children} ) } interface TooltipProps { children: React.ReactNode open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void delayDuration?: number } function Tooltip({ children, open: controlledOpen, defaultOpen = false, onOpenChange, delayDuration: propDelayDuration, }: TooltipProps) { const providerContext = React.useContext(TooltipProviderContext) const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const triggerRef = React.useRef(null) const isControlled = controlledOpen !== undefined const open = isControlled ? controlledOpen : uncontrolledOpen const delayDuration = propDelayDuration ?? providerContext.delayDuration const setOpen = React.useCallback( (value: boolean) => { if (!isControlled) { setUncontrolledOpen(value) } onOpenChange?.(value) }, [isControlled, onOpenChange] ) return ( {children} ) } interface TooltipTriggerProps extends React.HTMLAttributes { asChild?: boolean } function TooltipTrigger({ children, asChild, ...props }: TooltipTriggerProps) { const { setOpen, triggerRef, delayDuration } = useTooltip() const timeoutRef = React.useRef(null) const handleMouseEnter = () => { if (delayDuration > 0) { timeoutRef.current = setTimeout(() => setOpen(true), delayDuration) } else { setOpen(true) } } const handleMouseLeave = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } setOpen(false) } const handleFocus = () => { setOpen(true) } const handleBlur = () => { setOpen(false) } React.useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, []) if (asChild && React.isValidElement(children)) { return React.cloneElement(children as React.ReactElement, { ref: triggerRef, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onFocus: handleFocus, onBlur: handleBlur, "data-slot": "tooltip-trigger", }) } return ( } data-slot="tooltip-trigger" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onFocus={handleFocus} onBlur={handleBlur} {...props} > {children} ) } interface TooltipContentProps extends React.HTMLAttributes { side?: "top" | "right" | "bottom" | "left" sideOffset?: number align?: "start" | "center" | "end" } function TooltipContent({ className, side = "top", sideOffset = 4, align = "center", children, ...props }: TooltipContentProps) { const { open, triggerRef } = useTooltip() const [position, setPosition] = React.useState({ top: 0, left: 0 }) const [mounted, setMounted] = React.useState(false) const contentRef = React.useRef(null) React.useEffect(() => { setMounted(true) }, []) React.useLayoutEffect(() => { if (!open || !triggerRef.current || !contentRef.current) return const trigger = triggerRef.current.getBoundingClientRect() const content = contentRef.current.getBoundingClientRect() let top = 0 let left = 0 // Calculate position based on side switch (side) { case "top": top = trigger.top - content.height - sideOffset break case "bottom": top = trigger.bottom + sideOffset break case "left": left = trigger.left - content.width - sideOffset top = trigger.top + (trigger.height - content.height) / 2 break case "right": left = trigger.right + sideOffset top = trigger.top + (trigger.height - content.height) / 2 break } // Calculate alignment for top/bottom if (side === "top" || side === "bottom") { switch (align) { case "start": left = trigger.left break case "center": left = trigger.left + (trigger.width - content.width) / 2 break case "end": left = trigger.right - content.width break } } // Calculate alignment for left/right if (side === "left" || side === "right") { switch (align) { case "start": top = trigger.top break case "center": top = trigger.top + (trigger.height - content.height) / 2 break case "end": top = trigger.bottom - content.height break } } setPosition({ top, left }) }, [open, side, align, sideOffset, triggerRef]) if (!open || !mounted) return null const slideClasses = { top: "animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2", bottom: "animate-in fade-in-0 zoom-in-95 slide-in-from-top-2", left: "animate-in fade-in-0 zoom-in-95 slide-in-from-right-2", right: "animate-in fade-in-0 zoom-in-95 slide-in-from-left-2", } return createPortal(
{children}
, document.body ) } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }