Inline Citation
The reference chip inline with the claim it backs, rendered as nested Text so it sits inside a text run exactly as the web nests spans. The web's hover card is dead on touch: the chip is pressable and reports onSelect (a callback, never navigation); the detail surface — a popover or sheet hosting this registry's Sources — is the caller's composition. The card/carousel parts are dropped with the hover card. Chip label is the web's verbatim hostname +N, computed no-throw (the web's bare new URL throws on a malformed first source).
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/inline-citation.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/inline-citation.jsonDependencies
No required npm packages — this item installs through the registry dependencies below.
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/url.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/url.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import * as React from 'react';
import { Platform } from 'react-native';
import { citationBadgeLabel } from './inline-citation.logic';
/**
* InlineCitation — the numbered reference inline with the claim it backs (UC web
* original: a span run inside the assistant text with a hover-card badge).
*
* IT RENDERS INLINE, INSIDE TEXT. The web nests <span>s; React Native nests <Text>s —
* the vendored Text (an RN Text) composes inside a Text run exactly the same way, so
* the port keeps the web's shape: InlineCitation (root) wraps InlineCitationText (the
* cited words) plus the chip, all Text nodes in one run. No View may enter this tree —
* a View inside a Text run is the one nesting RN forbids.
*
* THE WEB'S HOVER CARD IS DEAD ON TOUCH, and the PRD verdict already ruled the
* replacement: "a pressable inline chip that opens a popover". The popover is the
* CONSUMER's composition — the chip reports `onSelect` (task-brief contract: a
* callback, NOT navigation) and the caller opens the detail surface it owns, typically
* this registry's Popover or Sheet hosting this registry's Sources list. That also
* settles the inline-layout constraint: a portal trigger cannot live inside a Text
* run, but a Text chip with a callback can, and the detail opens OUTSIDE the run.
*
* DROPPED WITH THE HOVER CARD, declared: InlineCitationCard/CardBody (HoverCard),
* the embla Carousel and its Prev/Next/Index chrome, and InlineCitationSource/Quote
* (only reachable from the card body). Their data surfaces through Sources composed in
* the consumer's popover instead.
*
* The chip keeps the web trigger's Badge look (secondary, rounded-full) rendered in
* Text — padding, radius and background all work on a nested Text on both platforms.
* Web's group-hover:bg-accent on the text is a hover surface: dead under a thumb, and
* the chip's active: twin is its replacement.
*/
/**
* The cited words. Web: a span with a hover tint. RN: the plain run — children is a
* string, per the house rule that text-bearing parts type their children narrowly
* (MessageText precedent).
*/
function InlineCitationText({ children, className, ...props }: { children: string; className?: string }) {
return (
<Text className={cn('text-foreground', className)} {...props}>
{children}
</Text>
);
}
type InlineCitationProps = {
/** Overrides the default run (web: children on the root span). */
children?: React.ReactNode;
className?: string;
};
function InlineCitation({ children, className, ...props }: InlineCitationProps) {
return (
<Text className={cn('text-foreground', className)} {...props}>
{children}
</Text>
);
}
type InlineCitationChipProps = {
/** The source URLs behind the claim. The first drives the hostname, the rest the +N. */
sources: readonly string[];
/**
* Fired on press with the first source URL (undefined when the list is empty).
* Opening the detail surface is the caller's job — this is a callback, not navigation.
*/
onSelect?: (url: string | undefined) => void;
/** Overrides the computed label (web: children on the Badge). */
children?: React.ReactNode;
className?: string;
};
/**
* The pressable chip — the web's InlineCitationCardTrigger, renamed: there is no card
* to trigger (HoverCard does not exist on touch), and the honest name is what it is.
* The label is the web's, byte-for-byte, via citationBadgeLabel.
*/
function InlineCitationChip({ sources, onSelect, children, className }: InlineCitationChipProps) {
const label = citationBadgeLabel(sources);
return (
<Text
onPress={onSelect ? () => onSelect(sources[0]) : undefined}
accessibilityRole="button"
accessibilityLabel={`Citation: ${label}`}
className={cn(
'overflow-hidden rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground',
'active:bg-accent',
Platform.select({ web: 'transition-colors hover:bg-secondary/80' }),
className,
)}
>
{children ?? label}
</Text>
);
}
export { InlineCitation, InlineCitationChip, InlineCitationText };
/**
* InlineCitation — pure logic. Zero react-native imports, so the Vitest tier owns it.
*
* The chip label is the web original's InlineCitationCardTrigger composition, verbatim:
* the FIRST source's hostname, then " +N" when more than one source backs the claim
* (`{hostname}{' '}{sources.length > 1 && `+${length - 1}`}`), and the string
* "unknown" when the list is empty. The one behavioral fix on the port: the web calls
* `new URL(sources[0]).hostname` bare, which THROWS on a malformed first entry and
* takes the whole transcript down. Here a malformed entry degrades through
* lib/url's no-throw hostnameOf instead — a bad citation is a rendering problem, never
* a crash.
*/
import { hostnameOf } from '@/registry/nativewind/lib/url';
/**
* The chip's text. Examples: `example.com`, `example.com +2` (three sources),
* `unknown` (none), `unknown +2` (a malformed first entry among three — no-throw,
* unlike the web original). A missing list (a part still streaming in) is `unknown`.
*/
export function citationBadgeLabel(urls: readonly string[] | undefined | null): string {
const first = urls?.[0];
if (first === undefined) return 'unknown';
const hostname = hostnameOf(first);
const rest = (urls?.length ?? 0) - 1;
return rest > 0 ? `${hostname} +${rest}` : hostname;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | — | Overrides the default run (web: children on the root span). |
| className | string | undefined | — | — |
Theming
Themed via bg-accent, text-foreground, bg-secondary, text-secondary-foreground.
Usage
() => (
<InlineCitationChip
sources={[
'https://raw.githubusercontent.com/hackerpug-ai/rnr-ai-elements/v0.2.0/public/r/uniwind/conversation.json',
'https://reactnativereusables.com/r/uniwind/text.json',
]}
/>
)