{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"citation-popover","type":"registry:ui","title":"Citation Popover","description":"A citation marker that shows the passage it came from — fixed positioning that survives a scrolling answer, touch and keyboard, and an honest state for a number the model invented.","author":"Scrim UI (https://scrimui.dev)","categories":["sources"],"docs":"https://scrimui.dev/components/citation-popover","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/citation-popover/citation-popover.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * A citation marker, and the passage behind it.\n *\n * Small component, four problems in it, and only the first is the one people\n * expect.\n *\n * **Positioning.** The popover cannot live inside the answer's flow: the\n * answer scrolls, and an absolutely-positioned panel inside a scroll\n * container gets clipped by it. So the panel is `position: fixed`, measured\n * from the chip's rect when it opens, and clamped to the viewport — flipping\n * above the chip when there is no room below, and sliding sideways rather\n * than hanging off the edge. Measured on open rather than tracked\n * continuously; a citation popover that follows the text as it scrolls is\n * doing work nobody asked for.\n *\n * **Touch.** Hover does not exist on a phone, and a hover-only affordance is\n * one the reader will never find. So the chip is a real `<button>` that\n * toggles on tap, and hover is an addition for pointers that have it — not\n * the mechanism. `onPointerEnter` rather than `onMouseEnter`, because the\n * synthetic mouse events a tap produces would otherwise open the panel and\n * the tap would immediately close it again.\n *\n * **Keyboard.** Focus opens it, Escape closes it, and Enter jumps to the\n * passage in the document — the same thing a click does. A citation only a\n * mouse can reach is a footnote you have hidden.\n *\n * **The number the model invented.** `passage` is optional because a model\n * asked to cite only what it was given will still, sometimes, write `[7]`\n * when it was given six passages. That case is the reason this component\n * takes a passage rather than a passage id: an unresolved marker renders as\n * struck-through plain text, so the claim survives and the promise of a\n * source does not. Rendering an empty popover instead is how a hallucinated\n * citation gets laundered into a real-looking one.\n *\n * The chip renders the same characters the model wrote. Not a superscript,\n * not an icon: the number is what the reader matches against the passage\n * list, and replacing it with a symbol breaks that match for the sake of\n * looking tidier.\n */\n\nexport type CitationPopoverProps = {\n  /** The number the model wrote — and the passage's own number. */\n  n: number;\n  /**\n   * The passage text. Slice it out of the document by the offsets you carried\n   * through retrieval; do not send it alongside them and hope the two agree.\n   * Undefined means \"no passage with that number\", which is a state, not a\n   * missing prop.\n   */\n  passage?: string;\n  /** Where the passage came from — file name, page, section. */\n  source?: string;\n  /** Cosine similarity, if you want it shown. Useful while tuning the floor. */\n  score?: number;\n  /** Jump to it in the document. Undefined leaves the chip a preview-only\n   *  affordance, which is the right behaviour when there is no document pane\n   *  to jump into. */\n  onJump?: () => void;\n};\n\nexport function CitationPopover({ n, passage, source, score, onJump }: CitationPopoverProps) {\n  const [open, setOpen] = React.useState(false);\n  const [position, setPosition] = React.useState<{ left: number; top: number; above: boolean }>();\n  const chipRef = React.useRef<HTMLButtonElement>(null);\n\n  const resolved = passage !== undefined;\n\n  const place = React.useCallback(() => {\n    const el = chipRef.current;\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    const width = Math.min(340, window.innerWidth - 24);\n    /* Guessing the height before it renders would flip wrongly on a long\n       passage; 240 is measured against the panel's own cap below, so the flip\n       decision is made against the worst case rather than the average. */\n    const below = window.innerHeight - rect.bottom;\n    const above = below < 240 && rect.top > below;\n    setPosition({\n      left: clamp(rect.left + rect.width / 2 - width / 2, 12, window.innerWidth - width - 12),\n      top: above ? rect.top - 8 : rect.bottom + 8,\n      above,\n    });\n  }, []);\n\n  const show = React.useCallback(() => {\n    if (!resolved) return;\n    place();\n    setOpen(true);\n  }, [place, resolved]);\n\n  /* Closing on scroll rather than repositioning: the panel was opened from a\n     rect that is no longer where it was, and following it around a scrolling\n     answer is motion the reader did not ask for. Capture phase, because the\n     scroll that moved the chip is usually an ancestor's, not the window's. */\n  React.useEffect(() => {\n    if (!open) return;\n    const close = () => setOpen(false);\n    window.addEventListener(\"scroll\", close, true);\n    window.addEventListener(\"resize\", close);\n    return () => {\n      window.removeEventListener(\"scroll\", close, true);\n      window.removeEventListener(\"resize\", close);\n    };\n  }, [open]);\n\n  if (!resolved) {\n    return (\n      <span\n        className=\"mx-0.5 align-baseline text-[0.85em] text-zinc-400 line-through dark:text-zinc-600\"\n        title={`The answer cited [${n}], but no passage with that number was retrieved.`}\n      >\n        [{n}]\n      </span>\n    );\n  }\n\n  return (\n    <>\n      <button\n        ref={chipRef}\n        type=\"button\"\n        onClick={() => (onJump ? onJump() : setOpen((v) => !v))}\n        onPointerEnter={(e) => e.pointerType === \"mouse\" && show()}\n        onPointerLeave={(e) => e.pointerType === \"mouse\" && setOpen(false)}\n        onFocus={show}\n        onBlur={() => setOpen(false)}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\" && open) {\n            /* stopPropagation so a dialog or drawer wrapping the answer does\n               not also close on the same key. Dismissing the popover is what\n               the reader meant; closing the thing they were reading is not. */\n            e.stopPropagation();\n            setOpen(false);\n          }\n        }}\n        aria-expanded={open}\n        aria-label={`Source ${n}${onJump ? \" — jump to it in the document\" : \"\"}`}\n        className=\"mx-0.5 inline-flex h-[1.35em] min-w-[1.35em] items-center justify-center rounded-[0.3em] bg-amber-100 px-[0.3em] align-[-0.1em] text-[0.75em] font-medium text-amber-900 tabular-nums transition-colors hover:bg-amber-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-amber-500 dark:bg-amber-400/20 dark:text-amber-200 dark:hover:bg-amber-400/30\"\n      >\n        {n}\n      </button>\n\n      {open && position && (\n        <span\n          role=\"tooltip\"\n          /* pointer-events-none: the panel is a preview, and a preview the\n             pointer can enter is a preview that has to solve the gap between\n             the chip and itself. It cannot be hovered, so there is no gap. */\n          className=\"pointer-events-none fixed z-50 block rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900\"\n          style={{\n            left: position.left,\n            top: position.top,\n            width: \"min(340px, calc(100vw - 24px))\",\n            transform: position.above ? \"translateY(-100%)\" : undefined,\n          }}\n        >\n          <span className=\"mb-1.5 flex items-center justify-between gap-3 text-[10px] font-medium uppercase tracking-wide text-zinc-400\">\n            <span className=\"min-w-0 truncate\">{source ?? `Source ${n}`}</span>\n            {score !== undefined && <span className=\"shrink-0 tabular-nums\">{score.toFixed(3)}</span>}\n          </span>\n          {/* Capped, and scrolling is not an option on a pointer-events:none\n              panel — a passage longer than this is a sign the chunk size is\n              too big, which is a retrieval problem rather than a UI one. */}\n          <span className=\"block max-h-[180px] overflow-hidden text-[12px] leading-5 text-zinc-600 dark:text-zinc-300\">\n            {passage}\n          </span>\n          {onJump && (\n            <span className=\"mt-2 block text-[10px] text-zinc-400\">\n              Click to jump to it in the document\n            </span>\n          )}\n        </span>\n      )}\n    </>\n  );\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), Math.max(min, max));\n}\n","type":"registry:ui","target":"components/ui/citation-popover.tsx"}]}