ui icon indicating copy to clipboard operation
ui copied to clipboard

enhancement request: dropzone

Open Sn0wye opened this issue 2 years ago • 11 comments

Hey chad!

Not to be boring, but there is some pretense of making a dropzone component? That'be so freaking useful!

Sn0wye avatar Apr 11 '23 13:04 Sn0wye

Looking into it.

shadcn avatar Apr 15 '23 15:04 shadcn

Adding a thumbs up to this request! Ideal case would be one which displays a preview of the uploaded file. I think Ant handles this quite well.

simon-marcus avatar Apr 17 '23 12:04 simon-marcus

Hi Sha D. Cn!

I just wanted to ask, shouldn't this issue be flagged with the new component and roadmap labels?

Cheers 👋🏻

demarchenac avatar Oct 30 '23 05:10 demarchenac

@demarchenac Agreed.

shadcn avatar Oct 30 '23 17:10 shadcn

related: https://shadcn.rails-components.com/docs/components/dropzone

kalaschnik avatar Nov 07 '23 15:11 kalaschnik

i needed a simple one today, not sure if this helps anyone but refer below:

import React, { useRef, useState } from 'react'; import { Card, CardContent } from "@/components/ui/card"; import { Icons } from '@/components/ui/icons'; import { Button } from '@/components/ui/button';

interface DropzoneProps { onChange: React.Dispatch<React.SetStateAction<string[]>>; className?: string; fileExtension?: string; }

export function Dropzone({ onChange, className, fileExtension, ...props }: DropzoneProps) { const fileInputRef = useRef<HTMLInputElement>(null); const [fileInfo, setFileInfo] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
};

const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    const { files } = e.dataTransfer;
    handleFiles(files);
};

const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { files } = e.target;
    if (files) {
        handleFiles(files);
    }
};

const handleFiles = (files: FileList) => {
    const uploadedFile = files[0];

    // Check file extension
    if (fileExtension && !uploadedFile.name.endsWith(`.${fileExtension}`)) {
        setError(`Invalid file type. Expected: .${fileExtension}`);
        return;
    }

    const fileSizeInKB = Math.round(uploadedFile.size / 1024); // Convert to KB

    const fileList = Array.from(files).map((file) => URL.createObjectURL(file));
    onChange((prevFiles) => [...prevFiles, ...fileList]);

    // Display file information
    setFileInfo(`Uploaded file: ${uploadedFile.name} (${fileSizeInKB} KB)`);
    setError(null); // Reset error state
};

const handleButtonClick = () => {
    if (fileInputRef.current) {
        fileInputRef.current.click();
    }
};

return (
    <Card
        className={`bg-muted border-dashed border-2 hover:border-muted-foreground/50 hover:cursor-pointer ${className}`}
    >
        <CardContent
            className="flex flex-col items-center justify-center px-2 py-4 text-xs space-y-2"
            onDragOver={handleDragOver}
            onDrop={handleDrop}
        >
            <Icons.import className="h-8 w-8 text-muted-foreground" />
            <div className="flex items-center justify-center text-muted-foreground">
                <span className="font-medium">Drag Files to Upload or</span>
                <Button
                    variant="ghost"
                    size="sm"
                    className="ml-auto h-8 flex space-x-2 text-xs px-0 pl-1"
                    onClick={handleButtonClick}
                >
                    Click Here
                </Button>
                <input
                    ref={fileInputRef}
                    type="file"
                    accept={`.${fileExtension}`} // Set accepted file type
                    onChange={handleFileInputChange}
                    className="hidden"
                    multiple
                />
            </div>
            {fileInfo && <p className="text-muted-foreground">{fileInfo}</p>}
            {error && <span className="text-red-500">{error}</span>}
        </CardContent>
    </Card>
);

}

step2341 avatar Nov 16 '23 07:11 step2341

import React, { useRef, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';

// Define the props expected by the Dropzone component
interface DropzoneProps {
  onChange: React.Dispatch<React.SetStateAction<string[]>>;
  className?: string;
  fileExtension?: string;
}

// Create the Dropzone component receiving props
export function Dropzone({
  onChange,
  className,
  fileExtension,
  ...props
}: DropzoneProps) {
  // Initialize state variables using the useState hook
  const fileInputRef = useRef<HTMLInputElement | null>(null); // Reference to file input element
  const [fileInfo, setFileInfo] = useState<string | null>(null); // Information about the uploaded file
  const [error, setError] = useState<string | null>(null); // Error message state


  // Function to handle drag over event
  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
  };

  // Function to handle drop event
  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    const { files } = e.dataTransfer;
    handleFiles(files);
  };

  // Function to handle file input change event
  const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { files } = e.target;
    if (files) {
      handleFiles(files);
    }
  };

  // Function to handle processing of uploaded files
  const handleFiles = (files: FileList) => {
    const uploadedFile = files[0];

    // Check file extension
    if (fileExtension && !uploadedFile.name.endsWith(`.${fileExtension}`)) {
      setError(`Invalid file type. Expected: .${fileExtension}`);
      return;
    }

    const fileSizeInKB = Math.round(uploadedFile.size / 1024); // Convert to KB

    const fileList = Array.from(files).map((file) => URL.createObjectURL(file));
    onChange((prevFiles) => [...prevFiles, ...fileList]);

    // Display file information
    setFileInfo(`Uploaded file: ${uploadedFile.name} (${fileSizeInKB} KB)`);
    setError(null); // Reset error state
  };

  // Function to simulate a click on the file input element
  const handleButtonClick = () => {
    if (fileInputRef.current) {
      fileInputRef.current.click();
    }
  };

  return (
    <Card
      className={`border-2 border-dashed bg-muted hover:cursor-pointer hover:border-muted-foreground/50 ${className}`}
      {...props}
    >
      <CardContent
        className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs"
        onDragOver={handleDragOver}
        onDrop={handleDrop}
      >
        <div className="flex items-center justify-center text-muted-foreground">
          <span className="font-medium">Drag Files to Upload or</span>
          <Button
            variant="ghost"
            size="sm"
            className="ml-auto flex h-8 space-x-2 px-0 pl-1 text-xs"
            onClick={handleButtonClick}
          >
            Click Here
          </Button>
          <input
            ref={fileInputRef}
            type="file"
            accept={`.${fileExtension}`} // Set accepted file type
            onChange={handleFileInputChange}
            className="hidden"
            multiple
          />
        </div>
        {fileInfo && <p className="text-muted-foreground">{fileInfo}</p>}
        {error && <span className="text-red-500">{error}</span>}
      </CardContent>
    </Card>
  );
}

// example usage

import { Dropzone } from '@/components/ui/dropzone';
import { useState } from 'react';

export default function Page() {
  const [files, setFiles] = useState<string[]>([]);

  return (
    <div className="sm:py-5">
      <Dropzone
        onChange={setFiles}
        className="w-full"
        fileExtension="pdf"
      />
    </div>
  );
}

Fixed formatting for easy copy-paste @step2341 dropzone and removed unknown components and given example usage

arafays avatar Dec 14 '23 00:12 arafays

@arafays Thanks for this. Would you be willing to explain two things to me, a newb to React/Typescript?

  1. Can you provide an example of how to declare/use the onChange handler?
  2. Is the Icons import from shadcn-ui? When I tried to use the shadcn-ui Icons component, I get an error on the Icons.import syntax that there is no method import. TIA!

dandubya avatar Dec 15 '23 21:12 dandubya

@dandubya sure here is the example use and No the icons is component is something @step2341 used and implemented custom I don't know where he got it from I am removing it from the comment

Here is an example of how you would use it

import { Dropzone } from '@/components/ui/dropzone';
import { useEffect, useState } from 'react';

export default function Page() {
  const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);

  // Function to handle the change in uploaded files
  const handleFileChange: React.Dispatch<React.SetStateAction<string[]>> = (
    newState: React.SetStateAction<string[]>
  ) => {
    setUploadedFiles(newState);
  };

  // remove this useEffect hook if you don't need to do anything with the uploaded files
  useEffect(() => {
    console.log(uploadedFiles);
  }, [uploadedFiles]);

  return (
    <div className="p-5">
      <div>
        <h1>File Upload</h1>
        <Dropzone
          onChange={handleFileChange} // Pass the handler function
          className="your-custom-class" // Optional: Add any custom class
          fileExtension="jpg" // Set the expected file extension
        />
        {/* Render the uploaded files remove if needed*/}
        {uploadedFiles.length > 0 && (
          <div>
            <h2>Uploaded Files:</h2>
            <ul>
              {uploadedFiles.map((file, index) => (
		<img key={index} src={file} />
              ))}
            </ul>
          </div>
        )}
      </div>
    </div>
  );
}

just remember you would need to handle form submission yourself if needed I can make a sandbox for submitting using react-hook-form

arafays avatar Dec 16 '23 01:12 arafays

@arafays Thank you!

dandubya avatar Dec 16 '23 02:12 dandubya

I have developed a dropzone component based on the suggestion provided by @step2341 . This component utilizes the default shadcn/ui input, manages forms, and integrates with React Hook Form.

import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import React, { ChangeEvent, useRef } from "react";

interface DropzoneProps
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "value" | "onChange"
  > {
  classNameWrapper?: string;
  className?: string;
  dropMessage: string;
  handleOnDrop: (acceptedFiles: FileList | null) => void;
}

const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
  (
    { className, classNameWrapper, dropMessage, handleOnDrop, ...props },
    ref
  ) => {
    const inputRef = useRef<HTMLInputElement | null>(null);
    // Function to handle drag over event
    const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      e.stopPropagation();
      handleOnDrop(null);
    };

    // Function to handle drop event
    const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      e.stopPropagation();
      const { files } = e.dataTransfer;
      if (inputRef.current) {
        inputRef.current.files = files;
        handleOnDrop(files);
      }
    };

    // Function to simulate a click on the file input element
    const handleButtonClick = () => {
      if (inputRef.current) {
        inputRef.current.click();
      }
    };
    return (
      <Card
        ref={ref}
        className={cn(
          `border-2 border-dashed bg-muted hover:cursor-pointer hover:border-muted-foreground/50`,
          classNameWrapper
        )}
      >
        <CardContent
          className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs"
          onDragOver={handleDragOver}
          onDrop={handleDrop}
          onClick={handleButtonClick}
        >
          <div className="flex items-center justify-center text-muted-foreground">
            <span className="font-medium">{dropMessage}</span>
            <Input
              {...props}
              value={undefined}
              ref={inputRef}
              type="file"
              className={cn("hidden", className)}
              onChange={(e: ChangeEvent<HTMLInputElement>) =>
                handleOnDrop(e.target.files)
              }
            />
          </div>
        </CardContent>
      </Card>
    );
  }
);

export default Dropzone;

Here's how to use it:

const defaultValues: { file: null | File } = {
    file: null,
  };
  const methods = useForm({
    defaultValues,
    shouldFocusError: true,
    shouldUnregister: false,
    shouldUseNativeValidation: false,
  });

  function handleOnDrop(acceptedFiles: FileList | null) {
    if (acceptedFiles && acceptedFiles.length > 0) {
      const allowedTypes = [
        { name: "csv", types: ["text/csv"] },
        {
          name: "excel",
          types: [
            "application/vnd.ms-excel",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
          ],
        },
      ];
      const fileType = allowedTypes.find((allowedType) =>
        allowedType.types.find((type) => type === acceptedFiles[0].type)
      );
      if (!fileType) {
        methods.setValue("file", null);
        methods.setError("file", {
          message: "File type is not valid",
          type: "typeError",
        });
      } else {
        methods.setValue("file", acceptedFiles[0]);
        methods.clearErrors("file");
      }
    } else {
      methods.setValue("file", null);
      methods.setError("file", {
        message: "File is required",
        type: "typeError",
      });
    }
  }
  return (
    <FormProvider {...methods}>
      <form
        className="flex flex-col items-center justify-center w-100 gap-2"
        onSubmit={methods.handleSubmit(handleFormSubmit)}
        noValidate
        autoComplete="off"
      >
        <FormField
          control={methods.control}
          name="file"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormControl>
                <Dropzone
                  {...field}
                  dropMessage="Drop files or click here"
                  handleOnDrop={handleOnDrop}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {methods.watch("file") && (
          <div className="flex items-center justify-center gap-3 p-4 relative">
            <FileCheck2Icon className="h-4 w-4" />
            <p className="text-sm font-medium">{methods.watch("file")?.name}</p>
          </div>
        )}
        <Button type="submit">Salve</Button>
      </form>
    </FormProvider>
  );

victorcesae avatar Dec 28 '23 19:12 victorcesae

Hey @victorcesae , first of all thank you for your component i tried using it and its renders properly but the issue is when i use it inside a dialog component from shadcn ui library after selecting the file it automatically closes the dialog component .

i dont know why and because of what the issue is causing , can you help me regarding this ?

https://github.com/shadcn-ui/ui/assets/89771105/df3bb630-e4d8-4f65-815c-3b908598e38a

this is the code i am using and this code is inside the data table component

<Dialog>
  <DialogTrigger asChild>
    <Button className="w-full" variant="secondary">
      Upload
    </Button>
  </DialogTrigger>
  <DialogContent className="sm:max-w-[425px]">
    <DialogHeader>
      <DialogTitle>Upload Certificate</DialogTitle>
      <DialogDescription>
        {`Upload Certificate for ${candidate_name}`}
      </DialogDescription>
    </DialogHeader>
    <Form {...methods}>
      <form
        className="flex flex-col items-center justify-center w-100 gap-2"
        onSubmit={methods.handleSubmit(uploadCertificate)}
        noValidate
        autoComplete="off"
      >
        <FormField
          control={methods.control}
          name="file"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormControl>
                <Dropzone
                  {...field}
                  dropMessage="Drop files or click here"
                  handleOnDrop={handleOnDrop}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {methods.watch("file") && (
          <div className="flex items-center justify-center gap-3 p-4 relative">
            <FileCheck2Icon className="h-4 w-4" />
            <p className="text-sm font-medium">
              {methods.watch("file")?.name}
            </p>
          </div>
        )}
        <Button type="submit">Save</Button>
      </form>
    </Form>
    <Progress value={uploadCertificateProgress} />
    <DialogFooter>
      <Button
        onClick={() => uploadCertificate}
        className="w-full"
        variant={"secondary"}
      >
        Upload
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Om-jannu avatar Feb 19 '24 10:02 Om-jannu

function handleOnDrop(acceptedFiles: FileList | null) { if (acceptedFiles && acceptedFiles.length > 0) { const allowedTypes = [ { name: "csv", types: ["text/csv"] }, { name: "excel", types: [ "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ], }, ]; const fileType = allowedTypes.find((allowedType) => allowedType.types.find((type) => type === acceptedFiles[0].type) ); if (!fileType) { methods.setValue("file", null); methods.setError("file", { message: "File type is not valid", type: "typeError", }); } else { methods.setValue("file", acceptedFiles[0]); methods.clearErrors("file"); } } else { methods.setValue("file", null); methods.setError("file", { message: "File is required", type: "typeError", }); } }

I don't see how can you get this error maybe your are calling the closeModal function, that i think is uploadCertificate inside the handleDrop function

I actually try your code but i don't get this error only when uptting (uploadCertificate or closeModal) functions inside handleDrop

victorcesae avatar Feb 19 '24 22:02 victorcesae

thank you for your response , ill try making the changes u mentioned

function handleOnDrop(acceptedFiles: FileList | null) { if (acceptedFiles && acceptedFiles.length > 0) { const allowedTypes = [ { name: "csv", types: ["text/csv"] }, { name: "excel", types: [ "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ], }, ]; const fileType = allowedTypes.find((allowedType) => allowedType.types.find((type) => type === acceptedFiles[0].type) ); if (!fileType) { methods.setValue("file", null); methods.setError("file", { message: "File type is not valid", type: "typeError", }); } else { methods.setValue("file", acceptedFiles[0]); methods.clearErrors("file"); } } else { methods.setValue("file", null); methods.setError("file", { message: "File is required", type: "typeError", }); } }

I don't see how can you get this error maybe your are calling the closeModal function, that i think is uploadCertificate inside the handleDrop function

I actually try your code but i don't get this error only when uptting (uploadCertificate or closeModal) functions inside handleDrop

Om-jannu avatar Feb 22 '24 18:02 Om-jannu

Hey! I think we all forgot about unbind URL URL.revokeObjectURL(objectURL);

tomaszhofman avatar Mar 04 '24 08:03 tomaszhofman

Hey @victorcesae , first of all thank you for your component i tried using it and its renders properly but the issue is when i use it inside a dialog component from shadcn ui library after selecting the file it automatically closes the dialog component .

i dont know why and because of what the issue is causing , can you help me regarding this ?

dropzone.issue.mp4 this is the code i am using and this code is inside the data table component

<Dialog>
  <DialogTrigger asChild>
    <Button className="w-full" variant="secondary">
      Upload
    </Button>
  </DialogTrigger>
  <DialogContent className="sm:max-w-[425px]">
    <DialogHeader>
      <DialogTitle>Upload Certificate</DialogTitle>
      <DialogDescription>
        {`Upload Certificate for ${candidate_name}`}
      </DialogDescription>
    </DialogHeader>
    <Form {...methods}>
      <form
        className="flex flex-col items-center justify-center w-100 gap-2"
        onSubmit={methods.handleSubmit(uploadCertificate)}
        noValidate
        autoComplete="off"
      >
        <FormField
          control={methods.control}
          name="file"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormControl>
                <Dropzone
                  {...field}
                  dropMessage="Drop files or click here"
                  handleOnDrop={handleOnDrop}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {methods.watch("file") && (
          <div className="flex items-center justify-center gap-3 p-4 relative">
            <FileCheck2Icon className="h-4 w-4" />
            <p className="text-sm font-medium">
              {methods.watch("file")?.name}
            </p>
          </div>
        )}
        <Button type="submit">Save</Button>
      </form>
    </Form>
    <Progress value={uploadCertificateProgress} />
    <DialogFooter>
      <Button
        onClick={() => uploadCertificate}
        className="w-full"
        variant={"secondary"}
      >
        Upload
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

you should use state for the opening and closing modal, with

<Dialog open={open} onOpenChange={setOpen}> and closing the modal upon successful submit

Steveb599 avatar Jun 02 '24 10:06 Steveb599

My simple version

import React, { ChangeEvent, useState } from 'react';

import { Input } from '@/components/ui/input';

import { cn } from '@/utils';

interface DropzoneProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
  onChange: (files: FileList | null) => void;
}

export const Dropzone = React.forwardRef<HTMLInputElement, DropzoneProps>(
  ({ className, onChange, children, ...props }, ref) => {
    const [isDragging, setIsDragging] = useState(false);

    const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      e.stopPropagation();
      setIsDragging(true);
    };

    const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      e.stopPropagation();
      setIsDragging(false);
      onChange(e.dataTransfer.files);
    };

    const handleDragLeave = () => {
      setIsDragging(false);
    };

    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
      onChange(e.target.files);
    };

    return (
      <div
        className={cn(
          'border border-transparent p-4',
          {
            'rounded-md border-dashed border-primary': isDragging,
          },
          className,
        )}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        onDragLeave={handleDragLeave}
      >
        {children}
        <Input
          ref={ref}
          type="file"
          className="hidden"
          onChange={handleChange}
          {...props}
        />
      </div>
    );
  },
);

Dropzone.displayName = 'Dropzone';

how to use it

const InitialTab = () => {
  const set = useStore(store => store.set);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleClickUpload = () => {
    inputRef.current?.click();
  };

  const handleClickRecord = (e: React.MouseEvent) => {
    e.stopPropagation();
    set({ audioTab: AudioTab.RECORD });
  };

  const handleChange = (files: FileList | null) => {
    const file = files?.[0];

    if (file) set({ audio: { file, url: URL.createObjectURL(file) } });
  };

  return (
    <div>
      <Dropzone ref={inputRef} onChange={handleChange} multiple={false} accept="audio/*">
        <div className="flex flex-col items-center justify-center gap-y-4">
          <div className="rounded-full bg-primary-foreground p-2">
            <UploadIcon className="text-primary" size={16} />
          </div>
          <div className="text-center">
            <p className="text-lg font-semibold">Drag and drop an audio file</p>
            <p className="text-sm font-medium text-muted-foreground">
              Audio file up to 50mb
            </p>
          </div>
          <div className="flex space-x-4">
            <Button variant="outline" onClick={handleClickUpload}>
              Upload Audio
            </Button>
            <Button variant="outline" onClick={handleClickRecord}>
              Record Audio
            </Button>
          </div>
        </div>
      </Dropzone>
    </div>
  );
};

vekaev avatar Jun 04 '24 01:06 vekaev

I created a simple version of the drop-zone component, published as shadcn-dropzone. ✨

yarn add shadcn-dropzone

Here is an example of how you would use it:

<Dropzone
  onDrop={(acceptedFiles: File) => {
    // Do something with the files
  }}
>
  {(dropzone: DropzoneState) => (
    <>
      {
        dropzone.isDragAccept ? (
          <div className='text-sm font-medium'>Drop your files here!</div>
        ) : (
          <div className='flex items-center flex-col gap-1.5'>
            <div className='flex items-center flex-row gap-0.5 text-sm font-medium'>
              Upload files
            </div>
          </div>
        )
      }
      <div className='text-xs text-gray-400 font-medium'>
        { dropzone.acceptedFiles.length } files uploaded so far.
      </div>
    </>
  )}
</Dropzone>

Demo: https://diragb.github.io/shadcn-dropzone

diragb avatar Jul 09 '24 20:07 diragb

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

shadcn avatar Jul 31 '24 23:07 shadcn