{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-citation","type":"registry:ui","title":"InlineCitation","description":"A numbered reference that sits inside a sentence and reveals the document behind it — title, domain and snippet — in a floating card on hover or focus","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/sources.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/inline-citation/InlineCitation.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from \"react\";\nimport type {\n\tAnchorHTMLAttributes,\n\tButtonHTMLAttributes,\n\tFocusEvent,\n\tMouseEvent,\n\tReactNode,\n} from \"react\";\nimport { cn } from \"../../utils\";\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 { useFancyId } from \"../../internals/use-id\";\nimport { useFloat } from \"../../internals/use-float\";\nimport type { FloatRect } from \"../../internals/float\";\nimport { sanitizeHref } from \"../../internals/markdown\";\nimport type { SourceData } from \"../../internals/ai-types\";\nimport { SourceCard } from \"../sources/SourceCard\";\nimport \"./inline-citation.css\";\n\n/**\n * Props for InlineCitation\n */\nexport interface InlineCitationProps extends Omit<\n\tAnchorHTMLAttributes<HTMLElement>,\n\t\"className\" | \"href\" | \"children\"\n> {\n\t/** The document being cited. Its title, domain and snippet fill the preview. */\n\tsource: SourceData;\n\t/** The reference number shown in the marker, e.g. `3` renders `[3]` */\n\tindex: number;\n\t/** Link target. Defaults to `source.url`; pass `\"\"` to render an unlinked marker. */\n\thref?: string;\n\t/** Replaces the default preview card body. Receives the source. */\n\tpreview?: (source: SourceData) => ReactNode;\n\t/** Called each time the preview is shown, once per appearance */\n\tonOpen?: () => void;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n/**\n * A model-provided url made safe for an anchor's `href`, or `\"\"` when the\n * marker must not render as a link at all. The same normalisation the other\n * source surfaces in this package run (`SourceCard`, `WebSearch`): a\n * disallowed scheme is rejected outright by the shared markdown-link\n * sanitizer, and a scheme-less host is promoted to `https://` first — left\n * alone, an `href` of \"docs.example.dev/guide\" resolves against the app's own\n * origin and lands on a page that was never there. A genuine relative path\n * (\"/local/guide\") has nowhere else to resolve against and is left alone.\n */\nfunction resolveHref(raw: string): string {\n\tif (raw === \"\") return \"\";\n\tconst hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith(\"//\");\n\tconst host = raw.split(/[/?#]/, 1)[0] ?? \"\";\n\tconst looksHostLike = !hasScheme && !raw.startsWith(\"/\") && host.includes(\".\");\n\treturn sanitizeHref(looksHostLike ? `https://${raw}` : raw) ?? \"\";\n}\n\n/** How long the pointer must rest on the marker before the card appears. */\nconst OPEN_DELAY_MS = 150;\n/** How long the card survives after the pointer leaves, so it can be walked into. */\nconst CLOSE_GRACE_MS = 250;\n\n/**\n * InlineCitation\n */\nexport const InlineCitation = forwardRef<HTMLElement, InlineCitationProps>(function InlineCitation(\n\t{\n\t\tsource,\n\t\tindex,\n\t\thref,\n\t\tpreview,\n\t\tonOpen,\n\t\tclassName,\n\t\tonMouseEnter,\n\t\tonMouseLeave,\n\t\tonFocus,\n\t\tonBlur,\n\t\tonClick,\n\t\t...rest\n\t},\n\tforwardedRef\n) {\n\tconst uid = useFancyId();\n\tconst previewId = `${uid}-preview`;\n\n\t// An explicit `\"\"` is the opt-out, which is why this is `??` and not `||`:\n\t// only an omitted prop falls through to the source's own URL. Whatever it\n\t// ends up being clears the same scheme check every link in this family runs\n\t// through, since a url on a source is as model-supplied as the prose around it.\n\tconst resolvedHref = resolveHref(href ?? source.url ?? \"\");\n\tconst isLink = resolvedHref !== \"\";\n\n\tconst [open, setOpen] = useState(false);\n\t// The synchronous mirror of `open`. The Svelte source reads and writes its\n\t// `$state` in the same breath as the timer bookkeeping; a React handler\n\t// closes over its render's `open` instead, and a stale read there is a second\n\t// `onOpen` for a card that never left the screen.\n\tconst openRef = useRef(false);\n\t// Plain refs: the timers must not wake anything that writes them.\n\tconst openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\tconst closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n\tconst emitOpen = useEventCallback(onOpen);\n\n\tconst cancelOpen = useCallback(() => {\n\t\tif (openTimer.current === undefined) return;\n\t\tclearTimeout(openTimer.current);\n\t\topenTimer.current = undefined;\n\t}, []);\n\n\tconst cancelClose = useCallback(() => {\n\t\tif (closeTimer.current === undefined) return;\n\t\tclearTimeout(closeTimer.current);\n\t\tcloseTimer.current = undefined;\n\t}, []);\n\n\tconst show = useCallback(() => {\n\t\tcancelOpen();\n\t\tcancelClose();\n\t\t// `onOpen` reports appearances, not intentions: a card already on screen\n\t\t// has nothing new to announce.\n\t\tif (openRef.current) return;\n\t\topenRef.current = true;\n\t\tsetOpen(true);\n\t\temitOpen();\n\t}, [cancelOpen, cancelClose, emitOpen]);\n\n\tconst hide = useCallback(() => {\n\t\tcancelOpen();\n\t\tcancelClose();\n\t\topenRef.current = false;\n\t\tsetOpen(false);\n\t}, [cancelOpen, cancelClose]);\n\n\tconst scheduleShow = useCallback(() => {\n\t\t// A pointer that comes back during the grace window keeps the card it\n\t\t// already has rather than starting the open delay over.\n\t\tcancelClose();\n\t\tif (openRef.current || openTimer.current !== undefined) return;\n\t\topenTimer.current = setTimeout(() => {\n\t\t\topenTimer.current = undefined;\n\t\t\tshow();\n\t\t}, OPEN_DELAY_MS);\n\t}, [cancelClose, show]);\n\n\tconst scheduleHide = useCallback(() => {\n\t\tcancelOpen();\n\t\tif (!openRef.current || closeTimer.current !== undefined) return;\n\t\tcloseTimer.current = setTimeout(() => {\n\t\t\tcloseTimer.current = undefined;\n\t\t\thide();\n\t\t}, CLOSE_GRACE_MS);\n\t}, [cancelOpen, hide]);\n\n\t// Escape is bound to the window rather than the marker so it also dismisses a\n\t// card that was opened by hover, when nothing on the page holds focus.\n\tuseEffect(() => {\n\t\tif (!open) return;\n\t\tconst onKeydown = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") hide();\n\t\t};\n\t\twindow.addEventListener(\"keydown\", onKeydown);\n\t\treturn () => window.removeEventListener(\"keydown\", onKeydown);\n\t}, [open, hide]);\n\n\t// Reads nothing, so it runs once and its teardown is the unmount cleanup.\n\tuseEffect(\n\t\t() => () => {\n\t\t\tcancelOpen();\n\t\t\tcancelClose();\n\t\t},\n\t\t[cancelOpen, cancelClose]\n\t);\n\n\t// The marker element, both for the caller's ref and for the float's anchor.\n\tconst [markerEl, markerRef] = useElementRef<HTMLElement>();\n\tconst composedMarkerRef = useComposedRefs<HTMLElement>(forwardedRef, markerRef);\n\tconst markerLive = useLiveRef(markerEl);\n\n\t// The anchor is read through a getter so the float re-measures the marker on\n\t// every scroll and resize tick instead of holding a stale element rect.\n\tconst anchor = useCallback(\n\t\t(): FloatRect | null => markerLive.current?.getBoundingClientRect() ?? null,\n\t\t[markerLive]\n\t);\n\t// In the DOM only while it is on screen, so the hook is handed `null` — and\n\t// does nothing at all — for as long as the card is closed.\n\tconst [previewEl, previewRef] = useElementRef<HTMLSpanElement>();\n\tuseFloat(previewEl, { anchor, placement: \"top\", offset: 8 });\n\n\t// The superscript is done in the colocated stylesheet rather than with\n\t// utilities: its font size, line height and lift are one setting, and\n\t// splitting them across two files is how a marker ends up taller than the\n\t// line it sits on.\n\tconst markerClass = cn(\n\t\t\"ft-citation-marker inline-flex cursor-pointer items-center rounded px-[0.2em] font-medium tabular-nums no-underline transition-colors focus-visible:ring-1 focus-visible:outline-none\",\n\t\tclassName\n\t);\n\n\t// The card is a SourceCard, which already carries a border, a surface and its\n\t// own padding. Wrapping that in a second bordered, padded box would draw a card\n\t// inside a card, so the chrome here is only put back for a body we do not\n\t// control.\n\tconst previewClass = cn(\n\t\t\"ft-citation-preview text-popover-foreground z-50 block rounded-lg text-left text-sm shadow-lg\",\n\t\tpreview ? \"border p-3\" : undefined\n\t);\n\n\t// The marker's text is a bare number; on its own it names nothing, so the\n\t// title rides along as the accessible name and the card stays supplementary.\n\tconst markerLabel = `Source ${index}: ${source.title}`;\n\t// A consumer's own description is kept next to the card's id, never replaced by it.\n\tconst describedBy =\n\t\t[rest[\"aria-describedby\"], open ? previewId : undefined].filter(Boolean).join(\" \") || undefined;\n\n\t// The component's own logic runs first, then the consumer's listener, once,\n\t// with the same event.\n\tconst handleMouseEnter = (event: MouseEvent<HTMLElement>) => {\n\t\tscheduleShow();\n\t\tonMouseEnter?.(event);\n\t};\n\tconst handleMouseLeave = (event: MouseEvent<HTMLElement>) => {\n\t\tscheduleHide();\n\t\tonMouseLeave?.(event);\n\t};\n\tconst handleFocus = (event: FocusEvent<HTMLElement>) => {\n\t\tshow();\n\t\tonFocus?.(event);\n\t};\n\tconst handleBlur = (event: FocusEvent<HTMLElement>) => {\n\t\thide();\n\t\tonBlur?.(event);\n\t};\n\tconst handleButtonClick = (event: MouseEvent<HTMLElement>) => {\n\t\tshow();\n\t\tonClick?.(event);\n\t};\n\n\t/*\n\t * The marker and the card sit in one fragment with nothing between them. JSX\n\t * drops a newline between two elements, but it keeps a literal space or a\n\t * `{\" \"}` — and such a text node lands between the marker and whatever the\n\t * sentence does next, which is how `read[3].` becomes `read[3] .` in every\n\t * sentence ending on a citation. The test named for it is the guard.\n\t */\n\treturn (\n\t\t<>\n\t\t\t{isLink ? (\n\t\t\t\t<a\n\t\t\t\t\t{...rest}\n\t\t\t\t\tref={composedMarkerRef}\n\t\t\t\t\thref={resolvedHref}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer nofollow ugc\"\n\t\t\t\t\tclassName={markerClass}\n\t\t\t\t\taria-label={markerLabel}\n\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\tonMouseEnter={handleMouseEnter}\n\t\t\t\t\tonMouseLeave={handleMouseLeave}\n\t\t\t\t\tonFocus={handleFocus}\n\t\t\t\t\tonBlur={handleBlur}\n\t\t\t\t\tonClick={onClick}\n\t\t\t\t>\n\t\t\t\t\t[{index}]\n\t\t\t\t</a>\n\t\t\t) : (\n\t\t\t\t<button\n\t\t\t\t\t{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}\n\t\t\t\t\tref={composedMarkerRef}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName={markerClass}\n\t\t\t\t\taria-label={markerLabel}\n\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\tonMouseEnter={handleMouseEnter}\n\t\t\t\t\tonMouseLeave={handleMouseLeave}\n\t\t\t\t\tonFocus={handleFocus}\n\t\t\t\t\tonBlur={handleBlur}\n\t\t\t\t\tonClick={handleButtonClick}\n\t\t\t\t>\n\t\t\t\t\t[{index}]\n\t\t\t\t</button>\n\t\t\t)}\n\t\t\t{open && (\n\t\t\t\t/*\n\t\t\t\t * Rendered only while it is on screen: a tooltip that lives in the DOM\n\t\t\t\t * permanently is a hidden paragraph every crawler and every screen-reader\n\t\t\t\t * element list has to step over, mid-sentence, once per citation.\n\t\t\t\t */\n\t\t\t\t<span\n\t\t\t\t\tref={previewRef}\n\t\t\t\t\tid={previewId}\n\t\t\t\t\trole=\"tooltip\"\n\t\t\t\t\tclassName={previewClass}\n\t\t\t\t\tonMouseEnter={cancelClose}\n\t\t\t\t\tonMouseLeave={scheduleHide}\n\t\t\t\t>\n\t\t\t\t\t{preview ? (\n\t\t\t\t\t\tpreview(source)\n\t\t\t\t\t) : (\n\t\t\t\t\t\t/*\n\t\t\t\t\t\t * The same card the sources list shows, so a document a reader met in\n\t\t\t\t\t\t * one place is recognisable in the other and there is one set of rules\n\t\t\t\t\t\t * for deriving its host and its monogram — but in its plain, non-anchor\n\t\t\t\t\t\t * shape: this preview is dismissed on blur, so a link inside it is one\n\t\t\t\t\t\t * no keyboard can ever reach. The marker itself is already that link.\n\t\t\t\t\t\t * A consumer's own `preview` body is theirs to compose and is left alone.\n\t\t\t\t\t\t */\n\t\t\t\t\t\t<SourceCard source={source} interactive={false} />\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/inline-citation/InlineCitation.tsx"},{"path":"react/src/components/inline-citation/index.ts","content":"\"use client\";\n\nexport { InlineCitation } from \"./InlineCitation\";\nexport type { InlineCitationProps } from \"./InlineCitation\";\n","type":"registry:ui","target":"@ui/fancy/components/inline-citation/index.ts"},{"path":"react/src/components/inline-citation/inline-citation.css","content":"/*\n * Both surfaces read their variables at the point of use with a fallback\n * rather than declaring them on a root, so a value set anywhere up the tree —\n * a wrapper's `style`, a theme class, `:root` — wins without having to\n * out-specify these rules.\n */\n/*\n * A hand-made superscript, because `vertical-align: super` is not one: it lifts\n * the box without shrinking the line it belongs to, so a marker inheriting the\n * prose line-height stretches the line box it sits in and leaves the paragraph\n * with uneven leading — wider gaps under exactly the lines that carry a\n * citation. Here the box is `0.75em × 1`, comfortably shorter than any line\n * height it lands in, and the lift is a relative offset, which moves the paint\n * without touching layout at all.\n */\n.ft-citation-marker {\n\tposition: relative;\n\ttop: -0.35em;\n\tvertical-align: baseline;\n\tfont-size: 0.75em;\n\tline-height: 1;\n\tcolor: var(--ft-citation-fg, var(--color-primary, currentColor));\n}\n\n/*\n * The pill is mixed from `currentColor`, so retinting the marker retints its\n * hover state with it and the two can never drift apart.\n */\n.ft-citation-marker:hover,\n.ft-citation-marker:focus-visible {\n\tbackground: var(--ft-citation-bg, color-mix(in oklab, currentColor 14%, transparent));\n}\n\n.ft-citation-preview {\n\twidth: var(--ft-citation-preview-width, 16rem);\n\tmax-width: calc(100vw - 1rem);\n\tbackground: var(--ft-citation-preview-bg, var(--color-popover, canvas));\n\tborder-color: var(--ft-citation-preview-border, var(--color-border, currentColor));\n}\n\n/*\n * The entrance lives entirely inside `no-preference`, so reduced motion is not\n * a degraded variant to keep in sync: the card simply appears, already placed.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-citation-preview {\n\t\tanimation: ft-citation-in 140ms cubic-bezier(0.4, 0, 0.2, 1);\n\t}\n}\n\n@keyframes ft-citation-in {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(3px);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: none;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/inline-citation/inline-citation.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/inline-citation?framework=react"}}