'use client'; import * as React from 'react'; import { createPortal } from 'react-dom'; import { motion, AnimatePresence, LayoutGroup, type Transition, } from 'motion/react'; import { cn } from '@workspace/ui/lib/utils'; type Side = 'top' | 'bottom' | 'left' | 'right'; type Align = 'start' | 'center' | 'end'; type TooltipData = { content: React.ReactNode; rect: DOMRect; side: Side; sideOffset: number; align: Align; alignOffset: number; id: string; arrow: boolean; }; type GlobalTooltipContextType = { showTooltip: (data: TooltipData) => void; hideTooltip: () => void; currentTooltip: TooltipData | null; transition: Transition; globalId: string; }; const GlobalTooltipContext = React.createContext< GlobalTooltipContextType | undefined >(undefined); const useGlobalTooltip = () => { const context = React.useContext(GlobalTooltipContext); if (!context) { throw new Error('useGlobalTooltip must be used within a TooltipProvider'); } return context; }; type TooltipPosition = { x: number; y: number; transform: string; initial: { x?: number; y?: number }; }; function getTooltipPosition({ rect, side, sideOffset, align, alignOffset, }: { rect: DOMRect; side: Side; sideOffset: number; align: Align; alignOffset: number; }): TooltipPosition { switch (side) { case 'top': if (align === 'start') { return { x: rect.left + alignOffset, y: rect.top - sideOffset, transform: 'translate(0, -100%)', initial: { y: 15 }, }; } else if (align === 'end') { return { x: rect.right + alignOffset, y: rect.top - sideOffset, transform: 'translate(-100%, -100%)', initial: { y: 15 }, }; } else { // center return { x: rect.left + rect.width / 2, y: rect.top - sideOffset, transform: 'translate(-50%, -100%)', initial: { y: 15 }, }; } case 'bottom': if (align === 'start') { return { x: rect.left + alignOffset, y: rect.bottom + sideOffset, transform: 'translate(0, 0)', initial: { y: -15 }, }; } else if (align === 'end') { return { x: rect.right + alignOffset, y: rect.bottom + sideOffset, transform: 'translate(-100%, 0)', initial: { y: -15 }, }; } else { // center return { x: rect.left + rect.width / 2, y: rect.bottom + sideOffset, transform: 'translate(-50%, 0)', initial: { y: -15 }, }; } case 'left': if (align === 'start') { return { x: rect.left - sideOffset, y: rect.top + alignOffset, transform: 'translate(-100%, 0)', initial: { x: 15 }, }; } else if (align === 'end') { return { x: rect.left - sideOffset, y: rect.bottom + alignOffset, transform: 'translate(-100%, -100%)', initial: { x: 15 }, }; } else { // center return { x: rect.left - sideOffset, y: rect.top + rect.height / 2, transform: 'translate(-100%, -50%)', initial: { x: 15 }, }; } case 'right': if (align === 'start') { return { x: rect.right + sideOffset, y: rect.top + alignOffset, transform: 'translate(0, 0)', initial: { x: -15 }, }; } else if (align === 'end') { return { x: rect.right + sideOffset, y: rect.bottom + alignOffset, transform: 'translate(0, -100%)', initial: { x: -15 }, }; } else { // center return { x: rect.right + sideOffset, y: rect.top + rect.height / 2, transform: 'translate(0, -50%)', initial: { x: -15 }, }; } } } type TooltipProviderProps = { children: React.ReactNode; openDelay?: number; closeDelay?: number; transition?: Transition; }; function TooltipProvider({ children, openDelay = 700, closeDelay = 300, transition = { type: 'spring', stiffness: 300, damping: 25 }, }: TooltipProviderProps) { const globalId = React.useId(); const [currentTooltip, setCurrentTooltip] = React.useState(null); const timeoutRef = React.useRef(null); const lastCloseTimeRef = React.useRef(0); const showTooltip = React.useCallback( (data: TooltipData) => { if (timeoutRef.current) clearTimeout(timeoutRef.current); if (currentTooltip !== null) { setCurrentTooltip(data); return; } const now = Date.now(); const delay = now - lastCloseTimeRef.current < closeDelay ? 0 : openDelay; timeoutRef.current = window.setTimeout( () => setCurrentTooltip(data), delay, ); }, [openDelay, closeDelay, currentTooltip], ); const hideTooltip = React.useCallback(() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { setCurrentTooltip(null); lastCloseTimeRef.current = Date.now(); }, closeDelay); }, [closeDelay]); const hideImmediate = React.useCallback(() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setCurrentTooltip(null); lastCloseTimeRef.current = Date.now(); }, []); React.useEffect(() => { window.addEventListener('scroll', hideImmediate, true); return () => window.removeEventListener('scroll', hideImmediate, true); }, [hideImmediate]); return ( {children} ); } type TooltipArrowProps = { side: Side; }; function TooltipArrow({ side }: TooltipArrowProps) { return (
); } type TooltipPortalProps = { children: React.ReactNode; }; function TooltipPortal({ children }: TooltipPortalProps) { const [isMounted, setIsMounted] = React.useState(false); React.useEffect(() => setIsMounted(true), []); return isMounted ? createPortal(children, document.body) : null; } function TooltipOverlay() { const { currentTooltip, transition, globalId } = useGlobalTooltip(); const position = React.useMemo(() => { if (!currentTooltip) return null; return getTooltipPosition({ rect: currentTooltip.rect, side: currentTooltip.side, sideOffset: currentTooltip.sideOffset, align: currentTooltip.align, alignOffset: currentTooltip.alignOffset, }); }, [currentTooltip]); return ( {currentTooltip && currentTooltip.content && position && ( {currentTooltip.content} {currentTooltip.arrow && ( )} )} ); } type TooltipContextType = { content: React.ReactNode; setContent: React.Dispatch>; arrow: boolean; setArrow: React.Dispatch>; side: Side; sideOffset: number; align: Align; alignOffset: number; id: string; }; const TooltipContext = React.createContext( undefined, ); const useTooltip = () => { const context = React.useContext(TooltipContext); if (!context) { throw new Error('useTooltip must be used within a TooltipProvider'); } return context; }; type TooltipProps = { children: React.ReactNode; side?: Side; sideOffset?: number; align?: Align; alignOffset?: number; }; function Tooltip({ children, side = 'top', sideOffset = 14, align = 'center', alignOffset = 0, }: TooltipProps) { const id = React.useId(); const [content, setContent] = React.useState(null); const [arrow, setArrow] = React.useState(true); return ( {children} ); } type TooltipContentProps = { children: React.ReactNode; arrow?: boolean; }; function TooltipContent({ children, arrow = true }: TooltipContentProps) { const { setContent, setArrow } = useTooltip(); React.useEffect(() => { setContent(children); setArrow(arrow); }, [children, setContent, setArrow, arrow]); return null; } type TooltipTriggerProps = { children: React.ReactElement; }; function TooltipTrigger({ children }: TooltipTriggerProps) { const { content, side, sideOffset, align, alignOffset, id, arrow } = useTooltip(); const { showTooltip, hideTooltip, currentTooltip } = useGlobalTooltip(); const triggerRef = React.useRef(null); const handleOpen = React.useCallback(() => { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); showTooltip({ content, rect, side, sideOffset, align, alignOffset, id, arrow, }); }, [showTooltip, content, side, sideOffset, align, alignOffset, id, arrow]); const handleMouseEnter = React.useCallback( (e: React.MouseEvent) => { (children.props as React.HTMLAttributes)?.onMouseEnter?.(e); handleOpen(); }, [handleOpen, children.props], ); const handleMouseLeave = React.useCallback( (e: React.MouseEvent) => { (children.props as React.HTMLAttributes)?.onMouseLeave?.(e); hideTooltip(); }, [hideTooltip, children.props], ); const handleFocus = React.useCallback( (e: React.FocusEvent) => { (children.props as React.HTMLAttributes)?.onFocus?.(e); handleOpen(); }, [handleOpen, children.props], ); const handleBlur = React.useCallback( (e: React.FocusEvent) => { (children.props as React.HTMLAttributes)?.onBlur?.(e); hideTooltip(); }, [hideTooltip, children.props], ); React.useEffect(() => { if (currentTooltip?.id !== id) return; if (!triggerRef.current) return; if (currentTooltip.content === content && currentTooltip.arrow === arrow) return; const rect = triggerRef.current.getBoundingClientRect(); showTooltip({ content, rect, side, sideOffset, align, alignOffset, id, arrow, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [content, arrow, currentTooltip?.id]); return React.cloneElement(children, { ref: triggerRef, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onFocus: handleFocus, onBlur: handleBlur, 'data-state': currentTooltip?.id === id ? 'open' : 'closed', 'data-side': side, 'data-align': align, 'data-slot': 'tooltip-trigger', } as React.HTMLAttributes); } export { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger, useGlobalTooltip, useTooltip, type TooltipProviderProps, type TooltipProps, type TooltipContentProps, type TooltipTriggerProps, };