{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ui-global-tooltip","type":"registry:component","title":"Global Tooltip","description":"Viewport-aware tooltip system with shared motion layer.","version":"1.0.0","status":"ga","files":[{"path":"src/components/ui/global-tooltip.tsx","type":"registry:component","content":"'use client'\n\nimport * as React from 'react'\n\nimport { createPortal } from 'react-dom'\nimport { motion, AnimatePresence, LayoutGroup, type Transition } from 'motion/react'\n\nimport { cn } from '@/lib/utils'\n\ntype Side = 'top' | 'bottom' | 'left' | 'right'\n\ntype Align = 'start' | 'center' | 'end'\n\ntype TooltipData = {\n  content: React.ReactNode\n  rect: DOMRect\n  side: Side\n  sideOffset: number\n  align: Align\n  alignOffset: number\n  id: string\n  arrow: boolean\n}\n\ntype GlobalTooltipContextType = {\n  showTooltip: (data: TooltipData) => void\n  hideTooltip: () => void\n  currentTooltip: TooltipData | null\n  transition: Transition\n  globalId: string\n}\n\nconst GlobalTooltipContext = React.createContext<GlobalTooltipContextType | undefined>(undefined)\n\nconst useGlobalTooltip = () => {\n  const context = React.useContext(GlobalTooltipContext)\n\n  if (!context) {\n    throw new Error('useGlobalTooltip must be used within a TooltipProvider')\n  }\n\n  return context\n}\n\ntype TooltipPosition = {\n  x: number\n  y: number\n  transform: string\n  initial: { x?: number; y?: number }\n}\n\nfunction getTooltipPosition({\n  rect,\n  side,\n  sideOffset,\n  align,\n  alignOffset\n}: {\n  rect: DOMRect\n  side: Side\n  sideOffset: number\n  align: Align\n  alignOffset: number\n}): TooltipPosition {\n  switch (side) {\n    case 'top':\n      if (align === 'start') {\n        return {\n          x: rect.left + alignOffset,\n          y: rect.top - sideOffset,\n          transform: 'translate(0, -100%)',\n          initial: { y: 15 }\n        }\n      } else if (align === 'end') {\n        return {\n          x: rect.right + alignOffset,\n          y: rect.top - sideOffset,\n          transform: 'translate(-100%, -100%)',\n          initial: { y: 15 }\n        }\n      } else {\n        // center\n        return {\n          x: rect.left + rect.width / 2,\n          y: rect.top - sideOffset,\n          transform: 'translate(-50%, -100%)',\n          initial: { y: 15 }\n        }\n      }\n\n    case 'bottom':\n      if (align === 'start') {\n        return {\n          x: rect.left + alignOffset,\n          y: rect.bottom + sideOffset,\n          transform: 'translate(0, 0)',\n          initial: { y: -15 }\n        }\n      } else if (align === 'end') {\n        return {\n          x: rect.right + alignOffset,\n          y: rect.bottom + sideOffset,\n          transform: 'translate(-100%, 0)',\n          initial: { y: -15 }\n        }\n      } else {\n        // center\n        return {\n          x: rect.left + rect.width / 2,\n          y: rect.bottom + sideOffset,\n          transform: 'translate(-50%, 0)',\n          initial: { y: -15 }\n        }\n      }\n\n    case 'left':\n      if (align === 'start') {\n        return {\n          x: rect.left - sideOffset,\n          y: rect.top + alignOffset,\n          transform: 'translate(-100%, 0)',\n          initial: { x: 15 }\n        }\n      } else if (align === 'end') {\n        return {\n          x: rect.left - sideOffset,\n          y: rect.bottom + alignOffset,\n          transform: 'translate(-100%, -100%)',\n          initial: { x: 15 }\n        }\n      } else {\n        // center\n        return {\n          x: rect.left - sideOffset,\n          y: rect.top + rect.height / 2,\n          transform: 'translate(-100%, -50%)',\n          initial: { x: 15 }\n        }\n      }\n\n    case 'right':\n      if (align === 'start') {\n        return {\n          x: rect.right + sideOffset,\n          y: rect.top + alignOffset,\n          transform: 'translate(0, 0)',\n          initial: { x: -15 }\n        }\n      } else if (align === 'end') {\n        return {\n          x: rect.right + sideOffset,\n          y: rect.bottom + alignOffset,\n          transform: 'translate(0, -100%)',\n          initial: { x: -15 }\n        }\n      } else {\n        // center\n        return {\n          x: rect.right + sideOffset,\n          y: rect.top + rect.height / 2,\n          transform: 'translate(0, -50%)',\n          initial: { x: -15 }\n        }\n      }\n  }\n}\n\ntype TooltipProviderProps = {\n  children: React.ReactNode\n  openDelay?: number\n  closeDelay?: number\n  transition?: Transition\n}\n\nfunction TooltipProvider({\n  children,\n  openDelay = 150,\n  closeDelay = 100,\n  transition = { type: 'spring', stiffness: 300, damping: 25 }\n}: TooltipProviderProps) {\n  const globalId = React.useId()\n  const [currentTooltip, setCurrentTooltip] = React.useState<TooltipData | null>(null)\n  const timeoutRef = React.useRef<number>(null)\n  const lastCloseTimeRef = React.useRef<number>(0)\n\n  const showTooltip = React.useCallback(\n    (data: TooltipData) => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current)\n\n      if (currentTooltip !== null) {\n        setCurrentTooltip(data)\n\n        return\n      }\n\n      const now = Date.now()\n      const delay = now - lastCloseTimeRef.current < closeDelay ? 0 : openDelay\n\n      timeoutRef.current = window.setTimeout(() => setCurrentTooltip(data), delay)\n    },\n    [openDelay, closeDelay, currentTooltip]\n  )\n\n  const hideTooltip = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current)\n    timeoutRef.current = window.setTimeout(() => {\n      setCurrentTooltip(null)\n      lastCloseTimeRef.current = Date.now()\n    }, closeDelay)\n  }, [closeDelay])\n\n  const hideImmediate = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current)\n    setCurrentTooltip(null)\n    lastCloseTimeRef.current = Date.now()\n  }, [])\n\n  React.useEffect(() => {\n    window.addEventListener('scroll', hideImmediate, true)\n\n    return () => window.removeEventListener('scroll', hideImmediate, true)\n  }, [hideImmediate])\n\n  return (\n    <GlobalTooltipContext.Provider\n      value={{\n        showTooltip,\n        hideTooltip,\n        currentTooltip,\n        transition,\n        globalId\n      }}\n    >\n      <LayoutGroup>{children}</LayoutGroup>\n      <TooltipOverlay />\n    </GlobalTooltipContext.Provider>\n  )\n}\n\ntype TooltipArrowProps = {\n  side: Side\n}\n\nfunction TooltipArrow({ side }: TooltipArrowProps) {\n  return (\n    <div\n      className={cn(\n        'bg-foreground absolute z-50 size-2.5 rotate-45 rounded-[2px]',\n        (side === 'top' || side === 'bottom') && 'left-1/2 -translate-x-1/2',\n        (side === 'left' || side === 'right') && 'top-1/2 -translate-y-1/2',\n        side === 'top' && '-bottom-[3px]',\n        side === 'bottom' && '-top-[3px]',\n        side === 'left' && '-right-[3px]',\n        side === 'right' && '-left-[3px]'\n      )}\n    />\n  )\n}\n\ntype TooltipPortalProps = {\n  children: React.ReactNode\n}\n\nfunction TooltipPortal({ children }: TooltipPortalProps) {\n  const [isMounted, setIsMounted] = React.useState(false)\n\n  React.useEffect(() => setIsMounted(true), [])\n\n  return isMounted ? createPortal(children, document.body) : null\n}\n\nfunction TooltipOverlay() {\n  const { currentTooltip, transition, globalId } = useGlobalTooltip()\n\n  const position = React.useMemo(() => {\n    if (!currentTooltip) return null\n\n    return getTooltipPosition({\n      rect: currentTooltip.rect,\n      side: currentTooltip.side,\n      sideOffset: currentTooltip.sideOffset,\n      align: currentTooltip.align,\n      alignOffset: currentTooltip.alignOffset\n    })\n  }, [currentTooltip])\n\n  return (\n    <AnimatePresence>\n      {currentTooltip && currentTooltip.content && position && (\n        <TooltipPortal>\n          <motion.div\n            data-slot='tooltip-overlay-container'\n            className='fixed z-50'\n            style={{\n              top: position.y,\n              left: position.x,\n              transform: position.transform\n            }}\n          >\n            <motion.div\n              data-slot='tooltip-overlay'\n              layoutId={`tooltip-overlay-${globalId}`}\n              initial={{ opacity: 0, scale: 0, ...position.initial }}\n              animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}\n              exit={{ opacity: 0, scale: 0, ...position.initial }}\n              transition={transition}\n              className='bg-foreground fill-foreground text-background relative w-fit rounded-md px-3 py-1.5 text-xs text-balance'\n            >\n              {currentTooltip.content}\n\n              {currentTooltip.arrow && <TooltipArrow side={currentTooltip.side} />}\n            </motion.div>\n          </motion.div>\n        </TooltipPortal>\n      )}\n    </AnimatePresence>\n  )\n}\n\ntype TooltipContextType = {\n  content: React.ReactNode\n  setContent: React.Dispatch<React.SetStateAction<React.ReactNode>>\n  arrow: boolean\n  setArrow: React.Dispatch<React.SetStateAction<boolean>>\n  side: Side\n  sideOffset: number\n  align: Align\n  alignOffset: number\n  id: string\n}\n\nconst TooltipContext = React.createContext<TooltipContextType | undefined>(undefined)\n\nconst useTooltip = () => {\n  const context = React.useContext(TooltipContext)\n\n  if (!context) {\n    throw new Error('useTooltip must be used within a TooltipProvider')\n  }\n\n  return context\n}\n\ntype TooltipProps = {\n  children: React.ReactNode\n  side?: Side\n  sideOffset?: number\n  align?: Align\n  alignOffset?: number\n}\n\nfunction Tooltip({ children, side = 'top', sideOffset = 10, align = 'center', alignOffset = 0 }: TooltipProps) {\n  const id = React.useId()\n  const [content, setContent] = React.useState<React.ReactNode>(null)\n  const [arrow, setArrow] = React.useState(true)\n\n  return (\n    <TooltipContext.Provider\n      value={{\n        content,\n        setContent,\n        arrow,\n        setArrow,\n        side,\n        sideOffset,\n        align,\n        alignOffset,\n        id\n      }}\n    >\n      {children}\n    </TooltipContext.Provider>\n  )\n}\n\ntype TooltipContentProps = {\n  children: React.ReactNode\n  arrow?: boolean\n}\n\nfunction TooltipContent({ children, arrow = true }: TooltipContentProps) {\n  const { setContent, setArrow } = useTooltip()\n\n  React.useEffect(() => {\n    setContent(children)\n    setArrow(arrow)\n  }, [children, setContent, setArrow, arrow])\n\n  return null\n}\n\ntype TooltipTriggerProps = {\n  children: React.ReactElement\n}\n\nfunction TooltipTrigger({ children }: TooltipTriggerProps) {\n  const { content, side, sideOffset, align, alignOffset, id, arrow } = useTooltip()\n  const { showTooltip, hideTooltip, currentTooltip } = useGlobalTooltip()\n  const triggerRef = React.useRef<HTMLElement>(null)\n\n  const handleOpen = React.useCallback(() => {\n    if (!triggerRef.current) return\n    const rect = triggerRef.current.getBoundingClientRect()\n\n    showTooltip({\n      content,\n      rect,\n      side,\n      sideOffset,\n      align,\n      alignOffset,\n      id,\n      arrow\n    })\n  }, [showTooltip, content, side, sideOffset, align, alignOffset, id, arrow])\n\n  const handleMouseEnter = React.useCallback(\n    (e: React.MouseEvent<HTMLElement>) => {\n      ;(children.props as React.HTMLAttributes<HTMLElement>)?.onMouseEnter?.(e)\n      handleOpen()\n    },\n    [handleOpen, children.props]\n  )\n\n  const handleMouseLeave = React.useCallback(\n    (e: React.MouseEvent<HTMLElement>) => {\n      ;(children.props as React.HTMLAttributes<HTMLElement>)?.onMouseLeave?.(e)\n      hideTooltip()\n    },\n    [hideTooltip, children.props]\n  )\n\n  const handleFocus = React.useCallback(\n    (e: React.FocusEvent<HTMLElement>) => {\n      ;(children.props as React.HTMLAttributes<HTMLElement>)?.onFocus?.(e)\n      handleOpen()\n    },\n    [handleOpen, children.props]\n  )\n\n  const handleBlur = React.useCallback(\n    (e: React.FocusEvent<HTMLElement>) => {\n      ;(children.props as React.HTMLAttributes<HTMLElement>)?.onBlur?.(e)\n      hideTooltip()\n    },\n    [hideTooltip, children.props]\n  )\n\n  React.useEffect(() => {\n    if (currentTooltip?.id !== id) return\n    if (!triggerRef.current) return\n\n    if (currentTooltip.content === content && currentTooltip.arrow === arrow) return\n\n    const rect = triggerRef.current.getBoundingClientRect()\n\n    showTooltip({\n      content,\n      rect,\n      side,\n      sideOffset,\n      align,\n      alignOffset,\n      id,\n      arrow\n    })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [content, arrow, currentTooltip?.id])\n\n  return React.cloneElement(children, {\n    ref: triggerRef,\n    onMouseEnter: handleMouseEnter,\n    onMouseLeave: handleMouseLeave,\n    onFocus: handleFocus,\n    onBlur: handleBlur,\n    'data-state': currentTooltip?.id === id ? 'open' : 'closed',\n    'data-side': side,\n    'data-align': align,\n    'data-slot': 'tooltip-trigger'\n  } as React.HTMLAttributes<HTMLElement>)\n}\n\nexport {\n  TooltipProvider,\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n  useGlobalTooltip,\n  useTooltip,\n  type TooltipProviderProps,\n  type TooltipProps,\n  type TooltipContentProps,\n  type TooltipTriggerProps\n}\n"}]}