{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"citation","type":"registry:component","title":"Citation","description":"An inline numbered source that previews the site, title and snippet on hover or focus.","author":"Hairline UI","dependencies":["lucide-react"],"files":[{"path":"registry/citation.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Globe } from \"lucide-react\";\n\n/* ─────────────────────────────────────────────────────────\n * CITATION: an inline [1] that previews its source\n *\n *   closed       a small numbered marker in the sentence\n *   open         hover or focus: the card rises out of a light blur\n *                with the site, title and snippet, and sinks back\n *                when you leave\n *   unavailable  the source couldn't be fetched\n *\n * The marker is the link itself, so clicking opens the source.\n * The card is a tooltip describing it; it flips above near the\n * bottom of the screen and shifts to stay inside the viewport.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CitationSource {\n  title: string;\n  url: string;\n  snippet?: string;\n  /** A favicon, e.g. <img src=\"…\" alt=\"\" />. */\n  icon?: React.ReactNode;\n}\n\nexport interface CitationProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  index: number;\n  source?: CitationSource;\n  status?: \"available\" | \"unavailable\";\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst CARD_WIDTH = 288;\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction hostname(url: string) {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\nexport function Citation({ index, source, status = \"available\", className = \"\", ...props }: CitationProps) {\n  const reduced = useReducedMotion();\n  const [open, setOpen] = React.useState(false);\n  // The card stays mounted while it sinks away after closing.\n  const [mounted, setMounted] = React.useState(false);\n  if (open && !mounted) setMounted(true);\n  const [place, setPlace] = React.useState<{ above: boolean; shift: number }>({ above: false, shift: 0 });\n  const triggerRef = React.useRef<HTMLAnchorElement>(null);\n  const cardRef = React.useRef<HTMLSpanElement>(null);\n  const timer = React.useRef<number | undefined>(undefined);\n  const id = React.useId();\n  const unavailable = status === \"unavailable\" || !source;\n\n  const show = (delay: number) => {\n    window.clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setOpen(true), delay);\n  };\n  const hide = (delay: number) => {\n    window.clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setOpen(false), delay);\n  };\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Keep the card on screen: flip above near the bottom, shift left near the right edge.\n  React.useLayoutEffect(() => {\n    if (!open || !triggerRef.current || !cardRef.current) return;\n    const t = triggerRef.current.getBoundingClientRect();\n    const h = cardRef.current.offsetHeight;\n    const overflowRight = t.left + CARD_WIDTH - (window.innerWidth - 12);\n    setPlace({ above: t.bottom + h + 12 > window.innerHeight && t.top > h + 12, shift: Math.max(0, overflowRight) });\n  }, [open]);\n\n  // Rise in from the side it opens on; sink back the same way, then unmount.\n  React.useLayoutEffect(() => {\n    const el = cardRef.current;\n    if (!el) return;\n    const from = `translateY(${place.above ? 4 : -4}px)`;\n    if (open) {\n      if (reduced) return;\n      const enter = el.animate(\n        [\n          { opacity: 0, transform: from, filter: \"blur(2px)\" },\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        ],\n        { duration: 220, easing: MORPH }\n      );\n      return () => enter.cancel();\n    }\n    if (reduced) {\n      setMounted(false);\n      return;\n    }\n    const leave = el.animate([{ opacity: 1, transform: \"none\" }, { opacity: 0, transform: from }], { duration: 140, easing: \"ease-out\", fill: \"forwards\" });\n    leave.onfinish = () => setMounted(false);\n    return () => leave.cancel();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, mounted]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open]);\n\n  return (\n    <span className={`relative inline-block align-baseline ${className}`} onMouseEnter={() => show(120)} onMouseLeave={() => hide(150)} {...props}>\n      <a\n        ref={triggerRef}\n        href={source?.url}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        aria-describedby={open ? id : undefined}\n        aria-label={`Source ${index}${source ? `: ${source.title}` : \"\"}`}\n        onFocus={() => show(0)}\n        onBlur={() => hide(0)}\n        className={`mx-0.5 inline-flex h-[1.2em] min-w-[1.2em] -translate-y-[0.12em] items-center justify-center rounded-[5px] px-1 font-mono text-[10.5px] leading-none no-underline transition-colors ${FOCUS} ${\n          unavailable ? \"bg-muted text-muted-foreground/60 line-through\" : open ? \"bg-foreground text-background\" : \"bg-muted text-muted-foreground hover:bg-accent hover:text-foreground\"\n        }`}\n      >\n        {index}\n      </a>\n\n      {mounted && (\n        <span\n          ref={cardRef}\n          id={id}\n          role=\"tooltip\"\n          aria-hidden={!open || undefined}\n          className={`absolute z-50 block rounded-xl border border-border bg-background p-3 text-left ${place.above ? \"bottom-full mb-1.5\" : \"top-full mt-1.5\"} ${\n            open ? \"\" : \"pointer-events-none\"\n          }`}\n          // Shifted with left, not transform, so the entrance animation can't undo it.\n          style={{ width: CARD_WIDTH, left: -place.shift }}\n        >\n          {unavailable ? (\n            <span className=\"block text-[12.5px] text-muted-foreground\">This source couldn’t be loaded.</span>\n          ) : (\n            <>\n              <span className=\"flex items-center gap-1.5 text-[11.5px] text-muted-foreground\">\n                <span aria-hidden=\"true\" className=\"flex size-3.5 shrink-0 items-center justify-center overflow-hidden rounded-full\">\n                  {source.icon ?? <Globe className=\"size-3.5\" />}\n                </span>\n                <span className=\"truncate\">{hostname(source.url)}</span>\n              </span>\n              <span className=\"mt-1.5 line-clamp-2 block text-[13px] font-medium leading-snug text-foreground\">{source.title}</span>\n              {source.snippet && <span className=\"mt-1 line-clamp-3 block text-[12px] leading-relaxed text-muted-foreground\">{source.snippet}</span>}\n            </>\n          )}\n        </span>\n      )}\n    </span>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/citation"}