File Input

Categorystyled
ArchitectureReact Client Component
Dependencies

Usage

Controls
.jpeg, .jpg, .png, .webp
import { useState } from "react";

//----- Sub-Components -----//
import { DemoWrapper } from "@/demos/react/demoWrapper";
import { FileInput } from "@/components/react/styled/fileInput";

//----- Types -----//
import type { ControlValuesInterface } from "./controls";

//----- Internal Constants -----//
const acceptedTypes = {
  image: ["image/jpeg", "image/jpg", "image/png", "image/webp"],
  video: ["video/mp4", "video/mpeg", "video/ogg", "video/webm"],
};

export const FileInputDemo = ({ code }: { code: string }) => {
  const [controlValues, setControlValues] = useState<ControlValuesInterface>({
    acceptedTypes: "image",
    multiple: false,
    label: "File input",
    description: "",
    dropText: "Drop here",
    noPreviewText: "Sorry this item doesn't provide a preview",
  });

  return (
    <DemoWrapper
      code={code}
      controls={{
        acceptedTypes: {
          type: "enum",
          values: Object.keys(acceptedTypes),
          defaultValue: controlValues["acceptedTypes"] as string,
          id: "acceptedTypes",
          label: "Accepted Types",
          gridSpan: 2,
        },
        multiple: {
          type: "boolean",
          defaultValue: controlValues["multiple"] as boolean,
          id: "multiple",
          label: "multiple",
        },
        label: {
          type: "string",
          defaultValue: controlValues["label"] as string,
          id: "label",
          label: "Label",
        },
        description: {
          type: "string",
          defaultValue: controlValues["description"] as string,
          id: "description",
          label: "Description",
        },
        dropText: {
          type: "string",
          defaultValue: controlValues["dropText"] as string,
          id: "dropText",
          label: "Drop Text",
        },
        noPreviewText: {
          type: "string",
          defaultValue: controlValues["noPreviewText"] as string,
          id: "noPreviewText",
          label: "No Preview Text",
        },
      }}
      setState={setControlValues}
    >
      <FileInput
        className="w-full md:w-[30vw]"
        multiple={controlValues["multiple"] as boolean}
        dropText={controlValues["dropText"] as string}
        noPreviewText={controlValues["noPreviewText"] as string}
        label={controlValues["label"] as string}
        description={controlValues["description"] as string}
        acceptedTypes={
          acceptedTypes[
            controlValues["acceptedTypes"] as keyof typeof acceptedTypes
          ]
        }
      />
    </DemoWrapper>
  );
};

Install Instructions

Copy and paste the code into that file

import {
  type ComponentPropsWithoutRef,
  type ReactNode,
  type ChangeEventHandler,
  type MouseEventHandler,
  type DragEventHandler,
  type SetStateAction,
  type Dispatch,
  useCallback,
  useRef,
  useState,
  useEffect,
} from "react";

//----- CVA -----//
import { cva } from "class-variance-authority";

//----- TW Merge -----//
import { twMerge } from "tailwind-merge";

//----- Icons -----//
import { FolderOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/solid";

//----- Interfaces -----//
interface FileInputProps extends ComponentPropsWithoutRef<"input"> {
  label?: string;
  buttonChildren?: ReactNode;
  description?: ReactNode;
  dropText?: string;
  acceptedTypes: string[];
  maxSize?: number;
  noPreviewText?: string;
  onFiles?: Dispatch<SetStateAction<File[]>>;
}

type FileInputFile = {
  file: File;
  thumbnail: string;
};

//----- Internal Utils -----//
const formatAcceptedTypes = (types: string[]) => {
  const accept: string[] = [];

  types.forEach((type) => accept.push("." + type.slice(type.indexOf("/") + 1)));

  return accept.join(",");
};

//----- Class Names -----//
const thumbnailButtons = cva(
  [
    "block",
    "flex-shrink-0",
    "aspect-square",
    "w-8",
    "overflow-hidden",
    "rounded-lg",
    "backdrop-blur-md",
    "transition-colors",
    "overflow-hidden",
    "border-transparent",
    "border",
    "dark:data-[focused=true]:border-primary-400",
    "data-[focused=true]:border-primary-600",
    "md:rounded-[calc(theme(borderRadius.2xl)_-_theme(spacing.2))]",
  ],
  {
    variants: {
      padding: {
        true: ["p-2", "border-0"],
        false: [],
      },
      color: {
        theme: [
          "bg-neutral-200",
          "dark:bg-neutral-800",
          "hover:bg-neutral-300",
          "hover:dark:bg-neutral-700",
          "active:bg-neutral-400",
          "active:dark:bg-neutral-600",
        ],
        red: [
          "bg-red-100/50",
          "dark:bg-red-900/50",
          "hover:bg-red-200/75",
          "hover:dark:bg-red-800/75",
          "active:bg-red-200",
          "active:dark:bg-red-800",
        ],
        green: [
          "bg-green-100/50",
          "dark:bg-green-900/50",
          "hover:bg-green-200/75",
          "hover:dark:bg-green-800/75",
          "active:bg-green-200",
          "active:dark:bg-green-800",
        ],
      },
    },
    defaultVariants: {
      padding: true,
      color: "theme",
    },
  }
);

const thumbnailPreviews = cva([
  "absolute",
  "top-0",
  "left-0",
  "w-full",
  "h-full",
  "object-center",
  "object-cover",
  "z-10",
  "flex",
  "flex-col",
  "justify-center",
  "items-center",
  "backdrop-blur-xl",
  "bg-neutral-200",
  "dark:bg-neutral-800",
]);

//----- Internal Components -----//
const DefaultButtonChildren = () => {
  return (
    <p
      className={[
        "not-prose",
        "flex",
        "flex-col",
        "justify-center",
        "items-center",
        "gap-2",
      ].join(" ")}
    >
      <span data-icon>
        <FolderOpenIcon className="w-12 h-12" />
      </span>
      <span className="text-xs opacity-75">Drag and drop, or Click</span>
    </p>
  );
};

//----- Main Component -----//
export const FileInput = ({
  label = "File input",
  buttonChildren,
  description,
  className,
  multiple,
  acceptedTypes,
  dropText = "Drop Here",
  noPreviewText = "This files doesn't have a preview",
  maxSize,
  onFiles,
  ...props
}: FileInputProps) => {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const wrapperRef = useRef<HTMLDivElement | null>(null);

  const [files, setFiles] = useState<FileInputFile[]>([]);
  const [dragging, setDragging] = useState(false);
  const [selectedFile, setSelectedFile] = useState(0);

  /** Manages the files passed to input element */
  const handleFiles = useCallback(
    (files: FileList | File[]) => {
      // Looping over new files and creating thumbnails
      const newFiles: FileInputFile[] = [];
      const externalFiles: File[] = [];

      for (const file of [...(files as unknown as File[])]) {
        // Validating size
        if (typeof maxSize !== "undefined" && file.size > maxSize) return;

        // validating types
        if (!acceptedTypes.includes(file.type)) return;

        const imgUrl = URL.createObjectURL(file);
        newFiles.push({ thumbnail: imgUrl, file });
        onFiles && externalFiles.push(file);
      }

      if (!multiple) {
        // Incase the input is for single a file only we will remove the current files
        setFiles((curr) => {
          curr.forEach(({ thumbnail }) => {
            URL.revokeObjectURL(thumbnail);
          });

          return newFiles;
        });
        return;
      } else {
        // Incase the input is for multiple files, we append the new files
        setFiles((curr) => [...curr, ...newFiles]);
      }

      onFiles && onFiles(externalFiles);
    },
    [multiple, acceptedTypes, maxSize, onFiles]
  );

  /** Proxies the click event, therefore clicks the input element when the button is clicked */
  const inputClickProxy: MouseEventHandler<HTMLButtonElement> = useCallback(
    () => inputRef.current?.click(),
    []
  );

  /** Clears the input elements */
  const clear = useCallback(() => {
    if (inputRef.current === null) return;
    inputRef.current.value = "";
    inputRef.current.files = null;
    setSelectedFile(0);

    setFiles((curr) => {
      curr.forEach(({ thumbnail }) => {
        URL.revokeObjectURL(thumbnail);
      });
      return [];
    });

    onFiles && onFiles([]);
  }, [onFiles]);

  /** Manages the files passed to input element */
  const onChange: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      const { files } = e.target;

      if (files === null || files.length === 0) return;

      handleFiles(files);
    },
    [handleFiles]
  );

  /** Handles Drop event and append file to files */
  const onDrop: DragEventHandler<HTMLDivElement> = useCallback(
    (e) => {
      e.preventDefault();
      setDragging(false);

      if (!e.dataTransfer.items) return;

      // @ts-ignore
      const files = [...e.dataTransfer.items]
        .filter((item) => item.kind === "file")
        .map((item) => item.getAsFile());

      handleFiles(files as File[]);
    },
    [handleFiles]
  );

  // Appending event listeners for when user is dragging files
  useEffect(() => {
    if (typeof window === "undefined") return;

    window.addEventListener("dragover", setDragging.bind(null, true));
    window.addEventListener("dragleave", setDragging.bind(null, false));
    window.addEventListener("dragend", setDragging.bind(null, false));
    return () => {
      window.removeEventListener("dragover", setDragging.bind(null, true));
      window.removeEventListener("dragleave", setDragging.bind(null, false));
      window.removeEventListener("dragend", setDragging.bind(null, false));
    };
  }, []);

  return (
    <div
      ref={wrapperRef}
      data-droppable={false}
      onDragOver={(e) => {
        e.preventDefault();
      }}
      onDrop={onDrop}
      className={twMerge(
        [
          "grid",
          "gap-2",
          "p-2",
          "border",
          "bg-neutral-100",
          "dark:bg-neutral-900",
          "border-neutral-200",
          "dark:border-neutral-800",
          "rounded-2xl",
          "not-prose",
          "@container",
          "data-[droppable=true]:opacity-25",
          "relative",
        ].join(" "),
        className
      )}
    >
      {
        // Overlay for when there is a file being dragged
        dragging && (
          <div
            className={[
              "absolute",
              "inset-0",
              "z-50",
              "bg-neutral-100/50",
              "dark:bg-neutral-900/50",
              "backdrop-blur-md",
              "flex",
              "justify-center",
              "items-center",
            ].join(" ")}
          >
            {dropText}
          </div>
        )
      }
      <div
        className={[
          "aspect-[4/2]",
          "@xl:aspect-[7/3]",
          "rounded-[calc(theme(borderRadius.2xl)_-_theme(spacing.2))]",
          "relative",
          "overflow-hidden",
          "flex",
          "bg-neutral-200",
          "dark:bg-neutral-800",
          "hover:bg-neutral-300",
          "hover:dark:bg-neutral-700",
          "active:bg-neutral-400",
          "active:dark:bg-neutral-600",
          "border-neutral-300",
          "dark:border-neutral-700",
          "border",
          "transition-colors",
        ].join(" ")}
      >
        {
          // Displaying the thumbnail incase the input includes some item
          files.length !== 0 &&
            (files[selectedFile]!.file.type.includes("video") ? (
              <video
                controls
                src={files[selectedFile]!.thumbnail}
                className={thumbnailPreviews()}
              />
            ) : files[selectedFile]!.file.type.includes("image") ? (
              <img
                src={files[selectedFile]!.thumbnail}
                alt={files[selectedFile]!.file.name}
                className={thumbnailPreviews()}
              />
            ) : (
              <div className={thumbnailPreviews()}>
                <p className="text-lg mb-2">{files[selectedFile]!.file.name}</p>
                <p className="text-xs opacity-75">{noPreviewText}</p>
              </div>
            ))
        }
        <input
          multiple={multiple}
          accept={formatAcceptedTypes(acceptedTypes)}
          ref={inputRef}
          onChange={onChange}
          type="file"
          className="absolute left-[50%] top-[50%] -z-10 translate-x-[-50%] translate-y-[-50%] opacity-0 w-1 h-1"
          {...props}
        />

        <button
          type="button"
          onClick={inputClickProxy}
          data-input-proxy="true"
          className={["grow", "p-6", "[&_*]:pointer-events-none"].join(" ")}
        >
          {buttonChildren ? buttonChildren : <DefaultButtonChildren />}
        </button>
      </div>
      <div className="p-2 grid gap-2 overflow-hidden">
        <div className="grid gap-2 grid-cols-[30%_65%] items-start content-start">
          {/* Main label of the input element */}
          <div className="flex flex-col">
            <label>{label}</label>
            <span className="text-sm opacity-75 text-red-600 dark:text-red-400">
              {formatAcceptedTypes(acceptedTypes).replaceAll(",", ", ")}
            </span>
          </div>
          {
            // Item controls for multi file inputs
            files.length !== 0 && (
              <div
                className={[
                  "ml-auto",
                  "flex",
                  "gap-2",
                  "max-w-full",
                  "overflow-x-scroll",
                ].join(" ")}
              >
                <button
                  type="button"
                  onClick={clear}
                  className={thumbnailButtons({ color: "red" })}
                >
                  <XMarkIcon className="w-4 h-4" />
                </button>
                {
                  // Only Displaying thumbnails incase input accepts more than one item
                  multiple &&
                    files.map(({ thumbnail, file }, index) => (
                      <button
                        type="button"
                        data-focused={selectedFile === index}
                        className={thumbnailButtons({ padding: false })}
                        key={thumbnail}
                        onClick={setSelectedFile.bind(null, index)}
                      >
                        {file.type.includes("image") ? (
                          <img
                            className="object-cover object-center w-8 h-8"
                            src={thumbnail}
                            alt={file.name}
                          />
                        ) : (
                          index + 1
                        )}
                      </button>
                    ))
                }
                {
                  // Only showing append button incase input is in multiple mode
                  multiple && (
                    <button
                      type="button"
                      data-input-proxy="true"
                      onClick={inputClickProxy}
                      className={thumbnailButtons({ color: "green" })}
                    >
                      <PlusIcon className="w-4 h-4" />
                    </button>
                  )
                }
              </div>
            )
          }
        </div>
        {
          // Only displaying description while it exits
          description && <p className="text-sm opacity-75">{description}</p>
        }
      </div>
    </div>
  );
};

Loading...