FileAttachment

Render a file/image chip. Use isImage + url for thumbnails, display="image-only" for previews, and onRemove to show the close control. While you upload, pass status="uploading" with progress from 0 to 100 (a bar in the chip, a ring over a thumbnail; without progress the bar animates, the thumbnail shows a loader and neither reports a value) and onCancel, which the × button then calls instead of onRemove. After a failure pass status="error" with error and onRetry; screen readers hear the failure once. In InputBar the same fields go on attachedFiles and attachedImages, with onCancelFile and onRetryFile; Send stays off while anything uploads (blockSendWhileUploading) and says labels.waitForUploads. The kit never uploads: the network is yours.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Upload states
File + image
Image only
Removable file
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

input/FileAttachment.tsx
import React, { useState } from 'react';
import { Box, Loader, Progress, RingProgress, UnstyledButton } from '@mantine/core';
import {
  IconFileCode,
  IconFileText,
  IconFileTypeJs,
  IconPhoto,
  IconRefresh,
  IconX,
} from '@tabler/icons-react';
import { ImageLightbox, type ImageLightboxLabels } from '../ImageLightbox/ImageLightbox';
import { VisuallyHiddenStatus } from '../primitives/VisuallyHiddenStatus/VisuallyHiddenStatus';
import type { AttachmentUpload } from '../types';
import { cx } from '../utils/cx';
import classes from './FileAttachment.module.css';

export interface FileAttachmentLabels {
  /** Accessible label of the image thumbnail, `Preview image` by default */
  preview: string;
  /** Accessible label of the remove button, `Remove attachment` by default */
  remove: string;
  /** File size under the name, `1.2 KB` by default */
  size: (bytes: number) => string;
  /** Labels of the fullscreen image preview */
  lightbox: Partial<ImageLightboxLabels>;
  /** Accessible label of the upload progress, `Uploading` by default */
  uploading: string;
  /** Line under the name when the upload failed without `error`, `Upload failed` by default */
  uploadFailed: string;
  /** Announced once when the upload fails, `Upload failed: report.pdf. Too large` by default */
  uploadFailedStatus: (filename: string, error?: string) => string;
  /** Accessible label of the × button during an upload, `Cancel upload` by default */
  cancelUpload: string;
  /** Accessible label of the retry button, `Retry upload` by default */
  retryUpload: string;
  /** Text of the upload progress, `42%` by default */
  progress: (percent: number) => string;
}

export const DEFAULT_FILE_ATTACHMENT_LABELS: FileAttachmentLabels = {
  preview: 'Preview image',
  remove: 'Remove attachment',
  size: formatFileSize,
  lightbox: {},
  uploading: 'Uploading',
  uploadFailed: 'Upload failed',
  uploadFailedStatus: (filename, error) => `Upload failed: ${filename}${error ? `. ${error}` : ''}`,
  cancelUpload: 'Cancel upload',
  retryUpload: 'Retry upload',
  progress: (percent) => `${percent}%`,
};

export interface FileAttachmentProps extends AttachmentUpload {
  id: string;
  filename: string;
  /** File size in bytes, shown under the name when provided */
  size?: number;
  isImage?: boolean;
  /** Image URL, required for thumbnails and preview */
  url?: string;
  onRemove?: () => void;
  /** Stops the upload; the × button calls it instead of `onRemove` while `uploading` */
  onCancel?: () => void;
  /** Starts a failed upload again */
  onRetry?: () => void;
  className?: string;
  /** `'chip'` renders icon + name, `'image-only'` renders a square thumbnail (images with `url` only) */
  display?: 'chip' | 'image-only';
  /** Opens the image thumbnail in a fullscreen preview on click, `true` by default */
  enableImagePreview?: boolean;
  /** Overrides of the default English labels */
  labels?: Partial<FileAttachmentLabels>;
  style?: React.CSSProperties;
}

function formatFileSize(bytes: number): string {
  if (bytes < 1024) {
    return `${bytes} B`;
  }
  if (bytes < 1024 * 1024) {
    return `${(bytes / 1024).toFixed(1)} KB`;
  }
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

type FileIconName = 'image' | 'code' | 'data' | 'text';

const CODE_EXTENSIONS = [
  'js',
  'ts',
  'jsx',
  'tsx',
  'py',
  'rb',
  'go',
  'rs',
  'java',
  'kt',
  'swift',
  'c',
  'cpp',
  'h',
  'hpp',
  'cs',
  'php',
];
const DATA_EXTENSIONS = ['json', 'yaml', 'yml', 'xml'];

function getFileIconName(filename: string, isImage?: boolean): FileIconName {
  if (isImage) {
    return 'image';
  }
  const ext = filename.split('.').pop()?.toLowerCase() ?? '';
  if (CODE_EXTENSIONS.includes(ext)) {
    return 'code';
  }
  if (DATA_EXTENSIONS.includes(ext)) {
    return 'data';
  }
  return 'text';
}

function renderFileIcon(iconName: FileIconName) {
  switch (iconName) {
    case 'image':
      return <IconPhoto size={16} className={classes.fileIcon} />;
    case 'code':
      return <IconFileCode size={16} className={classes.fileIcon} />;
    case 'data':
      return <IconFileTypeJs size={16} className={classes.fileIcon} />;
    default:
      return <IconFileText size={16} className={classes.fileIcon} />;
  }
}

function Thumb({
  className,
  canPreview,
  onOpen,
  previewLabel,
  children,
}: {
  className: string;
  canPreview: boolean;
  onOpen: (event: React.MouseEvent) => void;
  previewLabel: string;
  children: React.ReactNode;
}) {
  if (canPreview) {
    return (
      <UnstyledButton
        className={cx(classes.thumb, className)}
        data-preview
        onClick={onOpen}
        aria-label={previewLabel}
      >
        {children}
      </UnstyledButton>
    );
  }
  return <div className={cx(classes.thumb, className)}>{children}</div>;
}

/** Staged attachment shown above the composer: a chip with icon and name, or an image thumbnail */
export function FileAttachment({
  id,
  filename,
  size,
  isImage,
  url,
  onRemove,
  onCancel,
  onRetry,
  status,
  progress,
  error,
  className,
  display = 'chip',
  enableImagePreview = true,
  labels: labelsProp,
  style,
}: FileAttachmentProps) {
  const labels = { ...DEFAULT_FILE_ATTACHMENT_LABELS, ...labelsProp };
  const [isLightboxOpen, setIsLightboxOpen] = useState(false);
  const iconName = getFileIconName(filename, isImage);
  const isImageOnly = display === 'image-only' && isImage && !!url;
  const uploading = status === 'uploading';
  const failed = status === 'error';
  const canPreview = Boolean(enableImagePreview && isImage && url && !uploading && !failed);
  const percent =
    progress === undefined ? undefined : Math.round(Math.min(100, Math.max(0, progress)));
  const cancels = Boolean(uploading && onCancel);
  const dismiss = cancels ? onCancel : onRemove;
  const progressText = percent === undefined ? undefined : labels.progress(percent);

  const openLightbox = (event: React.MouseEvent) => {
    event.stopPropagation();
    setIsLightboxOpen(true);
  };

  return (
    <Box
      className={cx(classes.root, className)}
      data-image-only={isImageOnly || undefined}
      data-status={status}
      aria-busy={uploading || undefined}
      style={style}
    >
      {isImageOnly ? (
        <Thumb
          className={classes.thumbSquare}
          canPreview={canPreview}
          onOpen={openLightbox}
          previewLabel={labels.preview}
        >
          <img src={url} alt={filename} className={classes.img} />
          {uploading &&
            (percent === undefined ? (
              <Loader
                size="xs"
                color="var(--ae-fg-muted)"
                className={classes.ring}
                role="progressbar"
                aria-label={labels.uploading}
              />
            ) : (
              <RingProgress
                size={32}
                thickness={3}
                sections={[{ value: percent, color: 'var(--ae-fg-muted)' }]}
                rootColor="var(--file-attachment-ring-track)"
                className={classes.ring}
                role="progressbar"
                aria-label={labels.uploading}
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={percent}
                aria-valuetext={progressText}
              />
            ))}
        </Thumb>
      ) : (
        <>
          {isImage && url ? (
            <Thumb
              className={classes.thumbStretch}
              canPreview={canPreview}
              onOpen={openLightbox}
              previewLabel={labels.preview}
            >
              <img src={url} alt={filename} className={cx(classes.img, classes.imgSquare)} />
            </Thumb>
          ) : (
            <div className={classes.iconBox}>{renderFileIcon(iconName)}</div>
          )}

          <div className={classes.meta}>
            <span className={classes.name} title={filename}>
              {filename}
            </span>
            {uploading ? (
              <div className={classes.line}>
                <Progress.Root size="xs" className={classes.progress}>
                  <Progress.Section
                    value={percent ?? 100}
                    color="var(--ae-fg-muted)"
                    animated={percent === undefined}
                    withAria={percent !== undefined}
                    role="progressbar"
                    aria-label={labels.uploading}
                    aria-valuetext={progressText}
                  />
                </Progress.Root>
              </div>
            ) : failed ? (
              <span className={classes.size} title={error ?? labels.uploadFailed}>
                {error ?? labels.uploadFailed}
              </span>
            ) : (
              size !== undefined && <span className={classes.size}>{labels.size(size)}</span>
            )}
          </div>
        </>
      )}

      {failed && onRetry && (
        <UnstyledButton className={classes.retry} onClick={onRetry} aria-label={labels.retryUpload}>
          <IconRefresh size={14} />
        </UnstyledButton>
      )}

      {dismiss && (
        <UnstyledButton
          onClick={(e) => {
            e.stopPropagation();
            dismiss();
          }}
          className={classes.remove}
          aria-label={cancels ? labels.cancelUpload : labels.remove}
        >
          <IconX size={12} />
        </UnstyledButton>
      )}

      <VisuallyHiddenStatus>
        {failed && labels.uploadFailedStatus(filename, error)}
      </VisuallyHiddenStatus>

      {canPreview && url && (
        <ImageLightbox
          open={isLightboxOpen}
          onClose={() => setIsLightboxOpen(false)}
          images={[{ id, url, filename }]}
          labels={labels.lightbox}
        />
      )}
    </Box>
  );
}

FileAttachment.displayName = 'FileAttachment';
API reference
Prop
Type
Required
id
string
Yes
filename
string
Yes
size
number
No
File size in bytes, shown under the name when provided
isImage
boolean
No
url
string
No
Image URL, required for thumbnails and preview
onRemove
() => void
No
onCancel
() => void
No
Stops the upload; the × button calls it instead of `onRemove` while `uploading`
onRetry
() => void
No
Starts a failed upload again
className
string
No
display
'chip' | 'image-only'
No
`'chip'` renders icon + name, `'image-only'` renders a square thumbnail (images with `url` only)
enableImagePreview
boolean
No
Opens the image thumbnail in a fullscreen preview on click, `true` by default
labels
Partial<FileAttachmentLabels>
No
Overrides of the default English labels
style
React.CSSProperties
No
status
'uploading' | 'done' | 'error'
No
`uploading` shows progress and blocks sending, `error` offers a retry, `done` by default
progress
number
No
Upload progress from 0 to 100; an unknown amount animates while `uploading`
error
string
No
Why the upload failed, shown for `error`