{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "smart-tooltip",
  "type": "registry:ui",
  "title": "Smart Tooltip",
  "description": "An accessible tooltip for hover and keyboard focus with flip / shift placement, a pointing arrow and an OS-style delay group.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/smart-tooltip.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, type AnimationPlaybackControls } from \"motion/react\";\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport interface SmartTooltipProps {\n  /** Tooltip text. Keep it short and non-interactive (no links or buttons — use a popover for those). */\n  content: React.ReactNode;\n  /** The trigger: one focusable element. It receives aria-describedby pointing at the tooltip. */\n  children: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  /** Preferred side; flips to the opposite side when there is no room. */\n  side?: Side;\n  /** Alignment along the trigger; shifted to stay inside the viewport. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Hover delay before opening, in ms. Skipped while another tooltip is (or was just) open. */\n  delay?: number;\n  /** Grace period after the pointer leaves, so it can move onto the tooltip, in ms. */\n  closeDelay?: number;\n  /** After a tooltip closes, neighbours open instantly for this long, in ms. */\n  skipDelayDuration?: number;\n  /** Distance between trigger and tooltip in px. */\n  sideOffset?: number;\n  /** Show the pointing arrow. */\n  arrow?: boolean;\n  variant?: \"contrast\" | \"surface\" | \"accent\";\n  /** Maximum width in px. */\n  maxWidth?: number;\n  /** Touch: press-and-hold duration that shows the tooltip, in ms. */\n  longPressDelay?: number;\n  /** Controlled open state. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  /** Accent colour for the accent variant. */\n  accent?: string;\n  /** Extra classes for the tooltip bubble. */\n  className?: string;\n}\n\nconst EDGE = 8; // px kept from the viewport edge\nconst ARROW = 5; // px arrow half-size\nconst OPPOSITE: Record<Side, Side> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n\n/* ------------------------------- delay group ------------------------------- */\n// Module-level, like the operating system: once one tooltip is open, the next opens instantly.\nconst group = {\n  openId: null as string | null,\n  close: null as (() => void) | null,\n  lastClosedAt: -Infinity,\n};\nfunction groupOpen(id: string, close: () => void) {\n  if (group.openId && group.openId !== id) group.close?.();\n  group.openId = id;\n  group.close = close;\n}\nfunction groupClosed(id: string) {\n  if (group.openId !== id) return;\n  group.openId = null;\n  group.close = null;\n  group.lastClosedAt = performance.now();\n}\nfunction groupIsWarm(skip: number) {\n  return group.openId !== null || performance.now() - group.lastClosedAt < skip;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst VARIANTS = {\n  contrast: \"bg-zinc-900 text-zinc-50 dark:bg-zinc-50 dark:text-zinc-900\",\n  surface:\n    \"border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.3)] dark:border-white/10 dark:bg-[#1f2328] dark:text-zinc-100\",\n  accent: \"bg-[var(--st-accent)] text-zinc-950\",\n} as const;\n\n/**\n * An accessible tooltip that opens on hover (after a delay) and on keyboard focus, and meets WCAG\n * 1.4.13: the pointer can move onto it (hoverable), Escape hides it without moving focus\n * (dismissible), and it stays until hover or focus leaves (persistent). It renders in the top layer,\n * flips and shifts to stay in view with an arrow that keeps pointing at the trigger, and shares a\n * delay group so neighbouring tooltips open instantly. Touch: press and hold. Not for interactive content.\n */\nexport function SmartTooltip({\n  content,\n  children,\n  side = \"top\",\n  align = \"center\",\n  delay = 600,\n  closeDelay = 120,\n  skipDelayDuration = 300,\n  sideOffset = 8,\n  arrow = true,\n  variant = \"contrast\",\n  maxWidth = 240,\n  longPressDelay = 500,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  accent = \"#FF7448\",\n  className = \"\",\n}: SmartTooltipProps) {\n  const uid = React.useId();\n  const tooltipId = `${uid}-tooltip`;\n  const reduceMotion = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState(defaultOpen);\n  const isOpen = !disabled && (openProp ?? internal);\n  const [instant, setInstant] = React.useState(false);\n  const wrapRef = React.useRef<HTMLSpanElement>(null);\n  const tipRef = React.useRef<HTMLSpanElement>(null);\n  const timers = React.useRef({ open: 0, close: 0, press: 0 });\n  const press = React.useRef({ x: 0, y: 0, opened: false });\n  // Set after a click or Escape: stays closed until the pointer leaves / focus moves.\n  const suppressed = React.useRef(false);\n  const animation = React.useRef<AnimationPlaybackControls | null>(null);\n  const generation = React.useRef(0);\n  const onOpenChangeRef = React.useRef(onOpenChange);\n  React.useEffect(() => {\n    onOpenChangeRef.current = onOpenChange;\n  });\n\n  const setOpen = (next: boolean, skipAnimation = false) => {\n    window.clearTimeout(timers.current.open);\n    window.clearTimeout(timers.current.close);\n    if (next === isOpen) return;\n    setInstant(skipAnimation);\n    if (openProp === undefined) setInternal(next);\n    onOpenChange?.(next);\n  };\n\n  const scheduleOpen = (immediate: boolean) => {\n    if (disabled || suppressed.current) return;\n    window.clearTimeout(timers.current.close);\n    window.clearTimeout(timers.current.open);\n    const warm = groupIsWarm(skipDelayDuration);\n    if (immediate || warm) setOpen(true, warm && !immediate);\n    else timers.current.open = window.setTimeout(() => setOpen(true), delay);\n  };\n  const scheduleClose = () => {\n    window.clearTimeout(timers.current.open);\n    window.clearTimeout(timers.current.close);\n    timers.current.close = window.setTimeout(() => setOpen(false), closeDelay);\n  };\n\n  /** Positions the bubble: preferred side, flipped when it doesn't fit, shifted inside the viewport. */\n  const position = React.useCallback(() => {\n    const tip = tipRef.current;\n    const wrap = wrapRef.current;\n    if (!tip || !wrap) return;\n    const anchor = (wrap.firstElementChild as HTMLElement | null) ?? wrap;\n    const r = anchor.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const w = tip.offsetWidth;\n    const h = tip.offsetHeight;\n    const gap = sideOffset + (arrow ? ARROW - 1 : 0);\n    const fits = (s: Side) =>\n      s === \"top\" ? r.top - gap - h >= EDGE : s === \"bottom\" ? r.bottom + gap + h <= vh - EDGE : s === \"left\" ? r.left - gap - w >= EDGE : r.right + gap + w <= vw - EDGE;\n    const finalSide = fits(side) || !fits(OPPOSITE[side]) ? side : OPPOSITE[side];\n    const vertical = finalSide === \"top\" || finalSide === \"bottom\";\n    let x: number;\n    let y: number;\n    if (vertical) {\n      x = align === \"start\" ? r.left : align === \"end\" ? r.right - w : r.left + r.width / 2 - w / 2;\n      y = finalSide === \"top\" ? r.top - gap - h : r.bottom + gap;\n    } else {\n      y = align === \"start\" ? r.top : align === \"end\" ? r.bottom - h : r.top + r.height / 2 - h / 2;\n      x = finalSide === \"left\" ? r.left - gap - w : r.right + gap;\n    }\n    x = Math.max(EDGE, Math.min(x, vw - w - EDGE));\n    y = Math.max(EDGE, Math.min(y, vh - h - EDGE));\n    // The arrow keeps pointing at the trigger's centre even after shifting.\n    const arrowAt = vertical ? Math.max(10, Math.min(w - 10, r.left + r.width / 2 - x)) : Math.max(8, Math.min(h - 8, r.top + r.height / 2 - y));\n    tip.style.left = `${Math.round(x)}px`;\n    tip.style.top = `${Math.round(y)}px`;\n    tip.style.setProperty(\"--st-arrow\", `${Math.round(arrowAt)}px`);\n    tip.dataset.side = finalSide;\n  }, [side, align, sideOffset, arrow]);\n\n  // Show / hide the top-layer bubble and animate it in from the trigger side.\n  React.useLayoutEffect(() => {\n    const tip = tipRef.current;\n    if (!tip) return;\n    animation.current?.stop();\n    const run = ++generation.current;\n    if (isOpen) {\n      if (!tip.matches(\":popover-open\")) {\n        try {\n          tip.showPopover();\n        } catch {\n          // Popover API unavailable: position: fixed still applies.\n        }\n      }\n      tip.style.visibility = \"visible\";\n      position();\n      const s = tip.dataset.side as Side;\n      const d = 4;\n      const offset = s === \"top\" ? { y: [d, 0] } : s === \"bottom\" ? { y: [-d, 0] } : s === \"left\" ? { x: [d, 0] } : { x: [-d, 0] };\n      animation.current =\n        reduceMotion || instant\n          ? animate(tip, { opacity: [0, 1], x: 0, y: 0, scale: 1 }, { duration: instant ? 0.06 : 0.12 })\n          : animate(tip, { opacity: [0, 1], scale: [0.96, 1], ...offset }, { type: \"spring\", stiffness: 520, damping: 34, opacity: { duration: 0.12 } });\n      return;\n    }\n    if (tip.style.visibility !== \"visible\") return;\n    animation.current = animate(tip, { opacity: 0 }, { duration: instant ? 0.04 : 0.1 });\n    void animation.current.finished.then(() => {\n      // A newer show / hide has taken over.\n      if (generation.current !== run) return;\n      tip.style.visibility = \"hidden\";\n      try {\n        tip.hidePopover();\n      } catch {\n        // not shown as a popover\n      }\n    });\n  }, [isOpen, instant, reduceMotion, position]);\n\n  // Join the delay group while open; Escape dismisses; scroll / resize re-position; outside taps close (touch).\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const close = () => {\n      if (openProp === undefined) setInternal(false);\n      onOpenChangeRef.current?.(false);\n    };\n    groupOpen(uid, () => {\n      setInstant(true);\n      close();\n    });\n    let frame = 0;\n    // Escape closes only the tooltip: handled first (window, capture phase) and consumed, so it\n    // doesn't also close a surrounding dialog or menu. preventDefault stops a <dialog>'s cancel.\n    // While no tooltip is open this listener isn't attached and Escape passes through untouched.\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.isComposing) return;\n      event.preventDefault();\n      event.stopPropagation();\n      suppressed.current = true;\n      setInstant(false);\n      close();\n    };\n    const onMove = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(position);\n    };\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.pointerType !== \"mouse\" && !wrapRef.current?.contains(event.target as Node)) close();\n    };\n    window.addEventListener(\"keydown\", onKey, true);\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    window.addEventListener(\"scroll\", onMove, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onMove);\n    return () => {\n      groupClosed(uid);\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"keydown\", onKey, true);\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      window.removeEventListener(\"scroll\", onMove, { capture: true });\n      window.removeEventListener(\"resize\", onMove);\n    };\n  }, [isOpen, uid, openProp, position]);\n\n  React.useEffect(() => {\n    const t = timers.current;\n    return () => {\n      window.clearTimeout(t.open);\n      window.clearTimeout(t.close);\n      window.clearTimeout(t.press);\n      animation.current?.stop();\n    };\n  }, []);\n\n  const describedBy = [children.props[\"aria-describedby\"], disabled ? undefined : tooltipId].filter(Boolean).join(\" \") || undefined;\n  const trigger = React.cloneElement(children, { \"aria-describedby\": describedBy });\n\n  return (\n    <span\n      ref={wrapRef}\n      className=\"inline-flex [-webkit-touch-callout:none]\"\n      style={{ [\"--st-accent\" as string]: accent }}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") scheduleOpen(false);\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType !== \"mouse\") return;\n        suppressed.current = false;\n        scheduleClose();\n      }}\n      onPointerDown={(event) => {\n        if (event.pointerType === \"mouse\") {\n          // Clicking the trigger hides the tooltip, like a native one.\n          suppressed.current = true;\n          setOpen(false);\n          return;\n        }\n        window.clearTimeout(timers.current.press);\n        press.current = { x: event.clientX, y: event.clientY, opened: false };\n        timers.current.press = window.setTimeout(() => {\n          press.current.opened = true;\n          suppressed.current = false;\n          setOpen(true);\n        }, longPressDelay);\n      }}\n      onPointerMove={(event) => {\n        if (event.pointerType === \"mouse\") return;\n        if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 10) window.clearTimeout(timers.current.press);\n      }}\n      onPointerUp={() => window.clearTimeout(timers.current.press)}\n      onPointerCancel={() => window.clearTimeout(timers.current.press)}\n      onContextMenu={(event) => {\n        // A long-press showed the tooltip; don't also open the system menu.\n        if (press.current.opened) event.preventDefault();\n      }}\n      onClickCapture={(event) => {\n        // Swallow the click that can follow a long-press.\n        if (press.current.opened) {\n          press.current.opened = false;\n          event.preventDefault();\n          event.stopPropagation();\n        }\n      }}\n      onFocus={(event) => {\n        // Keyboard focus shows it at once; focus from a click does not.\n        if (event.target instanceof Element && event.target.matches(\":focus-visible\")) {\n          suppressed.current = false;\n          scheduleOpen(true);\n        }\n      }}\n      onBlur={() => {\n        suppressed.current = false;\n        setOpen(false);\n      }}\n    >\n      {trigger}\n      <span\n        ref={tipRef}\n        id={tooltipId}\n        role=\"tooltip\"\n        popover=\"manual\"\n        onPointerEnter={() => window.clearTimeout(timers.current.close)}\n        onPointerLeave={(event) => {\n          if (event.pointerType === \"mouse\") scheduleClose();\n        }}\n        className={`group/tip invisible fixed inset-auto m-0 block w-max overflow-visible rounded-lg px-2.5 py-1.5 text-left text-[12.5px] font-medium leading-snug tracking-[-0.005em] opacity-0 ${VARIANTS[variant] ?? VARIANTS.contrast} ${className}`}\n        style={{ maxWidth: `min(${maxWidth}px, calc(100vw - ${EDGE * 2}px))`, [\"--st-accent\" as string]: accent }}\n      >\n        {content}\n        {arrow ? (\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute size-2.5 rotate-45 bg-inherit group-data-[side=bottom]/tip:-top-[5px] group-data-[side=bottom]/tip:left-[var(--st-arrow)] group-data-[side=bottom]/tip:-translate-x-1/2 group-data-[side=left]/tip:-right-[5px] group-data-[side=left]/tip:top-[var(--st-arrow)] group-data-[side=left]/tip:-translate-y-1/2 group-data-[side=right]/tip:-left-[5px] group-data-[side=right]/tip:top-[var(--st-arrow)] group-data-[side=right]/tip:-translate-y-1/2 group-data-[side=top]/tip:-bottom-[5px] group-data-[side=top]/tip:left-[var(--st-arrow)] group-data-[side=top]/tip:-translate-x-1/2 ${\n              variant === \"surface\"\n                ? \"border-zinc-900/10 group-data-[side=bottom]/tip:border-l group-data-[side=bottom]/tip:border-t group-data-[side=left]/tip:border-r group-data-[side=left]/tip:border-t group-data-[side=right]/tip:border-b group-data-[side=right]/tip:border-l group-data-[side=top]/tip:border-b group-data-[side=top]/tip:border-r dark:border-white/10\"\n                : \"\"\n            }`}\n          />\n        ) : null}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/smart-tooltip"
}
