{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip","type":"registry:ui","title":"Tooltip","description":"Hover and focus tooltip in the native top layer: open delay with instant hand-off between neighbors, auto-flip placement, a tracking arrow and a springy fade-scale.","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"registry/ui/tooltip.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  cloneElement,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The bubble is a native `popover=\"manual\"` element, so it renders in the top layer and\n// is never clipped by overflow:hidden parents. Enter/exit mirrors the dialog: :popover-open\n// + @starting-style, with allow-discrete keeping it painted while it fades out.\nconst css = `\n.ui-tip{opacity:0;scale:.9;translate:var(--tip-dx) var(--tip-dy);transition:opacity .12s ease-in,scale .12s ease-in,translate .12s ease-in,display .12s allow-discrete,overlay .12s allow-discrete}\n.ui-tip:popover-open{opacity:1;scale:1;translate:0 0;transition-duration:.2s;transition-timing-function:cubic-bezier(.2,.9,.3,1.2)}\n@starting-style{.ui-tip:popover-open{opacity:0;scale:.9;translate:var(--tip-dx) var(--tip-dy)}}\n@media (prefers-reduced-motion:reduce){.ui-tip{transition-property:opacity,display,overlay;scale:1;translate:0 0}}\n`;\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nconst nudge: Record<TooltipSide, [string, string]> = {\n  top: [\"0px\", \"4px\"],\n  bottom: [\"0px\", \"-4px\"],\n  left: [\"4px\", \"0px\"],\n  right: [\"-4px\", \"0px\"],\n};\nconst opposite: Record<TooltipSide, TooltipSide> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n\n// Tooltips opened shortly after another one closed skip the delay (moving along a toolbar),\n// and only one tooltip is open at a time.\nlet lastClosed = 0;\nlet closeCurrent: (() => void) | null = null;\n\nexport interface TooltipProps {\n  /** Tooltip text or node. Keep it short; it is exposed as the trigger's description. */\n  content: ReactNode;\n  /** The trigger. Must be a single focusable element (button, link...). */\n  children: ReactElement<{ \"aria-describedby\"?: string }>;\n  /** Preferred side; flips to the opposite side when there is no room. */\n  side?: TooltipSide;\n  /** Gap between trigger and bubble, px. */\n  sideOffset?: number;\n  /** Hover delay before opening, ms. Keyboard focus opens immediately. */\n  delay?: number;\n  /** Render the little arrow. */\n  arrow?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nexport function Tooltip({\n  content,\n  children,\n  side = \"top\",\n  sideOffset = 8,\n  delay = 500,\n  arrow = true,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: TooltipProps) {\n  const [inner, setInner] = useState(defaultOpen);\n  const open = openProp ?? inner;\n  const [placed, setPlaced] = useState<TooltipSide>(side);\n  const id = useId();\n  const anchor = useRef<HTMLSpanElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      clearTimeout(timer.current);\n      if (!next && open) lastClosed = Date.now();\n      if (openProp === undefined) setInner(next);\n      onOpenChange?.(next);\n    },\n    [open, openProp, onOpenChange],\n  );\n\n  const show = (wait: number) => {\n    clearTimeout(timer.current);\n    if (wait <= 0 || closeCurrent || Date.now() - lastClosed < 300) setOpen(true);\n    else timer.current = setTimeout(() => setOpen(true), wait);\n  };\n  const hide = () => {\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setOpen(false), 100); // grace period to move onto the bubble\n  };\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  // Position against the trigger (fixed coords, top layer), flip if it would overflow, clamp to viewport.\n  const place = useCallback(() => {\n    const a = anchor.current?.getBoundingClientRect();\n    const t = tip.current;\n    if (!a || !t) return;\n    const w = t.offsetWidth;\n    const h = t.offsetHeight;\n    const vw = window.innerWidth;\n    const vh = window.innerHeight;\n    const fits = (s: TooltipSide) =>\n      s === \"top\" ? a.top - sideOffset - h >= 0 : s === \"bottom\" ? a.bottom + sideOffset + h <= vh : s === \"left\" ? a.left - sideOffset - w >= 0 : a.right + sideOffset + w <= vw;\n    const s = fits(side) || !fits(opposite[side]) ? side : opposite[side];\n    let x = s === \"left\" ? a.left - sideOffset - w : s === \"right\" ? a.right + sideOffset : a.left + a.width / 2 - w / 2;\n    let y = s === \"top\" ? a.top - sideOffset - h : s === \"bottom\" ? a.bottom + sideOffset : a.top + a.height / 2 - h / 2;\n    x = Math.min(Math.max(x, 4), vw - w - 4);\n    y = Math.min(Math.max(y, 4), vh - h - 4);\n    t.style.left = `${x}px`;\n    t.style.top = `${y}px`;\n    // Arrow keeps pointing at the trigger's center even after clamping.\n    t.style.setProperty(\"--tip-ax\", `${a.left + a.width / 2 - x}px`);\n    t.style.setProperty(\"--tip-ay\", `${a.top + a.height / 2 - y}px`);\n    setPlaced(s);\n  }, [side, sideOffset]);\n\n  useLayoutEffect(() => {\n    const t = tip.current;\n    if (!t?.showPopover) return;\n    if (open) {\n      if (!t.matches(\":popover-open\")) t.showPopover();\n      const close = () => setOpen(false);\n      if (closeCurrent !== close) closeCurrent?.();\n      closeCurrent = close;\n      place();\n      window.addEventListener(\"scroll\", place, true);\n      window.addEventListener(\"resize\", place);\n      const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n      document.addEventListener(\"keydown\", onKey);\n      return () => {\n        window.removeEventListener(\"scroll\", place, true);\n        window.removeEventListener(\"resize\", place);\n        document.removeEventListener(\"keydown\", onKey);\n        if (closeCurrent === close) closeCurrent = null;\n      };\n    }\n    if (t.matches(\":popover-open\")) t.hidePopover();\n  }, [open, place, setOpen]);\n\n  const [dx, dy] = nudge[placed];\n  const vertical = placed === \"top\" || placed === \"bottom\";\n\n  return (\n    <span\n      ref={anchor}\n      className=\"inline-flex\"\n      onPointerEnter={(e) => e.pointerType === \"mouse\" && show(delay)}\n      onPointerLeave={(e) => e.pointerType === \"mouse\" && hide()}\n      onFocus={(e) => e.target.matches(\":focus-visible\") && show(0)}\n      onBlur={() => setOpen(false)}\n      onPointerDown={() => setOpen(false)}\n    >\n      <style href=\"ui-tooltip\" precedence=\"default\">\n        {css}\n      </style>\n      {cloneElement(children, {\n        \"aria-describedby\": cn(children.props[\"aria-describedby\"], id) || undefined,\n      })}\n      <div\n        ref={tip}\n        id={id}\n        role=\"tooltip\"\n        popover=\"manual\"\n        data-side={placed}\n        style={{ \"--tip-dx\": dx, \"--tip-dy\": dy, transformOrigin: `var(--tip-ax) var(--tip-ay)` } as CSSProperties}\n        className={cn(\n          \"ui-tip fixed inset-auto m-0 w-max max-w-64 overflow-visible rounded-md border-0 bg-foreground px-2.5 py-1.5 text-xs leading-snug font-medium text-background shadow-lg\",\n          className,\n        )}\n      >\n        {content}\n        {arrow && (\n          <span\n            aria-hidden\n            className={cn(\n              \"absolute size-2 rotate-45 rounded-[1px] bg-inherit\",\n              placed === \"top\" && \"-bottom-1\",\n              placed === \"bottom\" && \"-top-1\",\n              placed === \"left\" && \"-right-1\",\n              placed === \"right\" && \"-left-1\",\n            )}\n            style={vertical ? { left: \"calc(var(--tip-ax) - 4px)\" } : { top: \"calc(var(--tip-ay) - 4px)\" }}\n          />\n        )}\n      </div>\n    </span>\n  );\n}\n"}]}