/** * Attachment — a file, with its upload life. * * A file in a chat composer or an upload list is the same row shape `Item` * already draws — media, a name, a size, a remove button — so this is that row, * not a second one. What it adds is the part `Item` has no opinion on: the * file's *state*. A file is picked, then uploading, then processing, then done * (or failed), and each of those has to look different or the user cannot tell * a stuck upload from a finished one. * * ```tsx * * * * report.pdf * PDF · 2.4 MB * * * * * * ``` * * While uploading or processing the title shimmers — the same sweep the rest of * the library uses for "in progress" — and an optional bar tracks `progress`. * A failed file tints its name and description in the destructive colour, which * carries the state without relying on an icon nobody reads. */ import { createContext, forwardRef, useContext, type ReactNode } from 'react'; import { View, type ViewProps } from 'react-native'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { AnimatedPressable, type AnimatedPressableProps } from '../../primitives/animated-pressable'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { Item } from '../item'; import { Shimmer } from '../shimmer'; export type AttachmentState = 'idle' | 'uploading' | 'processing' | 'error' | 'done'; type AttachmentSize = 'default' | 'sm' | 'xs'; interface AttachmentContextValue { state: AttachmentState; size: AttachmentSize; } const AttachmentContext = createContext({ state: 'done', size: 'default', }); /** True while the file is still moving — the two states that shimmer. */ function isBusy(state: AttachmentState): boolean { return state === 'uploading' || state === 'processing'; } const rootVariants = tv({ base: '', variants: { state: { idle: '', uploading: '', processing: '', // The border carries the failure, so it reads at a glance in a list. error: 'border border-destructive', done: '', }, }, }); export interface AttachmentProps extends Omit { className?: string; /** * Where the file is in its life. `uploading`/`processing` shimmer the name; * `error` tints the row destructive. Default `done`. */ state?: AttachmentState; /** Row density, passed through to the underlying Item. */ size?: AttachmentSize; /** Stack the row into a card — for a horizontal group of thumbnails. */ orientation?: 'horizontal' | 'vertical'; /** Upload progress 0–1. Draws a thin bar along the bottom while busy. */ progress?: number; disabled?: boolean; children?: ReactNode; } const AttachmentRoot = forwardRef( ( { className, state = 'done', size = 'default', orientation, progress, children, ...props }, ref ) => { const track = useCSSVariable('--color-primary'); return ( {children} {/* A determinate bar while busy, if progress is known. Absolute so it rides the row's bottom edge without shifting the layout. */} {isBusy(state) && progress !== undefined ? ( ) : null} ); } ); AttachmentRoot.displayName = 'Attachment'; export interface AttachmentGroupProps extends ViewProps { className?: string; orientation?: 'horizontal' | 'vertical'; children?: ReactNode; } /** A row (or column) of attachments — the same stack `Item.Group` provides. */ const AttachmentGroup = forwardRef( ({ className, orientation = 'vertical', children, ...props }, ref) => ( {children} ) ); AttachmentGroup.displayName = 'Attachment.Group'; export interface AttachmentMediaProps extends ViewProps { className?: string; /** `icon` frames it in a tile; `image` clips it for a thumbnail. */ variant?: 'default' | 'icon' | 'image'; children?: ReactNode; } /** Leading slot — a file-type icon tile, or an image thumbnail. */ const AttachmentMedia = forwardRef( ({ className, variant = 'icon', children, ...props }, ref) => ( {children} ) ); AttachmentMedia.displayName = 'Attachment.Media'; export interface AttachmentContentProps extends ViewProps { className?: string; children?: ReactNode; } const AttachmentContent = forwardRef( ({ className, children, ...props }, ref) => ( {children} ) ); AttachmentContent.displayName = 'Attachment.Content'; export interface AttachmentTitleProps extends TextProps { className?: string; children?: ReactNode; } /** The file name. Shimmers while the file is uploading or processing. */ const AttachmentTitle = forwardRef, AttachmentTitleProps>( ({ className, children, ...props }, ref) => { const { state } = useContext(AttachmentContext); if (isBusy(state)) { // Shimmer owns the text so it can mask the sweep to the glyphs. return {children}; } return ( {children} ); } ); AttachmentTitle.displayName = 'Attachment.Title'; export interface AttachmentDescriptionProps extends TextProps { className?: string; children?: ReactNode; } /** File type, size or status. Tints destructive on `error`. */ const AttachmentDescription = forwardRef< React.ElementRef, AttachmentDescriptionProps >(({ className, children, ...props }, ref) => { const { state } = useContext(AttachmentContext); return ( {children} ); }); AttachmentDescription.displayName = 'Attachment.Description'; export interface AttachmentActionsProps extends ViewProps { className?: string; children?: ReactNode; } const AttachmentActions = forwardRef( ({ className, children, ...props }, ref) => ( {children} ) ); AttachmentActions.displayName = 'Attachment.Actions'; export interface AttachmentActionProps extends Omit { className?: string; accessibilityLabel: string; children?: ReactNode; } /** * A single icon button in the actions slot — remove, retry, download. An icon * on its own says nothing to a screen reader, so the label is required. */ const AttachmentAction = forwardRef( ({ className, accessibilityLabel, children, ...props }, ref) => ( {textChildren(children)} ) ); AttachmentAction.displayName = 'Attachment.Action'; export const Attachment = Object.assign(AttachmentRoot, { Group: AttachmentGroup, Media: AttachmentMedia, Content: AttachmentContent, Title: AttachmentTitle, Description: AttachmentDescription, Actions: AttachmentActions, Action: AttachmentAction, });