Attachments
The pending-attachment chips above the composer (UC-CHAT-03 AC-3), in the web original's grid/inline/list variants, with list rows composed on the item primitive. File acquisition does NOT port — the PRD verdict replaces drag-and-drop and the file input with the caller's native pickers and their permission contracts. Remove is always visible (the web's hover-reveal is dead on touch) and DISABLES rather than disappears without onRemove; per-file upload state ('uploading' | 'done' | 'error') is the display seam for the caller-owned lifecycle. The hover preview card and the <video> thumbnail do not ship — hover is unreachable under a thumb and a media dependency for one tile is out of contract.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/attachments.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/attachments.jsonDependencies
No required npm packages — this item installs through the registry dependencies below.
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/empty.json |
https://rnraielements.com/r/nativewind/item.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/empty.json |
https://rnraielements.com/r/uniwind/item.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Button } from '@/registry/nativewind/components/ui/button';
import { Empty, EmptyTitle } from '@/registry/nativewind/components/ui/empty';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Item, ItemActions, ItemContent, ItemMedia } from '@/registry/nativewind/components/ui/item';
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import {
CircleAlertIcon,
FileTextIcon,
ImageIcon,
LoaderCircleIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { Image, Platform, View, type ViewProps } from 'react-native';
import {
attachmentStateLabel,
formatAttachmentSize,
getAttachmentLabel,
getMediaCategory,
type AttachmentData,
type AttachmentMediaCategory,
type AttachmentState,
type AttachmentVariant,
} from './attachments.logic';
/**
* Attachments — the pending-attachment chips above the composer (UC-CHAT-03 AC-3: a
* removable chip per attached file, before sending).
*
* THE CHIP ROW PORTS AT PARITY; FILE ACQUISITION DOES NOT — the PRD verdict
* (native-substitute, pure-rnr) is explicit: drag-and-drop and the file input are
* replaced by the native image and document pickers, which is the CALLER's wiring
* (expo-image-picker / expo-document-picker land in the consumer's app, with their
* permission contracts — none of it ships here). The component renders the list the
* caller owns; there is no picker, no hook, no useAttachments state — queue precedent
* (the component NEVER owns the queue).
*
* Composition is the web original's, flat and context-driven:
* <Attachments variant="…"> <Attachment data={file} onRemove={…}>
* <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove label="…" />
* </Attachment> </Attachments>
* Each sub-part reads the row context, so order is the caller's. Rows (the list
* variant) compose the `item` primitive — the PRD's own reuse ledger ("sources,
* file-tree, attachments, … all render lists of these").
*
* FOUR TOUCH-ADAPTATIONS, each the web original's hover/pointer machinery dying on a
* thumb:
* 1. The remove control is ALWAYS VISIBLE (the web reveals it on group-hover — queue
* and message-actions precedent).
* 2. With no onRemove wired it DISABLES rather than disappears — a control vanishing
* mid-press is the failure queue.tsx's shape prevents (upstream returns null).
* 3. The inline chip ships at h-10, not the web's h-8: the house standard (queue.tsx)
* rejects a sub-40pt visual control, and RNR's size="icon" Button is exactly
* h-10 — the chip is control-height because a compliant remove control lives in it.
* 4. AttachmentHoverCard/Trigger/Content do NOT ship — a hover preview is unreachable
* under a thumb (inline-citation precedent). The row itself carries preview + name;
* the detail surface, where wanted, is the consumer's composition.
*
* THE UPLOAD STATE IS DECLARED, NOT UPSTREAM: 'uploading' | 'done' | 'error' per the
* wave brief, rendered as icon + label + accessibility (never color alone). The
* lifecycle is the caller's; the state is only the display seam. `done` renders no
* extra chrome — the chip is the proof.
*
* The web <video> preview does NOT ship (a media dependency for one thumbnail);
* video chips fall to the category icon like every other non-image type, which is the
* web original's own fallback affordance.
*/
const mediaCategoryIcons: Record<AttachmentMediaCategory, LucideIcon> = {
image: ImageIcon,
video: VideoIcon,
audio: Music2Icon,
document: FileTextIcon,
unknown: PaperclipIcon,
};
type AttachmentsContextValue = { variant: AttachmentVariant };
const AttachmentsContext = React.createContext<AttachmentsContextValue | null>(null);
function useAttachmentsContext(): AttachmentsContextValue {
// The web original defaults a stray part to "grid"; parity, including the leniency.
return React.useContext(AttachmentsContext) ?? { variant: 'grid' };
}
type AttachmentContextValue = {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
state: AttachmentState;
removable: boolean;
remove: () => void;
variant: AttachmentVariant;
};
const AttachmentContext = React.createContext<AttachmentContextValue | null>(null);
function useAttachmentContext(): AttachmentContextValue {
const ctx = React.useContext(AttachmentContext);
if (!ctx) throw new Error('Attachment components must be used within <Attachment>');
return ctx;
}
type AttachmentsProps = Omit<ViewProps, 'children'> & {
/** grid: right-aligned thumbnails · inline: compact chips · list: full-width rows. */
variant?: AttachmentVariant;
children?: React.ReactNode;
};
function Attachments({ variant = 'grid', className, children, ...props }: AttachmentsProps) {
return (
<AttachmentsContext.Provider value={{ variant }}>
<View
className={cn(
'items-start gap-2',
variant === 'list' ? 'flex-col' : 'flex-row flex-wrap',
// The web grid container is ml-auto + w-fit: a right-aligned block. React
// Native has auto margins too, but justify-end right-aligns every WRAPPED
// line, which is what a chip grid in a column host actually needs.
variant === 'grid' && 'justify-end self-end',
className,
)}
{...props}
>
{children}
</View>
</AttachmentsContext.Provider>
);
}
type AttachmentProps = Omit<ViewProps, 'children'> & {
data: AttachmentData;
/** The caller-owned transport lifecycle; defaults to `done` (no extra chrome). */
state?: AttachmentState;
/** Omit it and every remove control on this row DISABLES rather than disappears. */
onRemove?: () => void;
children?: React.ReactNode;
};
function Attachment({ data, state = 'done', onRemove, className, children, ...props }: AttachmentProps) {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data.mimeType);
const removable = typeof onRemove === 'function';
const remove = React.useCallback(() => {
onRemove?.();
}, [onRemove]);
const stateText = attachmentStateLabel(state);
const label = getAttachmentLabel(data);
const contextValue = React.useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, state, removable, remove, variant }),
[data, mediaCategory, state, removable, remove, variant],
);
return (
<AttachmentContext.Provider value={contextValue}>
{variant === 'list' ? (
<Item
variant="outline"
className={cn('w-full rounded-lg p-3', className)}
{...props}
>
{children}
</Item>
) : (
<View
className={cn(
variant === 'grid' && 'size-24 overflow-hidden rounded-lg',
variant === 'inline' &&
'h-10 flex-row items-center gap-1.5 rounded-md border border-border px-1.5',
className,
)}
// Belt-and-braces (persona.tsx hardening rule): if the size class drops
// from a stale stylesheet rebuild, an unsized tile fills all available
// space — the wave-7 avatar balloon, exactly.
style={
variant === 'grid'
? { width: 96, height: 96 }
: // The inline chip needs a BOUNDED width: Yoga gives a flex-1 child
// zero basis inside a shrink-wrapping row (CSS min-content sizing,
// which the web relies on, does not apply), so the filename Text
// would measure zero and vanish. 240 caps the chip; numberOfLines
// truncates the name inside it.
{ maxWidth: 240 }
}
{...props}
>
{children}
</View>
)}
</AttachmentContext.Provider>
);
}
/**
* The media slot: the picked image when there is one, otherwise the category icon —
* with the upload state taking the slot while a file is in flight or failed (an
* uploading image has no meaningful preview yet, and the state is what the user needs
* to see).
*/
function AttachmentPreview({ className }: { className?: string }) {
const { data, mediaCategory, state, variant } = useAttachmentContext();
// The grid tile is the one variant with no text inside. Scope the a11y grouping to
// the PREVIEW leaf — announcing label + upload state — never the tile itself: an
// `accessible` tile flattens the nested remove Button out of the a11y tree, and a
// VoiceOver user could not remove an attachment (queue.tsx's QueueItemIndicator
// established the leaf-scoping pattern for exactly this reason).
const previewAccessibility =
variant === 'grid'
? {
accessible: true,
accessibilityLabel: [getAttachmentLabel(data), attachmentStateLabel(state)]
.filter(Boolean)
.join(', '),
accessibilityRole: 'image' as const,
}
: {};
const wrapperClasses = cn(
'items-center justify-center overflow-hidden',
variant === 'grid' && 'size-full bg-muted',
variant === 'inline' && 'size-5 rounded bg-background',
variant === 'list' && 'size-12 rounded bg-muted',
className,
);
// Belt-and-braces numeric sizing (persona.tsx hardening rule) — the class-based
// sizes are new-file classes the engine has dropped before on stale rebuilds.
const previewSize =
variant === 'inline'
? { width: 20, height: 20 }
: variant === 'list'
? { width: 48, height: 48 }
: undefined;
if (state !== 'done') {
return (
<View className={wrapperClasses} style={previewSize} {...previewAccessibility}>
<Icon
as={state === 'error' ? CircleAlertIcon : LoaderCircleIcon}
size={variant === 'inline' ? 12 : 16}
className={cn(
variant === 'inline' ? 'size-3' : 'size-4',
state === 'error' ? 'text-destructive' : 'text-muted-foreground',
)}
/>
</View>
);
}
if (mediaCategory === 'image' && data.uri) {
// Belt-and-braces numeric sizing (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-composer.
const size = variant === 'inline' ? 20 : variant === 'list' ? 48 : 96;
return (
<View className={wrapperClasses} style={previewSize} {...previewAccessibility}>
<Image
source={{ uri: data.uri }}
resizeMode="cover"
className="size-full"
style={{ width: size, height: size }}
/>
</View>
);
}
// (continued previewAccessibility — applied on the done-state surface below too)
return (
<View className={wrapperClasses} style={previewSize} {...previewAccessibility}>
<Icon
as={mediaCategoryIcons[mediaCategory]}
size={variant === 'inline' ? 12 : 16}
className={cn(variant === 'inline' ? 'size-3' : 'size-4', 'text-muted-foreground')}
/>
</View>
);
}
type AttachmentInfoProps = {
/** The mimeType under the name — the web original's default-off `showMediaType`. */
showMediaType?: boolean;
className?: string;
};
/**
* Name and meta. Renders nothing on grid (a thumbnail needs no caption — the web
* original returns null there too). The meta line carries the mimeType (opt-in) and
* the formatted byte size (when the caller supplied sizeBytes — the data-schema shape
* carries it for exactly this display; the web shows neither).
*/
function AttachmentInfo({ showMediaType = false, className }: AttachmentInfoProps) {
const { data, state, variant } = useAttachmentContext();
if (variant === 'grid') return null;
const label = getAttachmentLabel(data);
const stateText = attachmentStateLabel(state);
const metaParts = [
showMediaType ? data.mimeType : undefined,
formatAttachmentSize(data.sizeBytes),
].filter((part): part is string => Boolean(part));
const name = (
<Text numberOfLines={1} className="text-sm font-medium text-foreground">
{label}
</Text>
);
const meta = metaParts.length > 0 && state === 'done'
? (
<Text numberOfLines={1} className="text-xs text-muted-foreground">
{metaParts.join(' · ')}
</Text>
)
: null;
const stateLine = stateText ? (
<Text
numberOfLines={1}
className={cn('text-xs', state === 'error' ? 'text-destructive' : 'text-muted-foreground')}
>
{stateText}
</Text>
) : null;
if (variant === 'list') {
return (
<ItemContent className={className}>
{name}
{meta}
{stateLine}
</ItemContent>
);
}
return (
<View
className={cn(
// flex-1 only OUTSIDE the inline chip: the className's flexBasis:0 defeats
// the content basis the shrink-wrapping chip needs (see style below).
variant === 'inline' ? 'min-w-0' : 'min-w-0 flex-1',
className,
)}
// In the inline chip the parent shrink-wraps, and Yoga gives a flexBasis-0
// child ZERO width in that context (CSS min-content sizing does not apply) —
// the filename would measure zero and vanish. Content basis + grow lets the
// chip wrap to the name up to its 240 cap, then truncate.
style={
variant === 'inline' ? { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' } : undefined
}
>
{name}
{meta}
{stateLine}
</View>
);
}
type AttachmentRemoveProps = {
/** The accessibility label — the web original's default is "Remove". */
label?: string;
className?: string;
};
/**
* The remove control. ALWAYS VISIBLE, and DISABLED — not absent — when the row has no
* onRemove (upstream hides it; queue.tsx documents why a vanishing control is worse).
* The web's group-hover reveal and backdrop-blur are pointer machinery with no thumb.
*/
function AttachmentRemove({ label = 'Remove', className }: AttachmentRemoveProps) {
const { removable, remove, variant } = useAttachmentContext();
const button = (
<Button
variant="ghost"
size="icon"
disabled={!removable}
onPress={remove}
accessibilityLabel={label}
accessibilityState={{ disabled: !removable }}
// House formula (queue/artifact precedent): RNR's h-10 + hitSlop 2/side = the
// 44pt platform minimum, with no pixel of extra chrome.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn(
'rounded-full',
variant === 'grid' && 'absolute right-2 top-2 bg-background/80',
className,
)}
>
<Icon as={XIcon} size={16} className="text-muted-foreground" />
</Button>
);
if (variant === 'list') {
return <ItemActions>{button}</ItemActions>;
}
return button;
}
type AttachmentEmptyProps = {
/** Overrides the web original's default "No attachments". */
children?: string;
className?: string;
};
/**
* The empty state, composed on the shared `empty` primitive (the PRD's build-once
* reuse ledger names attachments as one of its consumers) — tuned down from the
* full-surface paddings to a composer strip.
*/
function AttachmentEmpty({ children, className }: AttachmentEmptyProps) {
return (
<Empty className={cn('flex-none gap-1 p-4', className)}>
<EmptyTitle className="text-sm font-normal text-muted-foreground">
{children ?? 'No attachments'}
</EmptyTitle>
</Empty>
);
}
export {
Attachment,
AttachmentEmpty,
AttachmentInfo,
AttachmentPreview,
AttachmentRemove,
Attachments,
useAttachmentContext,
useAttachmentsContext,
};
export type { AttachmentData, AttachmentState, AttachmentVariant };
export type { AttachmentMediaCategory };
/**
* Attachments — pure logic. Zero react-native imports, so the Vitest tier owns it (see
* the header of sources.logic.ts for why component files themselves cannot load under
* Node).
*
* AttachmentData is the data-schema doc's own shape (11-technical-requirements/
* 03-data-schema.md), declared locally per message.tsx precedent — the registry is
* stateless about the model and takes no runtime dependency on any SDK. It is
* deliberately narrower than the web original's FileUIPart | SourceDocumentUIPart
* union: `sources` are their own component in this registry, so the source-document
* variant (and upstream's `source` media category, which exists only to serve it) has
* no carrier here.
*
* The per-file upload state is an RN ADDITION over the web original, declared: a
* desktop drag-and-drop lands instantly, but a phone upload takes visible seconds, and
* a chip that looks complete while its bytes are still in flight is lying. UC-CHAT-03
* AC-3 makes the chip removable "before sending", which is exactly the uploading window.
* The caller owns the lifecycle (queue precedent — caller state, caller mutations);
* this is only the display seam for it.
*/
/** The data-schema doc's attachment shape — what prompt-input and attachments exchange. */
export type AttachmentData = {
id: string;
name: string;
mimeType: string;
uri?: string;
sizeBytes?: number;
};
/** The caller-owned transport lifecycle of one file, rendered by the chip. */
export type AttachmentState = 'uploading' | 'done' | 'error';
/** Upstream's media categories minus `source` — no source-document variant exists here. */
export type AttachmentMediaCategory = 'image' | 'video' | 'audio' | 'document' | 'unknown';
export const ATTACHMENT_VARIANTS = ['grid', 'inline', 'list'] as const;
export type AttachmentVariant = (typeof ATTACHMENT_VARIANTS)[number];
/**
* The preview affordance for a mimeType — the data-schema doc names this exact
* function, pure and Vitest-testable. Same prefix ladder as the web original's
* getMediaCategory, reading `mimeType` directly instead of a part object.
* No-throw: an empty or unrecognized type degrades to `unknown` (the paperclip),
* which is the web original's own fallback.
*/
export function getMediaCategory(mimeType: string): AttachmentMediaCategory {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('application/') || mimeType.startsWith('text/')) return 'document';
return 'unknown';
}
/**
* The chip's display label — the web original's getAttachmentLabel with the field
* renames the data-schema shape dictates (`name` for `filename`). A file with no name
* falls back to "Image" when it is one, "Attachment" otherwise, byte-verbatim.
*/
export function getAttachmentLabel(
data: Pick<AttachmentData, 'name' | 'mimeType'>,
): string {
if (data.name) return data.name;
return getMediaCategory(data.mimeType) === 'image' ? 'Image' : 'Attachment';
}
/**
* The one-line state label, or undefined for `done` — a finished upload needs no
* announcement; the chip itself is the proof. Uploading and failing DO (color is never
* the sole channel — lib/status.ts rule 1).
*/
export function attachmentStateLabel(state: AttachmentState): string | undefined {
if (state === 'uploading') return 'Uploading';
if (state === 'error') return 'Upload failed';
return undefined;
}
/**
* A human byte size for the chip's meta line — the data-schema shape carries
* `sizeBytes` and a file chip without its size is opaque on a phone. 1024-based, one
* decimal under 10 of a unit, none above. Undefined when the caller supplied no size:
* a missing measurement renders as nothing, never as "0 B".
*/
export function formatAttachmentSize(sizeBytes: number | undefined): string | undefined {
if (sizeBytes === undefined || Number.isNaN(sizeBytes)) return undefined;
if (sizeBytes < 1024) return `${Math.round(sizeBytes)} B`;
if (sizeBytes < 1024 ** 2) {
const kb = sizeBytes / 1024;
return `${kb < 10 ? Math.round(kb * 10) / 10 : Math.round(kb)} KB`;
}
const mb = sizeBytes / 1024 ** 2;
return `${mb < 10 ? Math.round(mb * 10) / 10 : Math.round(mb)} MB`;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| variant | "grid" | "inline" | "list" | undefined | 'grid' | grid: right-aligned thumbnails · inline: compact chips · list: full-width rows. |
| children | React.ReactNode | — | — |
Theming
Themed via border-border, bg-muted, bg-background, text-destructive, text-muted-foreground, text-foreground.
Usage
() => (
<Attachments variant="list">
<Attachment
data={{
id: 'att-1',
name: 'registry-manifest.pdf',
mimeType: 'application/pdf',
sizeBytes: 48213,
}}
state="done"
onRemove={() => {}}
>
<AttachmentInfo showMediaType />
<AttachmentInfo />
</Attachment>
<Attachment
data={{ id: 'att-2', name: 'cold-boot.png', mimeType: 'image/png', sizeBytes: 118_402 }}
state="uploading"
>
<AttachmentInfo />
</Attachment>
</Attachments>
)