{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip-group","type":"registry:ui","title":"Tooltip Group","description":"Delayed once, instant after that.","dependencies":["motion"],"categories":["overlay"],"docs":"https://www.interior.dev/docs/tooltip-group","files":[{"path":"registry/interior/tooltip-group.tsx","type":"registry:ui","target":"components/interior/tooltip-group.tsx","content":"\"use client\";\n\nimport {\n  cloneElement,\n  createContext,\n  useContext,\n  useEffect,\n  useId,\n  useRef,\n  useSyncExternalStore,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst LEAVE = [0.4, 0, 1, 1] as const;\n\nconst RISE = { type: \"spring\", stiffness: 560, damping: 34, mass: 0.6 } as const;\n\nconst WARM = { type: \"spring\", stiffness: 900, damping: 48, mass: 0.5 } as const;\n\nconst GLIDE = { type: \"spring\", stiffness: 520, damping: 40, mass: 0.75 } as const;\n\nconst SWAP = { type: \"spring\", stiffness: 700, damping: 44, mass: 0.5 } as const;\n\nlet groups = 0;\n\nconst stop = (t: Timer): Timer => {\n  if (t !== null) clearTimeout(t);\n  return null;\n};\n\nexport type TooltipTiming = {\n  openDelay: number;\n  closeDelay: number;\n  skipDelay: number;\n};\n\ntype Timer = ReturnType<typeof setTimeout> | null;\n\ntype TooltipStore = {\n  seat: string;\n  subscribe: (fn: () => void) => () => void;\n  getActive: () => string | null;\n  getWarm: () => boolean;\n  getSkipped: () => boolean;\n  getTravel: () => number;\n  open: (id: string, immediate: boolean, x?: number) => void;\n  close: (id: string, immediate: boolean) => void;\n  dismiss: (id: string) => void;\n  unblock: (id: string) => void;\n  reset: () => void;\n  dispose: () => void;\n};\n\nfunction createTooltipStore(getTiming: () => TooltipTiming): TooltipStore {\n  const listeners = new Set<() => void>();\n\n  let active: string | null = null;\n  let pending: string | null = null;\n  let blocked: string | null = null;\n  let warm = false;\n  let skipped = false;\n  let lastX: number | null = null;\n  let travel = 0;\n\n  let openTimer: Timer = null;\n  let closeTimer: Timer = null;\n  let coolTimer: Timer = null;\n\n  const notify = () => {\n    for (const fn of listeners) fn();\n  };\n\n  const setActive = (next: string | null) => {\n    if (active === next) return;\n    if (next !== null) {\n      skipped = warm;\n      warm = true;\n    }\n    active = next;\n    notify();\n  };\n\n  const cool = () => {\n    coolTimer = stop(coolTimer);\n    const { skipDelay } = getTiming();\n    if (skipDelay <= 0) {\n      if (warm) {\n        warm = false;\n        notify();\n      }\n      return;\n    }\n    coolTimer = setTimeout(() => {\n      coolTimer = null;\n      warm = false;\n      notify();\n    }, skipDelay);\n  };\n\n  groups += 1;\n  const seat = `tooltip-seat-${groups}`;\n\n  return {\n    seat,\n    subscribe(fn) {\n      listeners.add(fn);\n      return () => {\n        listeners.delete(fn);\n      };\n    },\n    getActive: () => active,\n    getWarm: () => warm,\n    getSkipped: () => skipped,\n    getTravel: () => travel,\n    open(id, immediate, x) {\n      if (blocked === id) return;\n      closeTimer = stop(closeTimer);\n      coolTimer = stop(coolTimer);\n      if (active === id) {\n        openTimer = stop(openTimer);\n        pending = null;\n        return;\n      }\n      const arrive = () => {\n        travel = lastX !== null && x !== undefined ? Math.sign(x - lastX) : 0;\n        lastX = x ?? null;\n        setActive(id);\n      };\n      if (immediate || warm) {\n        openTimer = stop(openTimer);\n        pending = null;\n        arrive();\n        return;\n      }\n      openTimer = stop(openTimer);\n      pending = id;\n      openTimer = setTimeout(() => {\n        openTimer = null;\n        pending = null;\n        arrive();\n      }, getTiming().openDelay);\n    },\n    close(id, immediate) {\n      if (pending === id) {\n        openTimer = stop(openTimer);\n        pending = null;\n      }\n      if (active !== id) return;\n      closeTimer = stop(closeTimer);\n      const finish = () => {\n        closeTimer = null;\n        setActive(null);\n        cool();\n      };\n      if (immediate || getTiming().closeDelay <= 0) {\n        finish();\n        return;\n      }\n      closeTimer = setTimeout(finish, getTiming().closeDelay);\n    },\n    dismiss(id) {\n      blocked = id;\n      openTimer = stop(openTimer);\n      closeTimer = stop(closeTimer);\n      coolTimer = stop(coolTimer);\n      pending = null;\n      const wasWarm = warm;\n      warm = false;\n      if (active === id) setActive(null);\n      else if (wasWarm) notify();\n    },\n    unblock(id) {\n      if (blocked === id) blocked = null;\n    },\n    reset() {\n      openTimer = stop(openTimer);\n      closeTimer = stop(closeTimer);\n      coolTimer = stop(coolTimer);\n      pending = null;\n      blocked = null;\n      lastX = null;\n      travel = 0;\n      const wasWarm = warm;\n      warm = false;\n      if (active !== null) setActive(null);\n      else if (wasWarm) notify();\n    },\n    dispose() {\n      openTimer = stop(openTimer);\n      closeTimer = stop(closeTimer);\n      coolTimer = stop(coolTimer);\n      listeners.clear();\n    },\n  };\n}\n\nconst TooltipGroupContext = createContext<TooltipStore | null>(null);\n\nfunction useDismissOnBlur(store: TooltipStore, enabled: boolean) {\n  useEffect(() => {\n    if (!enabled) return;\n    const bail = () => store.reset();\n    const onVisibility = () => {\n      if (document.hidden) store.reset();\n    };\n    window.addEventListener(\"blur\", bail);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      window.removeEventListener(\"blur\", bail);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [store, enabled]);\n}\n\nexport type TooltipGroupProps = {\n  children: React.ReactNode;\n  openDelay?: number;\n  closeDelay?: number;\n  skipDelay?: number;\n  onWarmChange?: (warm: boolean) => void;\n  className?: string;\n};\n\nexport function TooltipGroup({\n  children,\n  openDelay = 200,\n  closeDelay = 120,\n  skipDelay = 400,\n  onWarmChange,\n  className = \"\",\n}: TooltipGroupProps) {\n  const timing = useRef<TooltipTiming>({ openDelay, closeDelay, skipDelay });\n  timing.current = { openDelay, closeDelay, skipDelay };\n\n  const held = useRef<TooltipStore | null>(null);\n  if (held.current === null) {\n    held.current = createTooltipStore(() => timing.current);\n  }\n  const store = held.current;\n\n  const warm = useSyncExternalStore(\n    store.subscribe,\n    store.getWarm,\n    () => false,\n  );\n\n  const report = useRef(onWarmChange);\n  report.current = onWarmChange;\n\n  useEffect(() => {\n    report.current?.(warm);\n  }, [warm]);\n\n  useEffect(() => () => store.dispose(), [store]);\n  useDismissOnBlur(store, true);\n\n  return (\n    <TooltipGroupContext.Provider value={store}>\n      {className ? <div className={className}>{children}</div> : children}\n    </TooltipGroupContext.Provider>\n  );\n}\n\nexport type UseTooltipOptions = {\n  disabled?: boolean;\n  openDelay?: number;\n  closeDelay?: number;\n  skipDelay?: number;\n};\n\nexport type TooltipTriggerProps = {\n  onPointerEnter: (event: React.PointerEvent<HTMLElement>) => void;\n  onPointerLeave: (event: React.PointerEvent<HTMLElement>) => void;\n  onPointerDown: (event: React.PointerEvent<HTMLElement>) => void;\n  onPointerCancel: (event: React.PointerEvent<HTMLElement>) => void;\n  onFocus: (event: React.FocusEvent<HTMLElement>) => void;\n  onBlur: (event: React.FocusEvent<HTMLElement>) => void;\n  onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void;\n};\n\nexport type UseTooltipReturn = {\n  open: boolean;\n  warm: boolean;\n  skipped: boolean;\n  travel: number;\n  tooltipId: string;\n\n  seat: string;\n  triggerProps: TooltipTriggerProps;\n};\n\nfunction isKeyboardFocus(el: HTMLElement) {\n  try {\n    return el.matches(\":focus-visible\");\n  } catch {\n    return true;\n  }\n}\n\nexport function useTooltip({\n  disabled = false,\n  openDelay = 200,\n  closeDelay = 120,\n  skipDelay = 400,\n}: UseTooltipOptions = {}): UseTooltipReturn {\n  const tooltipId = `tt-${useId()}`;\n  const group = useContext(TooltipGroupContext);\n\n  const timing = useRef<TooltipTiming>({ openDelay, closeDelay, skipDelay });\n  timing.current = { openDelay, closeDelay, skipDelay };\n\n  const solo = useRef<TooltipStore | null>(null);\n  if (group === null && solo.current === null) {\n    solo.current = createTooltipStore(() => timing.current);\n  }\n  const store = group ?? (solo.current as TooltipStore);\n\n  useEffect(() => {\n    const own = solo.current;\n    return () => {\n      store.close(tooltipId, true);\n      own?.dispose();\n    };\n  }, [store, tooltipId]);\n  useDismissOnBlur(store, group === null);\n\n  const open = useSyncExternalStore(\n    store.subscribe,\n    () => store.getActive() === tooltipId,\n    () => false,\n  );\n  const warm = useSyncExternalStore(\n    store.subscribe,\n    store.getWarm,\n    () => false,\n  );\n  const skipped = useSyncExternalStore(\n    store.subscribe,\n    store.getSkipped,\n    () => false,\n  );\n  const travel = useSyncExternalStore(\n    store.subscribe,\n    store.getTravel,\n    () => 0,\n  );\n\n  useEffect(() => {\n    if (!disabled) return;\n    store.close(tooltipId, true);\n  }, [disabled, store, tooltipId]);\n\n  const triggerProps: TooltipTriggerProps = {\n    onPointerEnter: (event) => {\n      if (!disabled) store.open(tooltipId, false, event.clientX);\n    },\n    onPointerLeave: () => {\n      store.unblock(tooltipId);\n      store.close(tooltipId, false);\n    },\n    onPointerDown: () => store.dismiss(tooltipId),\n    onPointerCancel: () => {\n      store.unblock(tooltipId);\n      store.close(tooltipId, true);\n    },\n    onFocus: (event) => {\n      if (disabled) return;\n      if (!isKeyboardFocus(event.currentTarget)) return;\n      store.open(tooltipId, true);\n    },\n    onBlur: () => {\n      store.unblock(tooltipId);\n      store.close(tooltipId, true);\n    },\n    onKeyDown: (event) => {\n      if (event.key === \"Escape\") store.dismiss(tooltipId);\n    },\n  };\n\n  return { open, warm, skipped, travel, tooltipId, seat: store.seat, triggerProps };\n}\n\ntype TriggerChild = React.ReactElement<\n  React.HTMLAttributes<HTMLElement> & { \"aria-describedby\"?: string }\n>;\n\nexport type TooltipProps = UseTooltipOptions & {\n  label: React.ReactNode;\n  children: TriggerChild;\n  side?: \"top\" | \"bottom\";\n  className?: string;\n  contentClassName?: string;\n};\n\nfunction chain<E>(\n  theirs: ((event: E) => void) | undefined,\n  ours: (event: E) => void,\n) {\n  return (event: E) => {\n    theirs?.(event);\n    ours(event);\n  };\n}\n\nexport function Tooltip({\n  label,\n  children,\n  side = \"top\",\n  disabled = false,\n  openDelay,\n  closeDelay,\n  skipDelay,\n  className = \"\",\n  contentClassName = \"\",\n}: TooltipProps) {\n  const { open, skipped, travel, tooltipId, seat, triggerProps } = useTooltip({\n    disabled,\n    openDelay,\n    closeDelay,\n    skipDelay,\n  });\n  const reduced = useReducedMotion();\n\n  const described = [children.props[\"aria-describedby\"], open ? tooltipId : null]\n    .filter(Boolean)\n    .join(\" \");\n\n  const trigger = cloneElement(children, {\n    \"aria-describedby\": described.length > 0 ? described : undefined,\n    onPointerEnter: chain(\n      children.props.onPointerEnter,\n      triggerProps.onPointerEnter,\n    ),\n    onPointerLeave: chain(\n      children.props.onPointerLeave,\n      triggerProps.onPointerLeave,\n    ),\n    onPointerDown: chain(\n      children.props.onPointerDown,\n      triggerProps.onPointerDown,\n    ),\n    onPointerCancel: chain(\n      children.props.onPointerCancel,\n      triggerProps.onPointerCancel,\n    ),\n    onFocus: chain(children.props.onFocus, triggerProps.onFocus),\n    onBlur: chain(children.props.onBlur, triggerProps.onBlur),\n    onKeyDown: chain(children.props.onKeyDown, triggerProps.onKeyDown),\n  });\n\n  const lift = side === \"top\" ? 7 : -7;\n\n  return (\n    <span className={`relative inline-flex ${className}`}>\n      {trigger}\n\n      <span\n        aria-hidden={!open}\n        className=\"pointer-events-none absolute left-1/2 z-50 flex w-0 justify-center\"\n        style={\n          side === \"top\"\n            ? { bottom: \"calc(100% + 7px)\" }\n            : { top: \"calc(100% + 7px)\" }\n        }\n      >\n        <AnimatePresence>\n          {open && (\n            <motion.span\n              role=\"tooltip\"\n              id={tooltipId}\n              layoutId={reduced ? undefined : seat}\n              initial={\n                reduced\n                  ? false\n                  : skipped\n                    ? { opacity: 0, scale: 1, y: 0, filter: \"blur(0px)\" }\n                    : { opacity: 0, scale: 0.9, y: lift, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      scale: 0.96,\n                      y: lift * 0.35,\n                      filter: \"blur(2px)\",\n                      transition: { duration: 0.12, ease: LEAVE },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : { ...(skipped ? WARM : RISE), layout: GLIDE }\n              }\n              style={{ transformOrigin: side === \"top\" ? \"50% 100%\" : \"50% 0%\" }}\n              className={`relative w-max max-w-[220px] shrink-0 overflow-hidden rounded-[8px] px-2 py-1 text-[11.5px] font-medium leading-snug text-stone-700 dark:text-stone-100 ${contentClassName}`}\n            >\n              <motion.span\n                aria-hidden\n                layout={!reduced}\n                transition={reduced ? { duration: 0 } : GLIDE}\n                className=\"absolute inset-0 rounded-[8px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_6px_16px_-12px_rgba(28,25,23,0.35)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_2px_8px_rgba(0,0,0,0.45)]\"\n              />\n              <motion.span\n                layout={reduced ? false : \"position\"}\n                initial={\n                  reduced\n                    ? false\n                    : skipped\n                      ? { opacity: 0, x: travel * 14, y: 0 }\n                      : { opacity: 0, x: 0, y: 9 }\n                }\n                animate={{ opacity: 1, x: 0, y: 0 }}\n                transition={reduced ? { duration: 0 } : SWAP}\n                className=\"relative block whitespace-nowrap\"\n              >\n                {label}\n              </motion.span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n"}]}