File List
Lists the files of an UploaderProvider with their status, progress and actions.
file-list.tsx exports:
FileList: lists the files of the nearestUploaderProvider.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.jsonSetup for manual installation
First you will need to follow the manual install setup guide.
Copy this component
'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>
);
}'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
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Hides the retry, cancel and remove actions. |
... | Any other <ul> prop, such as className. |