{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip","type":"registry:ui","title":"Tooltip","description":"A small anchored label that explains a control on hover or focus — plain text only, never interactive content","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["overlays"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/tooltip/Tooltip.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport type { Side, Align } from \"../../internals/anchor-position\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useAnchorPosition } from \"../../internals/use-anchor-position\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useEventCallback } from \"../../internals/dom/use-event-callback\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { anchored, originFor } from \"../../internals/motion/anchored\";\nimport { runTransition } from \"../../internals/motion/animate\";\nimport type { TransitionRun } from \"../../internals/motion/animate\";\n\n// The source gates its focusable-trigger warning on the bundler dev flag,\n// which needs Vite's client types this package does not load.\n// `process.env.NODE_ENV` is the equivalent signal every React consumer's\n// bundler already replaces statically, so it is read as that exact literal in\n// try/catch (no `typeof process` guard in front: it would short-circuit in a\n// browser bundle). Declared locally so the d.ts build needs no @types/node.\ndeclare const process: { env: { NODE_ENV?: string } };\nconst DEV = (() => {\n\ttry {\n\t\treturn process.env.NODE_ENV !== \"production\";\n\t} catch {\n\t\treturn false;\n\t}\n})();\n\nexport interface TooltipProps {\n\t/** The tooltip's text. Plain text only — a tooltip never holds interactive content. */\n\tcontent: string;\n\t/** Side of the trigger to place the tooltip on. */\n\tside?: Side;\n\t/** Alignment along the trigger's cross axis. */\n\talign?: Align;\n\t/** Gap in pixels between the trigger and the tooltip. */\n\toffset?: number;\n\t/** Delay in milliseconds before a hover opens the tooltip. Never applied to a focus open — see the README. */\n\topenDelay?: number;\n\t/** Delay in milliseconds before the tooltip closes once neither hovered nor focused. */\n\tcloseDelay?: number;\n\t/** Suppresses the tooltip entirely — it never opens, on hover or focus, while true. */\n\tdisabled?: boolean;\n\t/**\n\t * The trigger. Must render exactly one focusable element (a button,\n\t * link, `IconButton`, ...) — Tooltip attaches its hover/focus\n\t * listeners and `aria-describedby` directly to it, imperatively,\n\t * since there is no prop to hand them to it declaratively.\n\t */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes, merged onto the trigger wrapper. */\n\tclassName?: string;\n}\n\n// A tooltip that isn't attached to something the keyboard can reach is the\n// exact failure this component exists to prevent, and it fails silently\n// otherwise — hovering still opens it, so it *looks* wired. Dev-only: the\n// check itself has no effect on behavior, only on whether a misuse gets\n// reported.\nfunction isFocusable(el: HTMLElement): boolean {\n\tif ((el as HTMLButtonElement | HTMLInputElement).disabled) return false;\n\tif ([\"BUTTON\", \"INPUT\", \"SELECT\", \"TEXTAREA\"].includes(el.tagName)) return true;\n\tif (el.tagName === \"A\" && el.hasAttribute(\"href\")) return true;\n\tconst tabindex = el.getAttribute(\"tabindex\");\n\treturn tabindex !== null && tabindex !== \"-1\";\n}\n\n/**\n * `aria-describedby` is an ID REFERENCE LIST — whitespace-separated, and read\n * in order — so the trigger's own description has to survive a tooltip\n * appearing over it.\n */\nfunction describedByIds(el: HTMLElement): string[] {\n\treturn (el.getAttribute(\"aria-describedby\") ?? \"\").split(/\\s+/).filter(Boolean);\n}\n\n/**\n * A plain-text label anchored to a focusable trigger, opening on hover or\n * focus.\n *\n * The source declares `ref = $bindable(null)` for the trigger wrapper\n * element, so that element arrives through this component's ref channel\n * (PORTING.md §\"API contract\") rather than through a prop.\n */\nexport const Tooltip = forwardRef<HTMLSpanElement, TooltipProps>(function Tooltip(\n\t{\n\t\tcontent,\n\t\tside = \"top\",\n\t\talign = \"center\",\n\t\toffset = 6,\n\t\topenDelay = 500,\n\t\tcloseDelay = 0,\n\t\tdisabled = false,\n\t\tchildren,\n\t\tclassName,\n\t},\n\tforwardedRef\n) {\n\tconst tooltipId = useFancyId();\n\n\tconst [open, setOpen] = useState(false);\n\n\t// The wrapper as a NODE (convention C-1): the listener-wiring effect below\n\t// is keyed on it, so it re-arms if the wrapper ever remounts, exactly as\n\t// the source effect keyed on `ref` does.\n\tconst [wrapper, setWrapperNode] = useElementRef<HTMLSpanElement>();\n\tconst wrapperRef = useComposedRefs(setWrapperNode, forwardedRef);\n\n\t// The trigger — the wrapper's first rendered child. State, not a plain\n\t// ref: the aria-describedby effect below has to react to it appearing.\n\tconst [triggerEl, setTriggerEl] = useState<HTMLElement | null>(null);\n\n\t// Three independent reasons to be open, each tracked on its own:\n\t// hovering the trigger, hovering the bubble itself, and focus. They are\n\t// three separate flags rather than one shared `hovered` flag\n\t// specifically for the trigger/bubble pair — the pointer crosses from\n\t// one to the other with both briefly true at once (enter the bubble\n\t// before leaving the trigger), and a single shared flag would let\n\t// whichever side's `pointerleave` fires *last* clobber the other side's\n\t// `pointerenter`, closing the tooltip while the pointer is still over\n\t// it. `updateVisibility` is the one place that turns the trio into an\n\t// open/closed decision, so releasing one never closes a tooltip the\n\t// others still want open.\n\t//\n\t// Refs, not state: nothing renders off them — only the `open` decision\n\t// they feed does, exactly where the source's reactivity re-renders markup.\n\tconst triggerHovered = useRef(false);\n\tconst contentHovered = useRef(false);\n\tconst focused = useRef(false);\n\n\tconst openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\tconst closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n\tfunction clearTimers() {\n\t\tif (openTimer.current !== undefined) {\n\t\t\tclearTimeout(openTimer.current);\n\t\t\topenTimer.current = undefined;\n\t\t}\n\t\tif (closeTimer.current !== undefined) {\n\t\t\tclearTimeout(closeTimer.current);\n\t\t\tcloseTimer.current = undefined;\n\t\t}\n\t}\n\n\t// Both `show` and `hide` clear whatever timer is currently pending\n\t// before scheduling their own — the fix for a rapid hover-out-hover-in:\n\t// without it, the leftover close timer from the \"out\" and the fresh\n\t// open timer from the \"in\" would both be alive at once, racing to set\n\t// opposite states.\n\tfunction show(delay: number) {\n\t\tclearTimers();\n\t\tif (delay <= 0) {\n\t\t\tsetOpen(true);\n\t\t\treturn;\n\t\t}\n\t\topenTimer.current = setTimeout(() => {\n\t\t\topenTimer.current = undefined;\n\t\t\tsetOpen(true);\n\t\t}, delay);\n\t}\n\n\tfunction hide() {\n\t\tclearTimers();\n\t\tif (closeDelay <= 0) {\n\t\t\tsetOpen(false);\n\t\t\treturn;\n\t\t}\n\t\tcloseTimer.current = setTimeout(() => {\n\t\t\tcloseTimer.current = undefined;\n\t\t\tsetOpen(false);\n\t\t}, closeDelay);\n\t}\n\n\t// Called from every hover/focus/blur handler below, and — separately —\n\t// from the effect further down that watches `disabled` itself. That\n\t// second caller is why `disabled` routes through `hide()` rather than a\n\t// bare early return: `updateVisibility` has to be able to force an\n\t// already-open tooltip closed when `disabled` flips true with the\n\t// pointer or focus still in place, not just decline to open a new one.\n\tfunction updateVisibility() {\n\t\tif (disabled) {\n\t\t\thide();\n\t\t\treturn;\n\t\t}\n\t\tif (triggerHovered.current || contentHovered.current || focused.current) {\n\t\t\t// A keyboard user tabbing through has to see the tooltip right\n\t\t\t// away, not wait out a hover-tuned delay meant to stop tooltips\n\t\t\t// from flashing as the mouse merely passes over things — so a\n\t\t\t// focus-driven open always uses a delay of 0, and only a hover\n\t\t\t// with no focus involved waits out `openDelay`.\n\t\t\tshow(focused.current ? 0 : openDelay);\n\t\t} else {\n\t\t\thide();\n\t\t}\n\t}\n\n\t// The trigger listeners live on the stack for the whole life of the\n\t// wrapper (the wiring effect below is keyed on the wrapper alone, exactly\n\t// like the source effect keyed on `ref`), so each goes through\n\t// `useEventCallback` and reads the current render's props without the\n\t// listeners ever being rebuilt.\n\tconst handlePointerEnter = useEventCallback(() => {\n\t\ttriggerHovered.current = true;\n\t\tupdateVisibility();\n\t});\n\tconst handlePointerLeave = useEventCallback(() => {\n\t\ttriggerHovered.current = false;\n\t\tupdateVisibility();\n\t});\n\tconst handleFocus = useEventCallback(() => {\n\t\tfocused.current = true;\n\t\tupdateVisibility();\n\t});\n\tconst handleBlur = useEventCallback(() => {\n\t\tfocused.current = false;\n\t\tupdateVisibility();\n\t});\n\n\t// Routed through the dismissable layer on the bubble (`escape: true,\n\t// outsideClick: false` — see below) rather than a keydown listener of its\n\t// own, so Escape has exactly one implementation across every overlay in\n\t// this family, not a second one that happens to agree with it today.\n\t// The layer decides by stack order, not by what currently has focus,\n\t// which is also why this closes instantly and never moves focus itself —\n\t// the trigger stays exactly where the keyboard user left it, unlike a\n\t// dismissed Popover/Dialog. Bypasses `closeDelay` on purpose, same as the\n\t// listener this replaced: Escape is a deliberate \"get me out of here\",\n\t// not a pointer drifting off that deserves a grace period.\n\tconst handleDismiss = useEventCallback(() => {\n\t\tsetOpen(false);\n\t\tclearTimers();\n\t});\n\n\t// The rendered tooltip bubble gets its own hover flag rather than\n\t// sharing the trigger's — see `triggerHovered`/`contentHovered` above.\n\t// Without this, moving the pointer off the trigger and onto the bubble\n\t// itself (when the bubble sits in the pointer's path) would read as\n\t// \"left the trigger\" and close it out from under the pointer before it\n\t// arrives.\n\tconst handleContentPointerEnter = useEventCallback(() => {\n\t\tcontentHovered.current = true;\n\t\tupdateVisibility();\n\t});\n\tconst handleContentPointerLeave = useEventCallback(() => {\n\t\tcontentHovered.current = false;\n\t\tupdateVisibility();\n\t});\n\n\t// The only path into `updateVisibility` that isn't a DOM event: `disabled`\n\t// is a prop, and toggling it doesn't fire a pointer/focus event on its\n\t// own. Without this, flipping `disabled` true while hovered or focused\n\t// would hide the bubble only via the `open && !disabled` gate below,\n\t// leaving `open` itself still `true` underneath — so flipping `disabled`\n\t// back to `false` with the pointer never having left would pop the bubble\n\t// straight back, skipping `openDelay` entirely, since nothing would have\n\t// gone through `show()` to schedule it.\n\t//\n\t// Keyed on `disabled` alone — the React spelling of the source's\n\t// `untrack(updateVisibility)`: the hover/focus flags are refs, so a\n\t// change to them never re-runs this effect on top of the direct call\n\t// each handler above already makes.\n\tconst updateVisibilityEvent = useEventCallback(updateVisibility);\n\tuseEffect(() => {\n\t\tupdateVisibilityEvent();\n\t}, [disabled, updateVisibilityEvent]);\n\n\t// `children` is caller content, not something this component renders\n\t// itself, so there is no prop to declaratively attach the hover/focus\n\t// listeners to its root element — they're attached imperatively, to the\n\t// wrapper's first rendered child, once it exists. Only depends on the\n\t// wrapper node: re-attaching the same listeners every time `disabled`\n\t// merely toggles would be pure churn now that `disabled` has its own\n\t// effect above.\n\tuseEffect(() => {\n\t\tif (!wrapper) return;\n\t\tconst el = wrapper.firstElementChild as HTMLElement | null;\n\t\tsetTriggerEl(el);\n\t\tif (!el) return;\n\n\t\tif (DEV && !isFocusable(el)) {\n\t\t\tconsole.warn(\n\t\t\t\t\"[Tooltip] The first element rendered by `children` is not focusable, so this tooltip is unreachable by keyboard even though it will still open on hover. Render a real interactive element (a <button>, <a href>, or similar) as the first thing inside `children`.\"\n\t\t\t);\n\t\t}\n\n\t\tel.addEventListener(\"pointerenter\", handlePointerEnter);\n\t\tel.addEventListener(\"pointerleave\", handlePointerLeave);\n\t\tel.addEventListener(\"focus\", handleFocus);\n\t\tel.addEventListener(\"blur\", handleBlur);\n\n\t\treturn () => {\n\t\t\tel.removeEventListener(\"pointerenter\", handlePointerEnter);\n\t\t\tel.removeEventListener(\"pointerleave\", handlePointerLeave);\n\t\t\tel.removeEventListener(\"focus\", handleFocus);\n\t\t\tel.removeEventListener(\"blur\", handleBlur);\n\t\t};\n\t}, [wrapper, handlePointerEnter, handlePointerLeave, handleFocus, handleBlur]);\n\n\t// Separate from the listener-wiring effect above: this one has to react\n\t// to `open` and `disabled`, not just the wrapper, so the attribute is\n\t// only ever present while there is really a mounted element behind it —\n\t// the bubble below shares the exact same `open && !disabled` gate.\n\t// Reacting to `open` here rather than in the wiring effect also keeps\n\t// the listeners themselves from being torn down and re-attached on every\n\t// open/close.\n\t//\n\t// The id is APPENDED to whatever the trigger already declared, and only\n\t// this component's own id is taken away again on close. A trigger that\n\t// carries its own description — a form control pointing at its hint, an\n\t// icon button pointing at a live status line — keeps it while the tooltip\n\t// shows, instead of having it overwritten for the duration and then\n\t// deleted outright. Divergence from the Svelte source, which writes and\n\t// removes the whole attribute.\n\tuseEffect(() => {\n\t\tif (!triggerEl || disabled || !open) return;\n\t\tconst el = triggerEl;\n\t\tconst ids = describedByIds(el);\n\t\tif (!ids.includes(tooltipId)) ids.push(tooltipId);\n\t\tel.setAttribute(\"aria-describedby\", ids.join(\" \"));\n\t\treturn () => {\n\t\t\t// Read fresh rather than restoring the captured string: anything the\n\t\t\t// consumer added while the tooltip was open is theirs to keep, and\n\t\t\t// an attribute left empty is worse than no attribute at all.\n\t\t\tconst rest = describedByIds(el).filter((id) => id !== tooltipId);\n\t\t\tif (rest.length > 0) el.setAttribute(\"aria-describedby\", rest.join(\" \"));\n\t\t\telse el.removeAttribute(\"aria-describedby\");\n\t\t};\n\t}, [triggerEl, disabled, open, tooltipId]);\n\n\t// The source's `onDestroy(clearTimers)`.\n\tuseEffect(() => clearTimers, []);\n\n\tconst mounted = open && !disabled;\n\n\t// The bubble as a NODE (convention C-1): it is created by the `mounted`\n\t// gate, so a `useRef` read inside a `[]`-deps effect would still be null\n\t// when the position, dismiss and entrance effects fire.\n\tconst [bubble, setBubbleNode] = useElementRef<HTMLDivElement>();\n\n\t// The placement as ACTUALLY resolved — the requested side and align until\n\t// `computePosition` flips or clamps it away from a viewport edge. Seeded\n\t// with the REQUESTED values by the hook itself, so a bubble that never\n\t// flips reads the right growth origin without depending on whether the\n\t// first placement has run yet. `resolvedAlign` differs from the requested\n\t// alignment whenever clamping slid the panel along the cross axis — near\n\t// a viewport edge the requested corner is no longer the one touching the\n\t// anchor, and an entrance grown from it would expand from the far corner\n\t// instead.\n\tconst { side: resolvedSide, align: resolvedAlign } = useAnchorPosition(bubble, {\n\t\tanchor: () => triggerEl,\n\t\tside,\n\t\talign,\n\t\toffset,\n\t});\n\n\tuseDismissable(bubble, { onDismiss: handleDismiss, escape: true, outsideClick: false });\n\n\t// An entrance and NEVER an exit — the source uses `in:` and not\n\t// `transition:`. Instant-out is the whole point of a tooltip: a label\n\t// that lingers on its way out makes the pointer feel sticky, and an\n\t// outro would also delay the unmount that `closeDelay`, Escape and blur\n\t// all expect to be immediate. That is why this is a bare `runTransition`\n\t// on mount rather than a presence clock: there is no exit window to keep\n\t// the node alive through. The open delay above is a *scheduling* delay —\n\t// nothing is mounted while it runs — so it is not, and must not become,\n\t// the transition's `delay`.\n\t//\n\t// `scale: false` keeps the entrance opacity-only, exactly as it has\n\t// always been: a tooltip is a label, not a surface, so it has no \"grew\n\t// out of the trigger\" story that a scale would tell. The origin is still\n\t// written, so a consumer styling off `data-side` gets the same\n\t// information every other panel exposes.\n\t//\n\t// A layout effect, per the effect-phase policy: the source starts intros\n\t// pre-paint, and a passive effect would paint one frame at rest first.\n\t// Reduced motion needs no rule of its own: `anchored` collapses the\n\t// duration to 0 and `runTransition`'s falsy-duration fast path then\n\t// skips `element.animate()` entirely — the bubble is simply there, in\n\t// the frame it mounted.\n\tconst resolvedSideRef = useLiveRef(resolvedSide);\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (!bubble) return;\n\t\tlet run: TransitionRun | undefined;\n\t\trun = runTransition(\n\t\t\tbubble,\n\t\t\tanchored(bubble, { side: resolvedSideRef.current, scale: false }, { direction: \"in\" }),\n\t\t\t1,\n\t\t\tundefined,\n\t\t\t// On enter finish, abort the run: it removes the `fill: forwards`\n\t\t\t// so the element returns to its resting style — which *is* the\n\t\t\t// visible end state by construction. On the synchronous\n\t\t\t// reduced-motion path `run` is still unset here, and there is\n\t\t\t// nothing to abort.\n\t\t\t() => run?.abort()\n\t\t);\n\t\treturn () => run?.abort();\n\t}, [bubble, resolvedSideRef]);\n\n\t// The bubble's hover handlers are attached natively rather than through\n\t// synthetic props: the source listens for real `pointerenter` /\n\t// `pointerleave`, and React's enter/leave plugin synthesises those from\n\t// `pointerover`/`pointerout` instead of listening for them.\n\tuseEffect(() => {\n\t\tif (!bubble) return;\n\t\tbubble.addEventListener(\"pointerenter\", handleContentPointerEnter);\n\t\tbubble.addEventListener(\"pointerleave\", handleContentPointerLeave);\n\t\treturn () => {\n\t\t\tbubble.removeEventListener(\"pointerenter\", handleContentPointerEnter);\n\t\t\tbubble.removeEventListener(\"pointerleave\", handleContentPointerLeave);\n\t\t};\n\t}, [bubble, handleContentPointerEnter, handleContentPointerLeave]);\n\n\tconst wrapperClasses = cn(\"ft-tooltip-trigger inline-flex\", className);\n\n\t// `data-align` publishes the REQUESTED alignment, exactly as the source\n\t// does; only the transform origin follows the resolved one.\n\treturn (\n\t\t<>\n\t\t\t<span ref={wrapperRef} className={wrapperClasses}>\n\t\t\t\t{children}\n\t\t\t</span>\n\t\t\t<Portal>\n\t\t\t\t{mounted ? (\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={setBubbleNode}\n\t\t\t\t\t\tid={tooltipId}\n\t\t\t\t\t\trole=\"tooltip\"\n\t\t\t\t\t\tclassName=\"ft-tooltip bg-primary text-primary-foreground pointer-events-auto z-50 rounded-[6px] px-[10px] py-[5px] text-[11px] font-medium shadow-lg\"\n\t\t\t\t\t\tdata-side={resolvedSide}\n\t\t\t\t\t\tdata-align={align}\n\t\t\t\t\t\tstyle={{ transformOrigin: originFor(resolvedSide, resolvedAlign) }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{content}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</Portal>\n\t\t</>\n\t);\n});\n\nTooltip.displayName = \"Tooltip\";\n","type":"registry:ui","target":"@ui/fancy/components/tooltip/Tooltip.tsx"},{"path":"react/src/components/tooltip/index.ts","content":"\"use client\";\n\nexport { Tooltip } from \"./Tooltip\";\nexport type { TooltipProps } from \"./Tooltip\";\n","type":"registry:ui","target":"@ui/fancy/components/tooltip/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/tooltip?framework=react"}}