{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip-clean","type":"registry:ui","title":"Tooltip (Clean)","description":"A small dark label for one control – shows on hover after a short wait and at once under keyboard focus, neighbours follow instantly, an optional shortcut inside, flips to stay on screen","dependencies":[],"files":[{"path":"registry/tooltip-clean/tooltip-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  cloneElement,\n  isValidElement,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type SyntheticEvent,\n  type PointerEvent as ReactPointerEvent,\n  type FocusEvent as ReactFocusEvent,\n} from \"react\";\n\n/*\n * Tooltip – a single self-contained file, clean style.\n * A small dark label for one control: it shows on hover after a short wait and at once under keyboard focus, and goes\n * on Escape, when the pointer leaves, on blur and on a click. Once one is open, its neighbours show at once (the\n * skip-delay window), so you can run along a toolbar and read every name. It lives in the top layer, so no card's\n * overflow cuts it, and flips to stay on screen. An optional shortcut sits inside it (⌘K). Never interactive content.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type TooltipProps = {\n  /** The label – a few words; it wraps at about 240px. Empty or null: only the child is rendered */\n  content: ReactNode;\n  /** A keyboard shortcut shown after the label, e.g. \"⌘K\" */\n  shortcut?: string;\n  /** Exactly one element that takes a ref and focus – a button, a link, an Icon Button */\n  children: ReactElement;\n  /** Where it shows; it flips to the other side when there is no room */\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  /** Which edges line up: start · center · end (start is the left, or the right in RTL) */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap to the trigger in px */\n  offset?: number;\n  /** How long the pointer rests on the trigger before it shows, in ms – keyboard focus shows it at once */\n  delay?: number;\n  /** Never shows it, e.g. when a label is not cut off */\n  disabled?: boolean;\n  /** Controls it from outside (with onOpenChange); leave it out and it opens on its own */\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** A class for the label */\n  className?: string;\n};\n\n/** A neighbour that opens within this many ms of the last one closing shows at once */\nconst SKIP = 300;\n/** The pointer may cross the gap onto the label without it closing (so it can be read under a magnifier) */\nconst GRACE = 120;\n\n/** Only one label at a time on the page – and the skip-delay window that makes neighbours show at once */\nconst group: { current: { close: () => void } | null; warmUntil: number } = { current: null, warmUntil: 0 };\n\nexport function Tooltip({\n  content,\n  shortcut,\n  children,\n  side = \"top\",\n  align = \"center\",\n  offset = 6,\n  delay = 500,\n  disabled,\n  open: openProp,\n  onOpenChange,\n  className,\n}: TooltipProps) {\n  const uid = useId();\n  const tipId = `${uid}-tip`;\n  const keysId = `${uid}-keys`;\n  const trigger = useRef<HTMLElement | null>(null);\n  const tip = useRef<HTMLSpanElement | null>(null);\n  const timer = useRef(0);\n  // After a click or Escape it stays closed until the pointer leaves or the focus goes\n  const blocked = useRef(false);\n  const [shown, setShown] = useState(false);\n  const [instant, setInstant] = useState(false);\n  const [quiet, setQuiet] = useState(false);\n  const empty = content == null || typeof content === \"boolean\" || content === \"\";\n  const open = !disabled && !empty && (openProp ?? shown);\n\n  const change = useRef<(v: boolean) => void>(() => {});\n  change.current = (v: boolean) => {\n    window.clearTimeout(timer.current);\n    if (openProp === undefined) setShown(v);\n    if (v !== open) onOpenChange?.(v);\n  };\n  const me = useRef({ close: () => change.current(false) });\n\n  const show = (now: boolean) => {\n    window.clearTimeout(timer.current);\n    const el = trigger.current;\n    if (disabled || empty || blocked.current || !el || el.getAttribute(\"aria-expanded\") === \"true\") return;\n    // A neighbour is open, or one just closed: no wait and no fade\n    const warm = (group.current && group.current !== me.current) || Date.now() < group.warmUntil;\n    if (now || warm) {\n      setInstant(!!warm);\n      change.current(true);\n    } else {\n      timer.current = window.setTimeout(() => {\n        setInstant(false);\n        change.current(true);\n      }, Math.max(0, delay));\n    }\n  };\n  const hide = (later = false) => {\n    window.clearTimeout(timer.current);\n    if (later) timer.current = window.setTimeout(() => change.current(false), GRACE);\n    else change.current(false);\n  };\n\n  // One at a time: opening this one closes the other; closing opens the skip-delay window\n  useEffect(() => {\n    if (!open) return;\n    const self = me.current;\n    if (group.current && group.current !== self) group.current.close();\n    group.current = self;\n    // When the label just repeats the trigger's own name, screen readers only get the shortcut from it\n    const el = trigger.current;\n    const name = (el?.getAttribute(\"aria-label\") || el?.textContent || \"\").trim();\n    setQuiet(typeof content === \"string\" && content.trim() === name);\n    return () => {\n      if (group.current === self) {\n        group.current = null;\n        group.warmUntil = Date.now() + SKIP;\n      }\n    };\n  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Into the top layer next to the trigger before paint; it follows on scroll and resize\n  useLayoutEffect(() => {\n    const el = tip.current;\n    if (!open || !el) return;\n    showLayer(el);\n    const update = () => {\n      const a = trigger.current;\n      if (a) place(el, a, side, align, offset);\n    };\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(update);\n    ro?.observe(el);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      ro?.disconnect();\n      hideLayer(el);\n    };\n  }, [open, side, align, offset]);\n\n  // Escape closes it first – caught early, so a dialog around it stays open\n  useEffect(() => {\n    if (!open) return;\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      e.preventDefault();\n      e.stopPropagation();\n      blocked.current = true;\n      change.current(false);\n    };\n    window.addEventListener(\"keydown\", key, true);\n    return () => window.removeEventListener(\"keydown\", key, true);\n  }, [open]);\n\n  if (!isValidElement(children)) return <>{children}</>;\n  const own = children.props as Record<string, unknown> & { ref?: Ref<HTMLElement> };\n  const call = (name: string, e: SyntheticEvent) => (own[name] as ((e: SyntheticEvent) => void) | undefined)?.(e);\n  // The child's handler runs first; when it calls preventDefault, the tooltip leaves the event alone\n  const wrap =\n    <E extends SyntheticEvent>(name: string, mine: (e: E) => void) =>\n    (e: E) => {\n      call(name, e);\n      if (!e.defaultPrevented) mine(e);\n    };\n\n  const describedBy = [own[\"aria-describedby\"], open && (quiet ? (shortcut ? keysId : null) : tipId)].filter(Boolean).join(\" \") || undefined;\n\n  const trig = cloneElement(children as ReactElement<Record<string, unknown>>, {\n    ref: (el: HTMLElement | null) => {\n      trigger.current = el;\n      const r = own.ref;\n      if (typeof r === \"function\") r(el);\n      else if (r) (r as { current: HTMLElement | null }).current = el;\n    },\n    \"aria-describedby\": describedBy,\n    // Touch has no hover: a tap never opens it (the label must not live only in a tooltip)\n    onPointerEnter: wrap(\"onPointerEnter\", (e: ReactPointerEvent) => {\n      if (e.pointerType !== \"touch\") show(false);\n    }),\n    onPointerLeave: wrap(\"onPointerLeave\", (e: ReactPointerEvent) => {\n      blocked.current = false;\n      if (e.pointerType !== \"touch\") hide(true);\n    }),\n    onPointerDown: wrap(\"onPointerDown\", () => {\n      blocked.current = true;\n      hide();\n    }),\n    onClick: wrap(\"onClick\", () => {\n      blocked.current = true;\n      hide();\n    }),\n    // Keyboard focus shows it at once; a click's focus does not\n    onFocus: wrap(\"onFocus\", (e: ReactFocusEvent) => {\n      let visible = true;\n      try {\n        visible = (e.currentTarget as HTMLElement).matches(\":focus-visible\");\n      } catch {}\n      if (visible) show(true);\n    }),\n    onBlur: wrap(\"onBlur\", () => {\n      blocked.current = false;\n      hide();\n    }),\n  });\n\n  return (\n    <>\n      {trig}\n      {open && (\n        <span\n          ref={tip}\n          id={tipId}\n          role=\"tooltip\"\n          className={[\"ui-tipc\", className].filter(Boolean).join(\" \")}\n          popover=\"manual\"\n          data-side={side}\n          data-instant={instant || undefined}\n          // The pointer can rest on the label itself; leaving it closes it\n          onPointerEnter={() => window.clearTimeout(timer.current)}\n          onPointerLeave={() => hide(true)}\n        >\n          {/* React 19 hoists this into <head> once, no matter how many tooltips render */}\n          <style href=\"ui-tooltip-clean\" precedence=\"default\">\n            {css}\n          </style>\n          <span className=\"ui-tipc__text\" dir=\"auto\">\n            {content}\n          </span>\n          {shortcut && (\n            <kbd className=\"ui-tipc__keys\" id={keysId}>\n              {shortcut}\n            </kbd>\n          )}\n        </span>\n      )}\n    </>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\n/** The top layer, so no overflow cuts it and no z-index fights it */\nfunction showLayer(el: HTMLElement) {\n  if (\"showPopover\" in el && !el.matches(\":popover-open\")) el.showPopover();\n}\nfunction hideLayer(el: HTMLElement) {\n  if (\"hidePopover\" in el && el.isConnected && el.matches(\":popover-open\")) el.hidePopover();\n}\n\n/**\n * Puts the fixed label next to the trigger: on the preferred side, or the other one when that has more room (beside it\n * with no room either way: above or under it); the edge it lines up with switches when it would stick out, and it is\n * pushed inside the window as a last resort\n */\nfunction place(el: HTMLElement, trigger: HTMLElement, side: \"top\" | \"bottom\" | \"left\" | \"right\", align: \"start\" | \"center\" | \"end\", gap: number) {\n  const pad = 8;\n  const r = trigger.getBoundingClientRect();\n  const vw = document.documentElement.clientWidth || window.innerWidth;\n  const vh = window.innerHeight;\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const room = { bottom: vh - r.bottom - gap - pad, top: r.top - gap - pad, right: vw - r.right - gap - pad, left: r.left - gap - pad };\n  const flip = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" } as const;\n  let s: keyof typeof room = side;\n  if (room[s] < (s === \"top\" || s === \"bottom\" ? h : w) && room[flip[s]] > room[s]) s = flip[s];\n  if ((s === \"left\" || s === \"right\") && room[s] < w) s = room.top >= h || room.top >= room.bottom ? \"top\" : \"bottom\";\n  // start and end follow the reading direction above and below\n  const rtl = getComputedStyle(trigger).direction === \"rtl\";\n  const a = (s === \"top\" || s === \"bottom\") && rtl && align !== \"center\" ? (align === \"start\" ? \"end\" : \"start\") : align;\n  let top: number;\n  let left: number;\n  if (s === \"top\" || s === \"bottom\") {\n    top = s === \"bottom\" ? r.bottom + gap : r.top - gap - h;\n    left = a === \"start\" ? r.left : a === \"end\" ? r.right - w : r.left + r.width / 2 - w / 2;\n    left = Math.min(Math.max(pad, left), vw - pad - w);\n  } else {\n    left = s === \"right\" ? r.right + gap : r.left - gap - w;\n    top = a === \"start\" ? r.top : a === \"end\" ? r.bottom - h : r.top + r.height / 2 - h / 2;\n    top = Math.min(Math.max(pad, top), Math.max(pad, vh - pad - h));\n  }\n  el.style.top = `${Math.round(top)}px`;\n  el.style.left = `${Math.round(left)}px`;\n  el.dataset.side = s;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-tipc (see Theming in the docs) */\n.ui-tipc {\n  --ui-tipc-bg: rgba(24, 24, 27, 0.92);\n  --ui-tipc-ink: #fcfcfc;\n  --ui-tipc-muted: rgba(255, 255, 255, 0.6);\n  --ui-tipc-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.24);\n  --ui-tipc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n\n  /* A small dark label in the top layer – it fits its text and wraps at 240px */\n  position: fixed;\n  inset: auto;\n  z-index: 1001;\n  display: flex;\n  align-items: baseline;\n  gap: 6px;\n  box-sizing: border-box;\n  width: max-content;\n  max-width: min(240px, calc(100vw - 16px));\n  margin: 0;\n  padding: 4px 8px;\n  border: 0;\n  border-radius: var(--aui-radius, 6px);\n  background: var(--ui-tipc-bg);\n  -webkit-backdrop-filter: blur(8px);\n  backdrop-filter: blur(8px);\n  box-shadow: var(--ui-tipc-shadow);\n  color: var(--ui-tipc-ink);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 12px;\n  line-height: 16px;\n  font-weight: 500;\n  text-align: start;\n  white-space: normal;\n  overflow: visible;\n  -webkit-font-smoothing: antialiased;\n  animation: ui-tipc-in 140ms var(--ui-tipc-ease) both;\n}\n.ui-tipc::backdrop { display: none; }\n.ui-tipc__text { min-width: 0; overflow-wrap: anywhere; }\n/* The shortcut: quieter, never broken across lines */\n.ui-tipc__keys {\n  flex: none;\n  margin: 0;\n  font: inherit;\n  font-feature-settings: \"tnum\";\n  letter-spacing: 0.02em;\n  color: var(--ui-tipc-muted);\n  white-space: nowrap;\n}\n\n/* It drifts in 2px from the trigger's side; a neighbour in the skip-delay window shows at once */\n.ui-tipc[data-side=\"bottom\"] { animation-name: ui-tipc-in-down; }\n.ui-tipc[data-side=\"left\"] { animation-name: ui-tipc-in-left; }\n.ui-tipc[data-side=\"right\"] { animation-name: ui-tipc-in-right; }\n.ui-tipc[data-instant] { animation: none; }\n@keyframes ui-tipc-in { from { opacity: 0; transform: translateY(2px); } }\n@keyframes ui-tipc-in-down { from { opacity: 0; transform: translateY(-2px); } }\n@keyframes ui-tipc-in-left { from { opacity: 0; transform: translateX(2px); } }\n@keyframes ui-tipc-in-right { from { opacity: 0; transform: translateX(-2px); } }\n\n/* Touch screens (a tablet with a mouse or pen): a little larger type */\n@media (pointer: coarse) {\n  .ui-tipc { padding: 5px 9px; font-size: 13px; line-height: 18px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-tipc { animation: none; }\n}\n\n/* Dark mode: a light label – follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-tipc {\n  --ui-tipc-bg: rgba(250, 250, 250, 0.94);\n  --ui-tipc-ink: #111113;\n  --ui-tipc-muted: rgba(0, 0, 0, 0.5);\n  --ui-tipc-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.6);\n  color-scheme: dark;\n}\n`;\n"}]}