Sources
The grouped sources behind an answer: a collapsible whose trigger reads the web original's Used N sources and whose rows compose the item primitive — title AND domain (UC-CHAT-05 AC-1), an optional caller-supplied favicon in the icon slot (RN has no favicon service without a new dep; the web row is a book icon anyway), and the platform link handler behind the scheme allowlist where the web anchor had target=_blank (AC-3). The PRD verdict is a collapsible list, not a table — there is no SourcesTable upstream.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/sources.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/sources.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/collapsible.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/item.json |
https://rnraielements.com/r/nativewind/url.json |
https://reactnativereusables.com/r/uniwind/collapsible.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/item.json |
https://rnraielements.com/r/uniwind/url.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/registry/nativewind/components/ui/item';
import { Text } from '@/registry/nativewind/components/ui/text';
import { hostnameOf, isSafeOpenUrl } from '@/registry/nativewind/lib/url';
import { cn } from '@/registry/nativewind/lib/utils';
import { BookIcon, ChevronDownIcon, type LucideIcon } from 'lucide-react-native';
import * as React from 'react';
import { Image, Linking, Platform, View, type ViewProps } from 'react-native';
import Animated, {
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { usedSourcesLabel, type SourceLike } from './sources.logic';
/**
* Sources — the grouped sources behind an answer (UC web original: a Collapsible whose
* trigger reads "Used N sources" and whose rows are anchors).
*
* THE PRD VERDICT IS A COLLAPSIBLE LIST OF SOURCE ROWS — "RNR collapsible, separator,
* and text" — NOT a table, and the current web original agrees (Sources/SourcesTrigger/
* SourcesContent/Source; there is no SourcesTable upstream). Rows compose the `item`
* primitive, the house list row (queue.tsx precedent), which is where the separator and
* text rules live.
*
* UC-CHAT-05, AC BY AC:
* - AC-1: expanding the list shows each source's TITLE AND DOMAIN — so each row carries
* a second line with the hostname (lib/url's no-throw extractor), which the web gets
* from the same value its favicon pipeline would use. No favicon fetch ships: RN has
* no favicon service without a new dependency, and the web row's icon is a book, not
* a favicon. A caller-supplied `faviconUri` (SourceData's own optional field) renders
* in the icon slot instead — declared, opt-in.
* - AC-2 belongs to inline-citation (press, not hover) — this list is the detail
* surface the citation chip's callback composes into a popover/sheet.
* - AC-3: the web row is an anchor (target="_blank" rel="noreferrer"). RN's platform
* link handler is Linking.openURL, guarded by lib/url's http/https allowlist — the
* data-schema doc's security-review surface. `rel` has no RN equivalent; the
* allowlist is the whole guard. A caller-supplied `onOpen` REPLACES the hop —
* re-apply `isSafeOpenUrl` in your handler if you open URLs yourself, or you
* reintroduce the javascript:/file: footgun the default path guards against.
* (speech-input precedent: caller-supplied contract).
*
* The web's data-[state] slide on the content is the collapsible's own motion on
* native; the chevron carries the house rotation (queue/task precedent,
* ReduceMotion.System).
*/
type SourcesContextValue = { open: boolean };
const SourcesContext = React.createContext<SourcesContextValue | null>(null);
function useSources() {
const ctx = React.useContext(SourcesContext);
if (!ctx) throw new Error('Sources sub-components must be used within <Sources>');
return ctx;
}
type SourcesProps = Omit<ViewProps, 'children'> & {
/** The web Collapsible's default: closed. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function Sources({ defaultOpen = false, onOpenChange, className, children, ...props }: SourcesProps) {
const [open, setOpen] = React.useState(defaultOpen);
function handleOpenChange(next: boolean) {
setOpen(next);
onOpenChange?.(next);
}
return (
<SourcesContext.Provider value={{ open }}>
<Collapsible open={open} onOpenChange={handleOpenChange} className={className} {...props}>
{children}
</Collapsible>
</SourcesContext.Provider>
);
}
type SourcesTriggerProps = {
/** The count the web trigger reads: "Used N sources". */
count: number;
/** Overrides the default label, exactly as the web trigger's children do. */
children?: React.ReactNode;
className?: string;
};
function SourcesTrigger({ count, children, className }: SourcesTriggerProps) {
const { open } = useSources();
const rotation = useSharedValue(open ? 1 : 0);
React.useEffect(() => {
rotation.value = withTiming(open ? 1 : 0, {
duration: open ? 250 : 200,
reduceMotion: ReduceMotion.System,
});
}, [open, rotation]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value * 180}deg` }],
}));
return (
<CollapsibleTrigger
className={cn(
'flex-row items-center gap-2 rounded-md py-1 active:bg-accent',
Platform.select({ web: 'transition-colors hover:bg-accent/50' }),
className,
)}
>
{children ?? (
<Text className="flex-1 text-left text-sm font-medium text-primary">
{usedSourcesLabel(count)}
</Text>
)}
<Animated.View style={style}>
<Icon as={ChevronDownIcon} size={14} className="text-primary" />
</Animated.View>
</CollapsibleTrigger>
);
}
function SourcesContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<CollapsibleContent className={cn('mt-3 gap-2', className)} {...props}>
{children}
</CollapsibleContent>
);
}
/** The row's leading mark: the caller's favicon when supplied, the web's book otherwise. */
function SourceMedia({ source }: { source: SourceLike }) {
if (source.faviconUri) {
// Numeric style sizing alongside the class (persona.tsx's hardening rule): an
// inline image whose one size class drops on a stale stylesheet rebuild would
// otherwise lay out at its intrinsic pixel size mid-transcript.
return (
<Image
source={{ uri: source.faviconUri }}
resizeMode="contain"
className="size-4 rounded-sm bg-muted/50"
style={{ width: 16, height: 16 }}
/>
);
}
return <Icon as={BookIcon} size={16} className="text-muted-foreground" />;
}
type SourceProps = {
/** Where the row opens — the platform link handler, behind the scheme allowlist. */
href: string;
/** The source title (the web anchor's visible text). */
title: string;
/** Opt-in favicon for the icon slot. RN has no favicon service without a new dep. */
faviconUri?: string;
/** Replaces the default Linking hop (caller-supplied contract, speech-input precedent). */
onOpen?: (href: string) => void;
/** Fires when the allowlist refuses the URL or the platform open fails. Never silent. */
onOpenError?: (error: Error) => void;
className?: string;
};
function Source({ href, title, faviconUri, onOpen, onOpenError, className }: SourceProps) {
const domain = hostnameOf(href);
function handlePress() {
if (onOpen) {
onOpen(href);
return;
}
if (!isSafeOpenUrl(href)) {
onOpenError?.(new Error(`Refused to open a non-http(s) URL: ${href}`));
return;
}
Linking.openURL(href).catch((error: Error) => onOpenError?.(error));
}
return (
<Item
onPress={handlePress}
// The web row is an anchor — announce "link", not "button" (Item's default).
accessibilityRole="link"
accessibilityLabel={`${title}, ${domain}`}
className={cn('py-2', className)}
>
<ItemMedia>
<SourceMedia source={{ title, url: href, faviconUri }} />
</ItemMedia>
<ItemContent>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{domain}</ItemDescription>
</ItemContent>
</Item>
);
}
export { Sources, SourcesContent, SourcesTrigger, Source, useSources };
export type { SourceLike };
/**
* Sources — pure logic. Zero react-native imports, so the Vitest tier owns it (see the
* header of lib/reasoning-lifecycle.ts for why component files themselves cannot load
* under Node).
*
* SourceData is the data-schema doc's own shape, declared locally per message.tsx
* precedent — the registry is stateless about the model and takes no runtime dependency
* on any SDK. `snippet` and `faviconUri` are optional affordances: the web original
* renders neither (an anchor with a book icon and the title), so the component ignores
* them unless the caller passes faviconUri to the row explicitly.
*
* The trigger label is the web original's template, byte-verbatim — including the
* plural on `1` ("Used 1 sources"), which the web ships and its test corpus asserts.
* Parity here is deliberate; localizing the plural is a consumer-level edit.
*/
/** The data-schema doc's source shape — what `sources` and `inline-citation` exchange. */
export type SourceData = {
id: string;
title: string;
url: string;
snippet?: string;
faviconUri?: string;
};
/** The one field the row composition asks for, whatever else the caller carries. */
export type SourceLike = Pick<SourceData, 'title' | 'url'> & Partial<Pick<SourceData, 'faviconUri'>>;
/** The web original's trigger text, verbatim: `Used {count} sources`. */
export function usedSourcesLabel(count: number): string {
return `Used ${count} sources`;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| defaultOpen | boolean | undefined | false | The web Collapsible's default: closed. |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| children | React.ReactNode | — | — |
Theming
Themed via bg-accent, text-primary, bg-muted, text-muted-foreground.
Usage
() => (
<Sources>
<SourcesTrigger />
<SourcesContent>
<Source
href="https://raw.githubusercontent.com/hackerpug-ai/rnr-ai-elements/v0.2.0/public/r/uniwind/conversation.json"
title="rnr-ai-elements registry — conversation.json"
/>
<Source
href="https://reactnativereusables.com/r/uniwind/text.json"
title="React Native Reusables — text"
/>
</SourcesContent>
</Sources>
)