File Field
A compact single-file input that sits in a form next to regular fields.
Drop a file or browsePDF, DOC or DOCX · 5 MB
Once a file is chosen, the field shows it with its progress and a remove button instead of the drop area.
Installation
Use the shadcn CLI to add the component to your project.
npx shadcn@latest add https://edgestore.dev/r/file-field.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 { UploadIcon } from 'lucide-react';
import * as React from 'react';
import { type Accept } from 'react-dropzone';
import { dropzoneState, dropzoneVariants, useUploadDropzone } from './dropzone';
import { FileListItem } from './multi-file';
import { formatFileSize } from './upload-utils';
import { useUploader } from './uploader-provider';
/**
* Props for the FileField component.
*/
export type FileFieldProps = Omit<React.ComponentProps<'div'>, 'children'> & {
/**
* Accepted file types.
*/
accept?: Accept;
/**
* Human-readable list of accepted types, shown in the hint and messages.
*
* @example "PDF, DOC or DOCX"
*/
typesLabel?: string;
/**
* Maximum file size in bytes.
*/
maxSize?: number;
/**
* Whether the field is disabled.
*/
disabled?: boolean;
/**
* Id for the hidden file input, so a `<label htmlFor>` opens the file dialog.
*/
inputId?: string;
};
/**
* A compact, single-file input that sits in a form next to regular fields.
* Once a file is chosen, it shows the file with its progress instead.
*
* @example
* ```tsx
* <Label id="resume-label" htmlFor="resume">Resume</Label>
* <FileField
* inputId="resume"
* aria-labelledby="resume-label"
* accept={{ 'application/pdf': ['.pdf'] }}
* typesLabel="PDF"
* maxSize={1024 * 1024 * 5} // 5MB
* />
* ```
*/
export function FileField({
accept,
typesLabel,
maxSize,
disabled,
inputId,
className,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
...props
}: FileFieldProps) {
const { fileStates } = useUploader();
const fileState = fileStates[0];
const errorId = React.useId();
const { getRootProps, getInputProps, isDragActive, isDragReject, errors } =
useUploadDropzone({ accept, maxSize, typesLabel, disabled, replace: true });
const hint = [typesLabel, maxSize && formatFileSize(maxSize)]
.filter(Boolean)
.join(' · ');
return (
<div className={cn('@container grid gap-1.5', className)} {...props}>
{fileState ? (
<ul>
<FileListItem fileState={fileState} disabled={disabled} />
</ul>
) : (
<div
{...getRootProps({
role: 'button',
'aria-label':
ariaLabel ?? (ariaLabelledBy ? undefined : 'Upload a file'),
'aria-labelledby': ariaLabelledBy,
'aria-describedby':
[ariaDescribedBy, errors.length > 0 && errorId]
.filter(Boolean)
.join(' ') || undefined,
'aria-invalid': errors.length > 0 || undefined,
className: cn(
dropzoneVariants,
'grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-1.5 rounded-lg bg-background px-3.5 py-3 text-left hover:bg-background aria-invalid:border-destructive @min-[22rem]:grid-cols-[auto_minmax(0,1fr)_auto]',
),
})}
{...dropzoneState({ isDragActive, isDragReject, disabled })}
>
<input {...getInputProps({ id: inputId, className: 'absolute' })} />
<UploadIcon className="size-4 text-muted-foreground group-data-[dragging]/dropzone:text-primary" />
<span className="min-w-0 text-sm group-data-[rejected]/dropzone:text-destructive">
{isDragReject ? (
'File type not supported'
) : isDragActive ? (
'Drop to upload'
) : (
<>
Drop a file or{' '}
<span className="font-semibold text-primary">browse</span>
</>
)}
</span>
{hint && (
<span className="col-start-2 text-xs text-muted-foreground @min-[22rem]:col-start-3">
{hint}
</span>
)}
</div>
)}
{errors[0] && (
<p id={errorId} role="alert" className="text-sm text-destructive">
{errors[0]}
</p>
)}
</div>
);
}Usage
Install or copy the component from Installation before using this example.
'use client';
import { FileField } from '@/components/upload/file-field';
import { DOCUMENT_ACCEPT } from '@/components/upload/upload-utils';
import {
UploaderProvider,
type UploadFn,
} from '@/components/upload/uploader-provider';
import { useEdgeStore } from '@/lib/edgestore';
import * as React from 'react';
export function FileFieldUsage() {
const { edgestore } = useEdgeStore();
const uploadFn: UploadFn = React.useCallback(
async ({ file, onProgressChange, signal }) => {
const res = await edgestore.publicFiles.upload({
file,
signal,
onProgressChange,
});
// you can run some server action or api here
// to add the necessary data to your database
console.log(res);
return res;
},
[edgestore],
);
return (
<UploaderProvider uploadFn={uploadFn} autoUpload>
<label id="resume-label" htmlFor="resume-input">
Resume
</label>
<FileField
inputId="resume-input"
aria-labelledby="resume-label"
accept={DOCUMENT_ACCEPT}
typesLabel="PDF, DOC or DOCX"
maxSize={1024 * 1024 * 5} // 5 MB
/>
</UploaderProvider>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
inputId | string | Id for the hidden file input, so a <label htmlFor> opens the file dialog. | |
maxSize | number | Maximum file size in bytes. | |
accept | Accept | all types | Accepted file types, in react-dropzone's format. |
typesLabel | string | Human-readable list of accepted types, shown in the hint and in error messages. | |
disabled | boolean | false | Disables the component. |
aria-labelledby | string | Id of the element that labels the field. |
With React Hook Form
Give each field its own UploaderProvider and write the completed file into the form value from onChange:
const handleChange = React.useCallback(
({ completedFiles }: { completedFiles: CompletedFileState[] }) => {
const [completed] = completedFiles;
field.onChange(
completed ? { filename: completed.file.name, url: completed.url } : null,
);
},
[field],
);
<UploaderProvider uploadFn={uploadFn} onChange={handleChange} autoUpload>
<FileField aria-labelledby="resume-label" />
</UploaderProvider>;