EdgeStore

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.json

Setup for manual installation

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

Copy this component

components/upload/file-field.tsx
'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

PropTypeDefaultDescription
inputIdstringId for the hidden file input, so a <label htmlFor> opens the file dialog.
maxSizenumberMaximum file size in bytes.
acceptAcceptall typesAccepted file types, in react-dropzone's format.
typesLabelstringHuman-readable list of accepted types, shown in the hint and in error messages.
disabledbooleanfalseDisables the component.
aria-labelledbystringId 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>;

On this page