EdgeStore

File List

Lists the files of an UploaderProvider with their status, progress and actions.

file-list.tsx exports:

  • FileList: lists the files of the nearest UploaderProvider.
  • FileListItem: renders one file with its status, progress and actions (retry, cancel, remove).
  • FileBadge: renders the colored file-type tile.

Use it next to an Upload Button, or anywhere you want the files of an UploaderProvider shown as a list.

The multi-file and file field components install it automatically via the CLI.

Installation

Use the shadcn CLI to add the component to your project.

npx shadcn@latest add https://edgestore.dev/r/file-list.json

Setup for manual installation

First you will need to follow the manual install setup guide.

Copy this component

components/upload/file-list.tsx
'use client';

import { cn } from '@/lib/utils';
import { CheckIcon, RotateCwIcon, XIcon } from 'lucide-react';
import * as React from 'react';
import { ProgressBar } from './progress-bar';
import {
  fileExtension,
  fileKind,
  formatFileSize,
  type FileKind,
} from './upload-utils';
import { useUploader, type FileState } from './uploader-provider';

const KIND_CLASSNAMES: Record<FileKind, string> = {
  image: 'text-purple-600 dark:text-purple-400',
  video: 'text-pink-600 dark:text-pink-400',
  audio: 'text-cyan-600 dark:text-cyan-400',
  pdf: 'text-red-600 dark:text-red-400',
  doc: 'text-blue-600 dark:text-blue-400',
  sheet: 'text-green-600 dark:text-green-400',
  slides: 'text-orange-600 dark:text-orange-400',
  archive: 'text-yellow-600 dark:text-yellow-400',
  code: 'text-teal-600 dark:text-teal-400',
  other: 'text-muted-foreground',
};

/**
 * A colored tile with the file's extension.
 */
export function FileBadge({
  file,
  className,
}: {
  file: File;
  className?: string;
}) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        'grid h-10 w-9 shrink-0 place-items-center rounded-md border border-current/25 bg-current/10 font-mono text-[10px] font-bold uppercase',
        KIND_CLASSNAMES[fileKind(file)],
        className,
      )}
    >
      {fileExtension(file.name).slice(0, 4) || 'file'}
    </span>
  );
}

function IconButton({
  label,
  className,
  ...props
}: React.ComponentProps<'button'> & { label: string }) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      className={cn(
        'grid size-8 place-items-center rounded-md text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4',
        className,
      )}
      {...props}
    />
  );
}

/**
 * One file with its status, progress and actions (retry, cancel, remove).
 */
export function FileListItem({
  fileState,
  disabled,
  className,
  ...props
}: React.ComponentProps<'li'> & {
  fileState: FileState;
  /** Hides the retry, cancel and remove actions. */
  disabled?: boolean;
}) {
  const { removeFile, cancelUpload, uploadFiles } = useUploader();
  const { file, key, status, progress, error } = fileState;

  return (
    <li
      data-status={status}
      className={cn(
        'relative flex animate-in items-center gap-3 overflow-hidden rounded-xl border bg-background py-2.5 pr-2 pl-2.5 fade-in-0 slide-in-from-top-1 data-[status=ERROR]:border-destructive/40',
        className,
      )}
      {...props}
    >
      <FileBadge file={file} />
      <div className="grid min-w-0 flex-1 gap-1">
        <div className="flex min-w-0 items-center gap-1.5">
          <span className="truncate text-sm font-medium" title={file.name}>
            {file.name}
          </span>
          {status === 'COMPLETE' && (
            <span className="grid size-4 shrink-0 place-items-center rounded-full bg-emerald-600 text-white dark:bg-emerald-500">
              <CheckIcon className="size-2.5" strokeWidth={4} />
              <span className="sr-only">Uploaded</span>
            </span>
          )}
        </div>
        <p
          className={cn(
            'text-xs text-muted-foreground tabular-nums',
            status === 'ERROR' && 'text-destructive',
          )}
        >
          {status === 'ERROR'
            ? (error ?? 'Upload failed')
            : status === 'UPLOADING'
              ? `${formatFileSize((file.size * progress) / 100)} of ${formatFileSize(file.size)} · ${Math.round(progress)}%`
              : formatFileSize(file.size)}
        </p>
      </div>
      {/* Pinned to the bottom edge so the row keeps its height when the upload ends. */}
      {status === 'UPLOADING' && (
        <ProgressBar
          progress={progress}
          aria-label={`Uploading ${file.name}`}
          className="absolute inset-x-0 bottom-0 h-0.5 rounded-none bg-transparent"
          indicatorClassName="rounded-none"
        />
      )}
      {!disabled && (
        <div className="flex items-center">
          {status === 'ERROR' && (
            <IconButton
              label={`Retry ${file.name}`}
              onClick={() => void uploadFiles([key])}
            >
              <RotateCwIcon />
            </IconButton>
          )}
          {status === 'UPLOADING' ? (
            <IconButton
              label={`Cancel ${file.name}`}
              onClick={() => cancelUpload(key)}
            >
              <XIcon />
            </IconButton>
          ) : (
            <IconButton
              label={`Remove ${file.name}`}
              onClick={() => removeFile(key)}
            >
              <XIcon />
            </IconButton>
          )}
        </div>
      )}
    </li>
  );
}

/**
 * Lists the files of the nearest `UploaderProvider`.
 *
 * @example
 * ```tsx
 * <FileList className="my-4" />
 * ```
 */
export function FileList({
  disabled,
  className,
  ...props
}: React.ComponentProps<'ul'> & {
  /** Hides the retry, cancel and remove actions. */
  disabled?: boolean;
}) {
  const { fileStates } = useUploader();
  if (fileStates.length === 0) return null;

  return (
    <ul
      aria-label="Files"
      className={cn('grid w-full gap-2', className)}
      {...props}
    >
      {fileStates.map((fileState) => (
        <FileListItem
          key={fileState.key}
          fileState={fileState}
          disabled={disabled}
        />
      ))}
    </ul>
  );
}
components/upload/upload-utils.ts
'use client';

import * as React from 'react';
import {
  type Accept,
  type FileError,
  type FileRejection,
} from 'react-dropzone';

export const IMAGE_ACCEPT: Accept = {
  'image/png': ['.png'],
  'image/jpeg': ['.jpg', '.jpeg'],
  'image/webp': ['.webp'],
  'image/gif': ['.gif'],
};

export const DOCUMENT_ACCEPT: Accept = {
  'application/pdf': ['.pdf'],
  'application/msword': ['.doc'],
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
    '.docx',
  ],
};

export const SPREADSHEET_ACCEPT: Accept = {
  'text/csv': ['.csv'],
  'application/vnd.ms-excel': ['.xls'],
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
    '.xlsx',
  ],
};

/**
 * Formats a file size in bytes to a human-readable string.
 *
 * @example
 * ```ts
 * formatFileSize(1024); // "1 KB"
 * formatFileSize(1024 * 1024 * 2.5); // "2.5 MB"
 * ```
 */
export function formatFileSize(bytes?: number) {
  if (!bytes) return '0 B';
  if (bytes < 1024) return `${Math.round(bytes)} B`;
  const units = ['KB', 'MB', 'GB', 'TB'];
  let value = bytes / 1024;
  let i = 0;
  while (value >= 1024 && i < units.length - 1) {
    value /= 1024;
    i++;
  }
  return `${value < 10 ? Number(value.toFixed(1)) : Math.round(value)} ${units[i]}`;
}

export function fileExtension(name: string) {
  const dot = name.lastIndexOf('.');
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}

export type FileKind =
  | 'image'
  | 'video'
  | 'audio'
  | 'pdf'
  | 'doc'
  | 'sheet'
  | 'slides'
  | 'archive'
  | 'code'
  | 'other';

const KIND_BY_EXTENSION: Record<string, FileKind> = {
  pdf: 'pdf',
  doc: 'doc',
  docx: 'doc',
  txt: 'doc',
  md: 'doc',
  rtf: 'doc',
  xls: 'sheet',
  xlsx: 'sheet',
  csv: 'sheet',
  ppt: 'slides',
  pptx: 'slides',
  key: 'slides',
  zip: 'archive',
  rar: 'archive',
  '7z': 'archive',
  tar: 'archive',
  gz: 'archive',
  js: 'code',
  ts: 'code',
  tsx: 'code',
  jsx: 'code',
  json: 'code',
  html: 'code',
  css: 'code',
  py: 'code',
};

export function fileKind(file: File): FileKind {
  if (file.type.startsWith('image/')) return 'image';
  if (file.type.startsWith('video/')) return 'video';
  if (file.type.startsWith('audio/')) return 'audio';
  return KIND_BY_EXTENSION[fileExtension(file.name)] ?? 'other';
}

export type UploadLimits = {
  maxSize?: number;
  minSize?: number;
  maxFiles?: number;
  /** Human-readable list of accepted types, e.g. "PNG or JPG". */
  typesLabel?: string;
};

/**
 * Friendlier rejection messages than react-dropzone's defaults
 * ("File is larger than 1048576 bytes"). Pass it to the `getErrorMessage` option.
 */
export function uploadErrorMessage(limits: UploadLimits) {
  return (error: FileError, file: File): string => {
    switch (error.code) {
      case 'file-too-large':
        return `${file.name} is ${formatFileSize(file.size)}. The limit is ${formatFileSize(limits.maxSize)}.`;
      case 'file-too-small':
        return `${file.name} is smaller than ${formatFileSize(limits.minSize)}.`;
      case 'file-invalid-type':
        return limits.typesLabel
          ? `${file.name} isn't supported. Use ${limits.typesLabel}.`
          : `${file.name} isn't a supported file type.`;
      case 'too-many-files':
        if (limits.maxFiles === 1) return 'Choose a single file.';
        return limits.maxFiles
          ? `You can add up to ${limits.maxFiles} files.`
          : 'Too many files.';
      default:
        return error.message;
    }
  };
}

/** Flattens rejections into unique, human-readable messages. */
export function rejectionMessages(rejections: readonly FileRejection[]) {
  return [
    ...new Set(rejections.flatMap((r) => r.errors.map((e) => e.message))),
  ];
}

/** Short summary of the limits, e.g. "PNG or JPG · up to 2 MB · 5 max". */
export function describeLimits({
  typesLabel,
  maxSize,
  maxFiles,
}: UploadLimits) {
  return [
    typesLabel,
    maxSize &&
      `up to ${formatFileSize(maxSize)}${maxFiles === 1 ? '' : ' each'}`,
    maxFiles && maxFiles > 1 && `${maxFiles} max`,
  ]
    .filter(Boolean)
    .join(' · ');
}

/**
 * Async validator that rejects images smaller than `min` pixels on either side.
 * Pass it to the `validator` option.
 */
export function minImageSize(min: number) {
  return async (file: File): Promise<FileError | null> => {
    // Let `accept` report the type error.
    if (!file.type.startsWith('image/')) return null;
    const url = URL.createObjectURL(file);
    try {
      const img = new Image();
      img.src = url;
      await img.decode();
      if (img.naturalWidth < min || img.naturalHeight < min) {
        return {
          code: 'image-too-small',
          message: `${file.name} is ${img.naturalWidth}×${img.naturalHeight}px. Use at least ${min}×${min}px.`,
        };
      }
      return null;
    } catch {
      return {
        code: 'image-unreadable',
        message: `${file.name} couldn't be read as an image.`,
      };
    } finally {
      URL.revokeObjectURL(url);
    }
  };
}

/**
 * Returns an object URL for previewing `file`, created once per file and
 * revoked when the file changes or the component unmounts.
 */
export function useObjectUrl(file?: File | null) {
  const [url, setUrl] = React.useState<string>();

  React.useEffect(() => {
    if (!file) return;
    const objectUrl = URL.createObjectURL(file);
    setUrl(objectUrl);
    return () => {
      URL.revokeObjectURL(objectUrl);
      setUrl(undefined);
    };
  }, [file]);

  return url;
}

Usage

<UploaderProvider uploadFn={uploadFn} autoUpload>
  {/* anything that adds files, e.g. <UploadButton /> */}
  <FileList />
</UploaderProvider>

Props

PropTypeDefaultDescription
disabledbooleanfalseHides the retry, cancel and remove actions.
...Any other <ul> prop, such as className.

On this page