Behive Tech Registry

File Dropzone

v0.2.0

TODO: Add component description.

pnpm add @behivetech/molecules.file-dropzone

registry/behivetech/molecules/file-dropzone · depends on @behivetech/get-class-name

file-dropzone.tsx
1import { useRef, useState, type DragEvent, type ReactNode } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import styles from "./file-dropzone.module.scss";
4
5export interface FileDropzoneProps {
6 /** Called with the chosen files, from either a drop or the click-to-browse fallback */
7 onFiles: (files: File[]) => void;
8 /** Native `accept` attribute passed to the underlying file input, e.g. "image/*,application/pdf" */
9 accept?: string;
10 /** Whether more than one file can be selected at once; defaults to true */
11 multiple?: boolean;
12 /** Prompt content; defaults to a generic drag-and-drop message */
13 label?: ReactNode;
14 /** Additional class names to merge with the component root element */
15 className?: string;
16}
17
18/**
19 * A drag-and-drop target with a click-to-browse fallback. Reports the chosen `File`
20 * objects via `onFiles` — no upload, preview, or validation logic; that's the
21 * consumer's concern.
22 */
23export const FileDropzone = ({
24 onFiles,
25 accept,
26 multiple = true,
27 label = "Drag and drop files here, or click to browse",
28 className,
29}: FileDropzoneProps) => {
30 const [isDragging, setIsDragging] = useState(false);
31 const inputRef = useRef<HTMLInputElement>(null);
32
33 const [rootClass] = getClassName({
34 className,
35 rootClass: "BHT__FileDropzone",
36 modifiers: { dragging: isDragging },
37 styles,
38 });
39
40 const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
41 event.preventDefault();
42 setIsDragging(true);
43 };
44
45 const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
46 event.preventDefault();
47 setIsDragging(false);
48 };
49
50 const handleDrop = (event: DragEvent<HTMLDivElement>) => {
51 event.preventDefault();
52 setIsDragging(false);
53 onFiles(Array.from(event.dataTransfer.files));
54 };
55
56 return (
57 <div
58 className={rootClass}
59 role="button"
60 tabIndex={0}
61 onClick={() => inputRef.current?.click()}
62 onKeyDown={(event) => {
63 if (event.key === "Enter" || event.key === " ") {
64 event.preventDefault();
65 inputRef.current?.click();
66 }
67 }}
68 onDragOver={handleDragOver}
69 onDragLeave={handleDragLeave}
70 onDrop={handleDrop}
71 >
72 {label}
73 <input
74 ref={inputRef}
75 type="file"
76 accept={accept}
77 multiple={multiple}
78 hidden
79 onChange={(event) => {
80 if (event.target.files) onFiles(Array.from(event.target.files));
81 event.target.value = "";
82 }}
83 />
84 </div>
85 );
86};
file-dropzone.module.scss
1// Drop target. MD3 has no dropzone component, so it borrows the outlined
2// container (dashed `outline` border, medium corner) and expresses hover and
3// drag-over through the standard state layer instead of a fixed background.
4
5.BHT__FileDropzone {
6 // `isolation` keeps the state layer's stacking context local to the zone.
7 position: relative;
8 isolation: isolate;
9 display: flex;
10 align-items: center;
11 justify-content: center;
12 text-align: center;
13 min-height: 8rem;
14 padding: var(--spacing-6, 1.5rem);
15 border: 2px dashed var(--md-sys-color-outline, var(--color-border-strong));
16 border-radius: var(--md-sys-shape-corner-medium, 12px);
17 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
18 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));
19 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
20 font-weight: var(--md-sys-typescale-body-medium-weight, 400);
21 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
22 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);
23 cursor: pointer;
24 transition: border-color var(--md-sys-motion-duration-short-3, 150ms)
25 var(--md-sys-motion-easing-standard, ease);
26
27 &::after {
28 content: "";
29 position: absolute;
30 inset: 0;
31 border-radius: inherit;
32 background: currentColor;
33 opacity: 0;
34 pointer-events: none;
35 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
36 var(--md-sys-motion-easing-standard, ease);
37 }
38
39 &:hover {
40 border-color: var(--md-sys-color-primary, var(--color-primary));
41 }
42
43 &:hover::after {
44 opacity: var(--md-sys-state-hover-opacity, 0.08);
45 }
46
47 &:focus-visible {
48 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
49 outline-offset: 2px;
50 }
51
52 &:focus-visible::after {
53 opacity: var(--md-sys-state-focus-opacity, 0.1);
54 }
55
56 &:active::after {
57 opacity: var(--md-sys-state-pressed-opacity, 0.1);
58 }
59
60 &--dragging {
61 &.BHT__FileDropzone {
62 border-color: var(--md-sys-color-primary, var(--color-primary));
63
64 &::after {
65 opacity: var(--md-sys-state-dragged-opacity, 0.16);
66 }
67 }
68 }
69}
index.ts
1export { FileDropzone } from "./file-dropzone.js";
2export type { FileDropzoneProps } from "./file-dropzone.js";
file-dropzone.composition.tsx
1import { useState } from "react";
2import { FileDropzone } from "./file-dropzone.js";
3
4export const BasicFileDropzone = () => {
5 const [names, setNames] = useState<string[]>([]);
6
7 return (
8 <>
9 <FileDropzone
10 onFiles={(files) => setNames(files.map((file) => file.name))}
11 />
12 {names.length > 0 && <p>Chosen: {names.join(", ")}</p>}
13 </>
14 );
15};
16
17export const ImagesOnlyFileDropzone = () => (
18 <FileDropzone
19 onFiles={(files) => console.log(files)}
20 accept="image/*"
21 label="Drop images here"
22 />
23);
file-dropzone.spec.tsx
1import { render, screen, fireEvent } from "@testing-library/react";
2import { FileDropzone } from "./file-dropzone.js";
3
4function makeFile(name: string) {
5 return new File(["content"], name, { type: "text/plain" });
6}
7
8describe("FileDropzone", () => {
9 it("renders the label", () => {
10 render(<FileDropzone onFiles={() => {}} label="Drop it here" />);
11 expect(screen.getByText("Drop it here")).toBeTruthy();
12 });
13
14 it("renders a default label when none is given", () => {
15 render(<FileDropzone onFiles={() => {}} />);
16 expect(screen.getByText(/drag and drop files here/i)).toBeTruthy();
17 });
18
19 it("calls onFiles with the dropped files", () => {
20 const handleFiles = vi.fn();
21 render(<FileDropzone onFiles={handleFiles} />);
22 const file = makeFile("photo.jpg");
23
24 fireEvent.drop(screen.getByRole("button"), {
25 dataTransfer: { files: [file] },
26 });
27
28 expect(handleFiles).toHaveBeenCalledWith([file]);
29 });
30
31 it("calls onFiles when a file is chosen via the hidden input", () => {
32 const handleFiles = vi.fn();
33 const { container } = render(<FileDropzone onFiles={handleFiles} />);
34 const file = makeFile("doc.pdf");
35 const input = container.querySelector('input[type="file"]')!;
36
37 fireEvent.change(input, { target: { files: [file] } });
38
39 expect(handleFiles).toHaveBeenCalledWith([file]);
40 });
41});