{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Inline citation pill that opens an origin-aware source card, anchored to the pill it came from.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ArrowUpRight, Globe } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\n\nconst SPRING_DEFAULT = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\n/** Grace period so the pointer can cross the gap between pill and card. */\nconst CLOSE_DELAY_MS = 120;\nconst CARD_WIDTH_PX = 288;\n\nexport type AICitationProps = {\n  className?: string;\n  /** Excerpt or description shown in the card. */\n  description?: string;\n  /**\n   * The source's mark for the card header — a real isotype or an `<img>`.\n   *\n   * A node rather than a URL so a brand's own SVG can be passed straight in.\n   */\n  favicon?: ReactNode;\n  /** The number or short label shown in the pill. */\n  label: string | number;\n  title: string;\n  url: string;\n};\n\nconst hostOf = (url: string): string => {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n};\n\n/**\n * An inline citation that can be peeked at.\n *\n * The card scales up **from the pill** rather than fading in centred: with a\n * transform origin at the bottom of the marker, the growth points back at what\n * was clicked, so the pointer never loses the thread. It opens on hover and on\n * focus, and closes on Escape — a hover-only preview is unreachable by keyboard\n * and unusable on touch.\n */\nconst AICitation = ({\n  className,\n  description,\n  favicon,\n  label,\n  title,\n  url,\n}: AICitationProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isOpen, setIsOpen] = useState(false);\n  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const open = () => {\n    if (closeTimeoutRef.current) {\n      clearTimeout(closeTimeoutRef.current);\n      closeTimeoutRef.current = null;\n    }\n    setIsOpen(true);\n  };\n\n  const scheduleClose = () => {\n    closeTimeoutRef.current = setTimeout(\n      () => setIsOpen(false),\n      CLOSE_DELAY_MS\n    );\n  };\n\n  useEffect(\n    () => () => {\n      if (closeTimeoutRef.current) {\n        clearTimeout(closeTimeoutRef.current);\n      }\n    },\n    []\n  );\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    const handle = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setIsOpen(false);\n      }\n    };\n    window.addEventListener(\"keydown\", handle);\n    return () => window.removeEventListener(\"keydown\", handle);\n  }, [isOpen]);\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: the wrapper only tracks hover and focus to reveal a preview of the link it contains; the link itself is the interactive element and works without it\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: same — progressive disclosure around a real anchor, not an interactive span\n    <span\n      className={cn(\"relative inline-block align-super\", className)}\n      onBlur={scheduleClose}\n      onFocus={open}\n      onMouseEnter={open}\n      onMouseLeave={scheduleClose}\n    >\n      <a\n        aria-describedby={isOpen ? `${url}-card` : undefined}\n        className=\"inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-border bg-muted px-1 font-medium text-[10px] text-muted-foreground no-underline transition-colors hover:border-foreground/30 hover:text-foreground\"\n        href={url}\n        rel=\"noopener noreferrer\"\n        target=\"_blank\"\n      >\n        {label}\n      </a>\n\n      <AnimatePresence>\n        {isOpen ? (\n          <motion.span\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            className=\"absolute bottom-full left-1/2 z-50 mb-1.5 block rounded-xl border border-border bg-background p-3 text-left align-baseline shadow-lg\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, scale: 0.94, y: 4 }\n            }\n            id={`${url}-card`}\n            initial={\n              shouldReduceMotion\n                ? { opacity: 1, scale: 1, y: 0 }\n                : { opacity: 0, scale: 0.94, y: 4 }\n            }\n            style={{\n              // Origin at the bottom centre — where the pill is — so the card\n              // grows out of the marker instead of appearing over it.\n              transformOrigin: \"bottom center\",\n              translateX: \"-50%\",\n              width: CARD_WIDTH_PX,\n            }}\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n          >\n            <span className=\"mb-1 flex items-center gap-1.5\">\n              <span className=\"flex size-3.5 shrink-0 items-center justify-center overflow-hidden rounded-sm text-muted-foreground *:size-full *:fill-foreground *:object-cover\">\n                {favicon ?? <Globe aria-hidden=\"true\" size={11} />}\n              </span>\n              <span className=\"truncate text-muted-foreground text-xs\">\n                {hostOf(url)}\n              </span>\n              <ArrowUpRight\n                aria-hidden=\"true\"\n                className=\"ml-auto text-muted-foreground\"\n                size={12}\n              />\n            </span>\n\n            <span className=\"block font-medium text-foreground text-sm leading-snug\">\n              {title}\n            </span>\n\n            {description ? (\n              <span className=\"mt-1 block text-muted-foreground text-xs leading-relaxed\">\n                {description}\n              </span>\n            ) : null}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n};\n\nexport default AICitation;\n","path":"index.tsx","target":"components/smoothui/ai-citation/index.tsx","type":"registry:ui"}],"name":"ai-citation","registryDependencies":[],"title":"Ai Citation","type":"registry:ui"}