Skip to main content
react-md

useFileUpload

function useFileUpload<E extends HTMLElement, CustomError = never>(
  options: FileUploadOptions<E, CustomError> = {}
): Readonly<FileUploadHookReturnValue<E, CustomError>>;

The useFileUpload hook can be used to upload files to the browser in different formats to be previewed <img>, <video>, <embed>, etc tags. However, it can also be used to upload the files as an ArrayBuffer and then uploaded to a server.

Example Usage

This example will show how to handle simple validation and preview uploaded files. Only the Demo files are editable.

0 of 10

"use client";

import { Box } from "@react-md/core/box/Box";
import { Button } from "@react-md/core/button/Button";
import { FileInput } from "@react-md/core/files/FileInput";
import {
  type FileUploadOptions,
  useFileUpload,
} from "@react-md/core/files/useFileUpload";
import { Form } from "@react-md/core/form/Form";
import { FormMessage } from "@react-md/core/form/FormMessage";
import { FormMessageCounter } from "@react-md/core/form/FormMessageCounter";
import { LinearProgress } from "@react-md/core/progress/LinearProgress";
import { type ReactElement } from "react";

import { FilePreviewCard } from "@/components/FilePreview/FilePreviewCard.jsx";
import { FileUploadErrorModal } from "@/components/FileUploadErrorModal/FileUploadErrorModal.jsx";

import styles from "./FileUploadExample.module.scss";

export const EXTENSIONS = [
  "svg",
  "jpeg",
  "jpg",
  "png",
  "apng",
  "mkv",
  "mp4",
  "mpeg",
  "mpg",
  "webm",
  "mov",
] as const;

export const FOUR_HUNDRED_MB = 400 * 1024 * 1024;
export const MAX_FILES = 10;

export type FileUploadExampleProps = Partial<FileUploadOptions<HTMLElement>>;

export default function FileUploadExample(
  props: FileUploadExampleProps,
): ReactElement {
  const {
    maxFiles = MAX_FILES,
    maxFileSize = FOUR_HUNDRED_MB,
    extensions = EXTENSIONS,
  } = props;
  const { stats, errors, onChange, clearErrors, reset, remove, accept } =
    useFileUpload({
      ...props,
      maxFiles,
      maxFileSize,
      extensions,
    });

  return (
    <Form className={styles.container}>
      <FileUploadErrorModal errors={errors} clearErrors={clearErrors} />
      <Box>
        <FileInput
          accept={accept}
          onChange={onChange}
          multiple={maxFiles > 1}
        />
        <Button onClick={reset} disabled={!stats.length}>
          Remove all files
        </Button>
      </Box>
      <div className={styles.progress}>
        <LinearProgress
          aria-label="Upload size limit"
          min={0}
          max={maxFiles}
          value={stats.length}
        />
        <FormMessage theme="none">
          <FormMessageCounter>
            {stats.length} of {maxFiles}
          </FormMessageCounter>
        </FormMessage>
      </div>
      <Box
        grid
        gridColumns="fill"
        gridAutoRows
        align="stretch"
        className={styles.grid}
      >
        {stats.map(({ key, ...uploadStatus }) => (
          <FilePreviewCard
            key={key}
            {...uploadStatus}
            fileKey={key}
            remove={remove}
          />
        ))}
      </Box>
    </Form>
  );
}

Press Enter to start editing.

@use "everything";

.container {
  width: 100%;

  @include everything.tablet-media {
    @include everything.box-set-var(item-min-size, 18rem);
    @include everything.box-set-var(auto-rows-height, 18rem * 3);
    @include everything.box-set-var(row-max-height, 18rem);
  }
}

.progress {
  padding: everything.$box-padding;
}

Press Enter to start editing.

import { Button } from "@react-md/core/button/Button";
import { Card } from "@react-md/core/card/Card";
import { CardContent } from "@react-md/core/card/CardContent";
import { CardHeader } from "@react-md/core/card/CardHeader";
import { CardSubtitle } from "@react-md/core/card/CardSubtitle";
import { CardTitle } from "@react-md/core/card/CardTitle";
import { type FileUploadActions } from "@react-md/core/files/useFileUpload";
import {
  type FileReaderResult,
  type FileUploadStats,
} from "@react-md/core/files/utils";
import { LinearProgress } from "@react-md/core/progress/LinearProgress";
import { Typography } from "@react-md/core/typography/Typography";
import CloseIcon from "@react-md/material-icons/CloseIcon";
import { filesize } from "filesize";
import { type HTMLAttributes, type ReactElement, useId } from "react";

import styles from "./FilePreviewCard.module.scss";
import { SimpleFilePreview } from "./SimpleFilePreview.jsx";

export interface FilePreviewCardProps
  extends HTMLAttributes<HTMLDivElement>,
    Omit<FileUploadStats, "key"> {
  fileKey: string;
  result?: FileReaderResult;
  remove: FileUploadActions["remove"];
}

export function FilePreviewCard({
  file,
  fileKey,
  result,
  status,
  progress,
  remove,
  ...props
}: FilePreviewCardProps): ReactElement {
  const { name, size } = file;
  const titleId = useId();

  return (
    <Card {...props} aria-labelledby={titleId} role="region">
      <CardHeader
        afterAddon={
          <Button
            aria-label="Remove"
            buttonType="icon"
            onClick={() => remove(fileKey)}
          >
            <CloseIcon />
          </Button>
        }
      >
        <CardTitle id={titleId} type="subtitle-2" textOverflow="nowrap">
          {name}
        </CardTitle>
        <CardSubtitle>{filesize(size).toString()}</CardSubtitle>
      </CardHeader>
      <CardContent className={styles.content}>
        {status !== "complete" && (
          <>
            <LinearProgress
              aria-label="File upload"
              value={progress}
              className={styles.progress}
            />
            <Typography textAlign="center" type="headline-4" as="p">
              Uploading...
            </Typography>
          </>
        )}
        {status === "complete" && (
          <SimpleFilePreview result={result} file={file} />
        )}
      </CardContent>
    </Card>
  );
}
@use "everything";

.content {
  @include everything.icon-set-var(size, 3rem);

  position: relative;
}

.progress {
  @include everything.progress-set-var(linear-size, 0.5rem);

  left: 0;
  position: absolute;
  right: 0;
  top: 0;
}
"use client";

import { box } from "@react-md/core/box/styles";
import {
  type FileReaderResult,
  isImageFile,
  isVideoFile,
} from "@react-md/core/files/utils";
import { objectFit } from "@react-md/core/objectFit";
import { ResponsiveItemOverlay } from "@react-md/core/responsive-item/ResponsiveItemOverlay";
import { Typography } from "@react-md/core/typography/Typography";
import { useToggle } from "@react-md/core/useToggle";
import ErrorIcon from "@react-md/material-icons/ErrorIcon";
import { type ReactElement } from "react";

const THREE_MB = 3 * 1024 * 1024;

export interface SimpleFilePreviewProps {
  result?: FileReaderResult;
  file: File;
}

export function SimpleFilePreview(props: SimpleFilePreviewProps): ReactElement {
  const { file, result } = props;
  const isGifLike = file.size < THREE_MB;
  const { toggled: error, enable: handleError } = useToggle();

  return (
    <>
      {(typeof result !== "string" || error) && (
        <ResponsiveItemOverlay
          position="middle"
          className={box({ stacked: true, disablePadding: true })}
        >
          <ErrorIcon theme="error" />
          <Typography>
            {!error
              ? "I did not set up a preview for this file type."
              : "Your Browser is unable to preview this file."}
          </Typography>
        </ResponsiveItemOverlay>
      )}
      {typeof result === "string" && (
        <>
          {isImageFile(file) && (
            <img
              src={result}
              alt=""
              onError={handleError}
              className={objectFit()}
            />
          )}
          {isVideoFile(file) && (
            <video
              muted
              controls={!isGifLike && !error}
              loop={isGifLike}
              autoPlay={isGifLike}
              onError={handleError}
              className={objectFit()}
            >
              <source src={result} type={file.type || "video/webm"} />
            </video>
          )}
        </>
      )}
    </>
  );
}
"use client";

import { Button } from "@react-md/core/button/Button";
import { Dialog } from "@react-md/core/dialog/Dialog";
import { DialogContent } from "@react-md/core/dialog/DialogContent";
import { DialogFooter } from "@react-md/core/dialog/DialogFooter";
import { DialogHeader } from "@react-md/core/dialog/DialogHeader";
import { DialogTitle } from "@react-md/core/dialog/DialogTitle";
import { type FileValidationError } from "@react-md/core/files/validation";
import { type ReactElement, useEffect, useState } from "react";

import { ErrorRenderer } from "./ErrorRenderer.jsx";

export interface FileUploadErrorModalProps {
  errors: readonly FileValidationError<never>[];
  clearErrors(): void;
}

export function FileUploadErrorModal({
  errors,
  clearErrors,
}: FileUploadErrorModalProps): ReactElement {
  // Having the visibility being derived on the `errors.length > 0` would make
  // it so the errors are cleared during the exit animation. To fix this, keep a
  // separate `visible` state and set it to `true` whenever a new error is
  // added. When the modal is closed, set the `visible` state to false and wait
  // until the modal has closed before clearing the errors.
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    setVisible(errors.length > 0);
  }, [errors]);

  const onRequestClose = (): void => {
    setVisible(false);
  };

  return (
    <Dialog
      aria-labelledby="error-modal-title"
      role="alertdialog"
      modal
      onRequestClose={onRequestClose}
      visible={visible}
      onExited={clearErrors}
    >
      <DialogHeader>
        <DialogTitle id="error-modal-title">File Upload Errors</DialogTitle>
      </DialogHeader>
      <DialogContent>
        {errors.map((error) => (
          <ErrorRenderer key={error.key} error={error} />
        ))}
      </DialogContent>
      <DialogFooter>
        <Button onClick={onRequestClose}>Okay</Button>
      </DialogFooter>
    </Dialog>
  );
}
import {
  type FileValidationError,
  isFileSizeError,
} from "@react-md/core/files/validation";
import { ListItemChildren } from "@react-md/core/list/ListItemChildren";
import { Typography } from "@react-md/core/typography/Typography";
import { filesize } from "filesize";
import { Fragment, type ReactElement } from "react";

import { ErrorHeader } from "./ErrorHeader.jsx";

export interface ErrorRendererProps {
  error: FileValidationError<never>;
}

export function ErrorRenderer({ error }: ErrorRendererProps): ReactElement {
  if ("files" in error) {
    const { key, files } = error;
    return (
      <Fragment key={key}>
        <ErrorHeader error={error} />
        <Typography type="subtitle-1" as="ul">
          {files.map((file, i) => (
            <li key={i}>
              <ListItemChildren
                primaryText={file.name}
                secondaryText={
                  isFileSizeError(error) && filesize(file.size).toString()
                }
              />
            </li>
          ))}
        </Typography>
      </Fragment>
    );
  }

  // error
  /* ^ is a {@link FileAccessError} */
  return (
    <Typography margin="none">
      File access is restricted. Try a different file or folder.
    </Typography>
  );
}
import {
  type FileExtensionError,
  type FileSizeError,
  type TooManyFilesError,
  isFileSizeError,
  isTooManyFilesError,
} from "@react-md/core/files/validation";
import { Typography } from "@react-md/core/typography/Typography";
import { filesize } from "filesize";
import { type ReactElement } from "react";

interface ErrorHeaderProps {
  error: TooManyFilesError | FileSizeError | FileExtensionError;
}

export function ErrorHeader({ error }: ErrorHeaderProps): ReactElement {
  if (isFileSizeError(error)) {
    const { type } = error;
    const limit = filesize(error.limit);
    if (type === "total") {
      return (
        <Typography type="subtitle-2" margin="none">
          {`Unable to upload the following files due to total upload size limit (${limit})`}
        </Typography>
      );
    }

    const range = type === "min" ? "greater" : "less";
    return (
      <Typography type="subtitle-2" margin="none">
        {`Unable to upload the following files because files must be ${range} than ${limit}`}
      </Typography>
    );
  }
  if (isTooManyFilesError(error)) {
    const { limit } = error;
    return (
      <Typography type="subtitle-2" margin="none">
        {`Unable to upload the following files due to total files allowed limit (${limit})`}
      </Typography>
    );
  }

  const { extensions } = error;
  return (
    <Typography type="subtitle-2" margin="none">
      {`Invalid file extension. Must be one of ${extensions.join(", ")}`}
    </Typography>
  );
}

Server Uploads

If the files should be uploaded to the server instead of previewed in the browser, it is recommended to set the getFileParser option to always "readAsArrayBuffer". Here is a small example for how you might want to upload files to the server using this hook.

This example also showcases some other UI that can be rendered by utilizing the data returned by this hook.

  1. Remaining File 1
  2. Remaining File 2
  3. Remaining File 3
  4. Remaining File 4
  5. Remaining File 5
0 B / 10.74 GB
"use client";

import { Box } from "@react-md/core/box/Box";
import { Button } from "@react-md/core/button/Button";
import { Card } from "@react-md/core/card/Card";
import { CardContent } from "@react-md/core/card/CardContent";
import { FileInput } from "@react-md/core/files/FileInput";
import { useFileUpload } from "@react-md/core/files/useFileUpload";
import {
  type CompletedFileUploadStats,
  getSplitFileUploads,
} from "@react-md/core/files/utils";
import { Form } from "@react-md/core/form/Form";
import { FormMessage } from "@react-md/core/form/FormMessage";
import { FormMessageCounter } from "@react-md/core/form/FormMessageCounter";
import { List } from "@react-md/core/list/List";
import { ListItem } from "@react-md/core/list/ListItem";
import {
  DEFAULT_OVERLAY_CLASSNAMES,
  DEFAULT_OVERLAY_TIMEOUT,
} from "@react-md/core/overlay/styles";
import { LinearProgress } from "@react-md/core/progress/LinearProgress";
import { CSSTransition } from "@react-md/core/transition/CSSTransition";
import { Typography } from "@react-md/core/typography/Typography";
import { useAsyncFunction } from "@react-md/core/useAsyncFunction";
import { useDropzone } from "@react-md/core/useDropzone";
import { randomInt } from "@react-md/core/utils/randomInt";
import { wait } from "@react-md/core/utils/wait";
import CheckCircleIcon from "@react-md/material-icons/CheckCircleIcon";
import CloseIcon from "@react-md/material-icons/CloseIcon";
import FileUploadIcon from "@react-md/material-icons/FileUploadIcon";
import WatchIcon from "@react-md/material-icons/WatchIcon";
import { cnb } from "cnbuilder";
import { filesize } from "filesize";
import { type ReactElement, useState } from "react";

import { FileUploadErrorModal } from "@/components/FileUploadErrorModal/FileUploadErrorModal.jsx";

import styles from "./ServerUploadExample.module.scss";

export default function ServerUploadExample(): ReactElement {
  const {
    stats,
    errors,
    clearErrors,
    accept,
    onDrop,
    onChange,
    reset,
    remove,
    totalBytes,
    totalFiles,
  } = useFileUpload({
    // this makes it so that only one `uploading` files can be active at a time
    // and the remaining files are put in the `pending` queue
    concurrency: 1,
    extensions,
    maxFiles,
    maxFileSize,
    getFileParser: () => "readAsArrayBuffer",
  });
  const { pending, uploading, complete } = getSplitFileUploads(stats);
  const { dropzoneHandlers, isOver, isDragging } = useDropzone({ onDrop });
  const { progress, isUploading, onSubmit, resetProgress } =
    useFakeServerUpload(complete, totalBytes);
  const roundedSize = Math.min(maxFileSize, Math.round(totalBytes));
  const percentage = (totalBytes / maxFileSize) * 100;

  return (
    <Card className={styles.card} fullWidth>
      <CardContent>
        <Form onReset={reset} onSubmit={onSubmit}>
          <List
            ordered
            {...(!isUploading && dropzoneHandlers)}
            className={cnb(
              styles.list,
              isOver && styles.dragover,
              (isOver || isDragging) && styles.dragging,
            )}
          >
            {stats.map((stat) => (
              <ListItem
                key={stat.key}
                presentational
                leftAddon={
                  stat.status === "pending" ? (
                    <WatchIcon />
                  ) : stat.status === "uploading" ? (
                    <FileUploadIcon />
                  ) : (
                    <CheckCircleIcon theme="success" />
                  )
                }
                rightAddon={
                  <Button
                    onClick={() => remove(stat.key)}
                    buttonType="icon"
                    aria-label="Remove"
                  >
                    <CloseIcon />
                  </Button>
                }
                secondaryText={filesize(stat.file.size)}
              >
                {stat.file.name}
              </ListItem>
            ))}
            {Array.from(
              { length: Math.max(0, maxFiles - totalFiles) },
              (_, i) => (
                <ListItem
                  key={i}
                  height="extra-large"
                  disabled
                  disabledOpacity
                  leftAddon={<FileUploadIcon />}
                >
                  {`Remaining File ${i + 1 + totalFiles}`}
                </ListItem>
              ),
            )}
          </List>
          <FileInput
            onChange={onChange}
            accept={accept}
            multiple
            disabled={isUploading || totalFiles > maxFiles}
            className={styles.upload}
          >
            Upload <span className={styles.phoneHidden}>or Drag and Drop</span>
          </FileInput>
          <LinearProgress
            aria-label="Upload limit"
            aria-describedby="total-size-allowed-counter"
            value={roundedSize}
            max={maxFileSize}
            className={cnb(percentage >= 70 && styles.progress)}
            theme={
              percentage >= 85
                ? "error"
                : percentage >= 70
                  ? "warning"
                  : undefined
            }
          />
          <FormMessage
            id="total-size-allowed-counter"
            theme="none"
            disableWrap
            error={totalBytes > maxFileSize}
          >
            <FormMessageCounter>
              {`${filesize(totalBytes)} / ${filesize(maxFileSize)}`}
            </FormMessageCounter>
          </FormMessage>
          <Box justify="end" fullWidth disablePadding>
            <Button
              type="reset"
              theme="warning"
              themeType="contained"
              disabled={isUploading}
            >
              Reset
            </Button>
            <Button
              type="submit"
              disabled={
                isUploading ||
                !!errors.length ||
                !complete.length ||
                !!pending.length ||
                !!uploading.length
              }
              theme="primary"
              themeType="contained"
            >
              Submit
            </Button>
          </Box>
          {typeof progress === "number" && (
            <LinearProgress
              aria-label="Upload Progress"
              value={progress}
              max={totalBytes}
              className={styles.uploadProgress}
            />
          )}
          <CSSTransition
            transitionIn={progress === totalBytes}
            temporary
            timeout={{ enter: 3000 + DEFAULT_OVERLAY_TIMEOUT }}
            classNames={DEFAULT_OVERLAY_CLASSNAMES}
            onEntered={() => {
              reset();
              resetProgress();
            }}
          >
            <Typography>Upload Complete! Resetting in 3 seconds.</Typography>
          </CSSTransition>
          <FileUploadErrorModal errors={errors} clearErrors={clearErrors} />
        </Form>
      </CardContent>
    </Card>
  );
}

const extensions = [
  "svg",
  "jpeg",
  "jpg",
  "png",
  "apng",
  "mkv",
  "mp4",
  "mpeg",
  "mpg",
  "webm",
  "mov",
];

const TEN_GIGABYTE = 10 * 1024 * 1024 * 1024;
const maxFiles = 5;
const maxFileSize = TEN_GIGABYTE;

interface FakeServerUploadImplementation {
  onSubmit: () => Promise<void>;
  progress: number | undefined;
  isUploading: boolean;
  resetProgress: () => void;
}

function useFakeServerUpload(
  complete: readonly CompletedFileUploadStats[],
  totalSize: number,
): FakeServerUploadImplementation {
  const { handleAsync, pending } = useAsyncFunction();
  const [progress, setProgress] = useState<number | undefined>(undefined);

  return {
    onSubmit: handleAsync(async () => {
      // you might do something like this:
      // const data = new FormData();
      // complete.forEach(({ file }) => {
      //   data.append("files[]", file, file.name);
      // });
      //
      // await fetch("/api/file-upload", {
      //   method: "POST",
      //   body: data,
      // });

      // but I don't want to implement a server upload here, so instead here's
      // a fake upload with progress
      setProgress(0);
      let current = 0;
      const chunks = getRandomUploadSizes(
        totalSize,
        randomInt({ min: 5, max: 15 }),
      );
      for (const bytes of chunks) {
        await wait(randomInt({ min: 15, max: 300 }));

        current += bytes;
        setProgress(Math.min(totalSize, current));
      }
      // just in case the random upload sizes failed
      setProgress(totalSize);
    }),
    progress,
    isUploading: pending || typeof progress === "number",
    resetProgress: () => setProgress(undefined),
  };
}

function getRandomUploadSizes(
  totalSize: number,
  chunks = 10,
): readonly number[] {
  const numbers = Array.from({ length: chunks }, () => Math.random());
  const sum = numbers.reduce((total, n) => total + n, 0);
  const scale = totalSize / sum;

  return numbers.map((n) => Math.round(n * scale));
}

Press Enter to start editing.

@use "everything";

.card {
  max-width: 30rem;
}

.list {
  @include everything.interaction-outline();
  position: relative;
}

.dragging {
  @include everything.interaction-focus-styles($disable-background: true);
}

.dragover {
  @include everything.interaction-set-var(
    focus-color,
    everything.theme-get-var(success-color)
  );
}

.upload {
  margin: 1rem 0;
  width: 100%;
}

.phoneHidden {
  @include everything.phone-media {
    display: none;
  }
}

.progress {
  @include everything.progress-set-var(color, currentcolor);
}

.uploadProgress {
  margin-top: 1rem;
}

Press Enter to start editing.

"use client";

import { Button } from "@react-md/core/button/Button";
import { Dialog } from "@react-md/core/dialog/Dialog";
import { DialogContent } from "@react-md/core/dialog/DialogContent";
import { DialogFooter } from "@react-md/core/dialog/DialogFooter";
import { DialogHeader } from "@react-md/core/dialog/DialogHeader";
import { DialogTitle } from "@react-md/core/dialog/DialogTitle";
import { type FileValidationError } from "@react-md/core/files/validation";
import { type ReactElement, useEffect, useState } from "react";

import { ErrorRenderer } from "./ErrorRenderer.jsx";

export interface FileUploadErrorModalProps {
  errors: readonly FileValidationError<never>[];
  clearErrors(): void;
}

export function FileUploadErrorModal({
  errors,
  clearErrors,
}: FileUploadErrorModalProps): ReactElement {
  // Having the visibility being derived on the `errors.length > 0` would make
  // it so the errors are cleared during the exit animation. To fix this, keep a
  // separate `visible` state and set it to `true` whenever a new error is
  // added. When the modal is closed, set the `visible` state to false and wait
  // until the modal has closed before clearing the errors.
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    setVisible(errors.length > 0);
  }, [errors]);

  const onRequestClose = (): void => {
    setVisible(false);
  };

  return (
    <Dialog
      aria-labelledby="error-modal-title"
      role="alertdialog"
      modal
      onRequestClose={onRequestClose}
      visible={visible}
      onExited={clearErrors}
    >
      <DialogHeader>
        <DialogTitle id="error-modal-title">File Upload Errors</DialogTitle>
      </DialogHeader>
      <DialogContent>
        {errors.map((error) => (
          <ErrorRenderer key={error.key} error={error} />
        ))}
      </DialogContent>
      <DialogFooter>
        <Button onClick={onRequestClose}>Okay</Button>
      </DialogFooter>
    </Dialog>
  );
}
import {
  type FileValidationError,
  isFileSizeError,
} from "@react-md/core/files/validation";
import { ListItemChildren } from "@react-md/core/list/ListItemChildren";
import { Typography } from "@react-md/core/typography/Typography";
import { filesize } from "filesize";
import { Fragment, type ReactElement } from "react";

import { ErrorHeader } from "./ErrorHeader.jsx";

export interface ErrorRendererProps {
  error: FileValidationError<never>;
}

export function ErrorRenderer({ error }: ErrorRendererProps): ReactElement {
  if ("files" in error) {
    const { key, files } = error;
    return (
      <Fragment key={key}>
        <ErrorHeader error={error} />
        <Typography type="subtitle-1" as="ul">
          {files.map((file, i) => (
            <li key={i}>
              <ListItemChildren
                primaryText={file.name}
                secondaryText={
                  isFileSizeError(error) && filesize(file.size).toString()
                }
              />
            </li>
          ))}
        </Typography>
      </Fragment>
    );
  }

  // error
  /* ^ is a {@link FileAccessError} */
  return (
    <Typography margin="none">
      File access is restricted. Try a different file or folder.
    </Typography>
  );
}
import {
  type FileExtensionError,
  type FileSizeError,
  type TooManyFilesError,
  isFileSizeError,
  isTooManyFilesError,
} from "@react-md/core/files/validation";
import { Typography } from "@react-md/core/typography/Typography";
import { filesize } from "filesize";
import { type ReactElement } from "react";

interface ErrorHeaderProps {
  error: TooManyFilesError | FileSizeError | FileExtensionError;
}

export function ErrorHeader({ error }: ErrorHeaderProps): ReactElement {
  if (isFileSizeError(error)) {
    const { type } = error;
    const limit = filesize(error.limit);
    if (type === "total") {
      return (
        <Typography type="subtitle-2" margin="none">
          {`Unable to upload the following files due to total upload size limit (${limit})`}
        </Typography>
      );
    }

    const range = type === "min" ? "greater" : "less";
    return (
      <Typography type="subtitle-2" margin="none">
        {`Unable to upload the following files because files must be ${range} than ${limit}`}
      </Typography>
    );
  }
  if (isTooManyFilesError(error)) {
    const { limit } = error;
    return (
      <Typography type="subtitle-2" margin="none">
        {`Unable to upload the following files due to total files allowed limit (${limit})`}
      </Typography>
    );
  }

  const { extensions } = error;
  return (
    <Typography type="subtitle-2" margin="none">
      {`Invalid file extension. Must be one of ${extensions.join(", ")}`}
    </Typography>
  );
}

Parameters

interface FileUploadOptions<E extends HTMLElement, CustomError = never>
  extends FileUploadHandlers<E>,
    FileValidationOptions {
  /**
   * Setting this value to a number greater than `0` will update the browser
   * upload process to queue the uploads in chunks instead of all at once. This
   * can help prevent the browser from freezing if dealing with large files that
   * are being converted to data urls.
   *
   * @defaultValue `-1`
   */
  concurrency?: number;

  /** {@inheritDoc FilesValidator} */
  validateFiles?: FilesValidator<CustomError>;
  /** {@inheritDoc GetFileParser} */
  getFileParser?: GetFileParser;
}

export interface FileUploadHandlers<E extends HTMLElement> {
  onDrop?: DragEventHandler<E>;
  onChange?: ChangeEventHandler<HTMLInputElement>;
}

export interface FileValidationOptions {
  /**
   * If the number of files should be limited, set this value to a number
   * greater than `0`.
   *
   * Note: This still allows "infinite" files when set to `0` since the
   * `<input>` element should normally be set to `disabled` if files should not
   * be able to be uploaded.
   *
   * @defaultValue `-1`
   */
  maxFiles?: number;

  /**
   * An optional minimum file size to enforce for each file. This will only be
   * used when it is greater than `0`.
   *
   * @defaultValue `-1`
   */
  minFileSize?: number;

  /**
   * An optional maximum file size to enforce for each file. This will only be
   * used when it is greater than `0`.
   *
   * @defaultValue `-1`
   */
  maxFileSize?: number;

  /**
   * An optional list of extensions to enforce when uploading files.
   *
   * Note: The extensions and file names will be compared ignoring case.
   *
   * @example Only Allow Images
   * ```ts
   * const extensions = ["png", "jpeg", "jpg", "gif"];
   * ```
   */
  extensions?: readonly string[];

  /** {@inheritDoc IsValidFileName} */
  isValidFileName?: IsValidFileName;

  /**
   * An optional total file size to enforce when the {@link maxFiles} option is
   * not set to `1`.
   *
   * @defaultValue `-1`
   */
  totalFileSize?: number;
}

Returns

An object with the following definition:

export interface FileUploadState<CustomError = never> {
  /**
   * All the files that have been validated and are either:
   * - pending upload
   * - uploading
   * - complete
   *
   * Each key in this object is the {@link BaseFileUploadStats.key} generated
   * once the upload starts pending.
   */
  stats: Readonly<Record<string, Readonly<FileUploadStats>>>;

  /**
   * A list of validation errors that have occurred before starting the upload
   * process.
   *
   * @see {@link FileAccessError}
   * @see {@link TooManyFilesError}
   * @see {@link FileValidationError}
   */
  errors: readonly FileValidationError<CustomError>[];
}

export interface FileUploadHookReturnValue<
  E extends HTMLElement = HTMLElement,
  CustomError = never,
> extends FileUploadActions,
    Required<FileUploadHandlers<E>> {
  errors: readonly FileValidationError<CustomError>[];

  /**
   * A list of all the {@link FileUploadStats}.
   *
   * @see {@link getSplitFileUploads} for separating by status
   */
  stats: readonly Readonly<FileUploadStats>[];

  /**
   * The total number of bytes for all the files that exist in the
   * {@link stats} list.
   */
  totalBytes: number;

  /**
   * The total number of files in the {@link stats} list.
   */
  totalFiles: number;

  /**
   * An `accept` string that can be passed to the {@link FileInput} component
   * when the {@link FileValidationOptions.extensions} list has been provided to
   * limit which files the OS will _attempt_ to allow access to.
   *
   * @example Simple example
   * ```ts
   * const extensions = ['pdf', 'docx', 'ppt'];
   * const { accept } = useFileUpload({ extensions, ...others });
   *
   * expect(accept).toBe("*.pdf,*.docx,*.ppt")
   * ```
   *
   * @defaultValue `"*"`
   */
  accept: string;
}

export interface FileUploadActions {
  /**
   * Reset everything related to uploads ensuring that all file readers have
   * been aborted.
   */
  reset: () => void;

  /**
   * Removes all the errors that exist in state without canceling any of the
   * uploads already in progress.
   */
  clearErrors: () => void;

  /**
   * This function is used to cancel pending and uploading files or removing
   * completed files.
   *
   * @param keyOrKeys - A single or list of {@link BaseFileUploadStats.key} to
   * remove from state.
   */
  remove: (keyOrKeys: string | readonly string[]) => void;
}

Errors

TooManyFilesError

This error will occur when the user attempts to upload more than the total number of files allowed. The TooManyFilesError error will contain:

FileSizeError

This error will occur when the user attempts to upload a file that is either:

The FileSizeError error will contain:

FileExtensionError

This error will occur when the user attempts to upload a file that does not end with one of the supported extensions. The FileExtensionError error will contain:

GenericFileError

This is a helper error class that is used by the other error types and probably won't be triggered. The GenericFileError error will contain:

FileAccessError

This error will occur if the user attempts to drag and drop a file from a shared directory that they do not have access to. This error is very uncommon.

Utils

validateFiles

function validateFiles<CustomError>(
  files: readonly File[],
  options: FilesValidationOptions
): ValidatedFilesResult<CustomError>;

This is the default implementation for validating files with the useFileUpload hook.

getSplitFileUploads

function getSplitFileUploads(
  stats: readonly FileUploadStats[]
): SplitFileUploads;

The getSplitFileUploads can be used to split the stats returned by the useFileUpload hook into the pending, uploading, and complete groups to create a dynamic UI for file uploads.

Main Usage

import { FileUpload } from "@react-md/core/files/FileUpload";
import { useFileUpload } from "@react-md/core/files/useFileUpload";
import { getSplitFileUploads } from "@react-md/core/files/utils";

function Example() {
  const { stats, errors, accept, onChange } = useFileUpload();
  const { pending, uploading, completed } = getSplitFileUploads(stats);

  return (
    <>
      <FileUpload accept={accept} onChange={onChange} />
      {pending.map(({ key, file, progress, status }) => {
        // pretend some UI for each pending item with the provided data
        return null;
      })}
      {uploading.map(({ file, key, progress, status }) => {
        // pretend some UI for each uploading item with the provided data
        return null;
      })}
      {complete.map(({ file, key, progress, result, status }) => {
        // pretend some UI for each complete item with the provided data
        return null;
      })}
    </>
  );
}

Parameters

export type FileUploadStats =
  | ProcessingFileUploadStats
  | CompletedFileUploadStats;

export interface ProcessingFileUploadStats extends BaseFileUploadStats {
  status: "pending" | "uploading";
}

export interface CompletedFileUploadStats extends BaseFileUploadStats {
  status: "complete";
  result: FileReader["result"];
}

export interface BaseFileUploadStats {
  key: string;
  file: File;
  progress: number;
}

Returns

An object with the following definition:

export interface SplitFileUploads {
  readonly pending: readonly ProcessingFileUploadStats[];
  readonly uploading: readonly ProcessingFileUploadStats[];
  readonly complete: readonly CompletedFileUploadStats[];
}

isValidFileName

export type IsValidFileName = (
  file: File,
  extensionRegExp: RegExp | undefined,
  extensions: readonly string[]
) => boolean;

export const isValidFileName: IsValidFileName;

This is just the default implementation for checking if a provided file is valid based on the provided extensions.

File Type Checkers

The file type checkers are not guaranteed to be 100% correct and all have the following signature:

type IsFileType = (file: File) => boolean;

Type Guards

The following type guards are provided to determine how to display the errors and all have the following signature:

type IsKnownError<
  CustomError extends object,
  ExpectedError extends FileValidationError<never>,
> = (error: FileValidationError<CustomError>) => error is ExpectedError;