File Dropzone
v0.2.0TODO: Add component description.
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";45export 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}1718/**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 the21 * 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);3233 const [rootClass] = getClassName({34 className,35 rootClass: "BHT__FileDropzone",36 modifiers: { dragging: isDragging },37 styles,38 });3940 const handleDragOver = (event: DragEvent<HTMLDivElement>) => {41 event.preventDefault();42 setIsDragging(true);43 };4445 const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {46 event.preventDefault();47 setIsDragging(false);48 };4950 const handleDrop = (event: DragEvent<HTMLDivElement>) => {51 event.preventDefault();52 setIsDragging(false);53 onFiles(Array.from(event.dataTransfer.files));54 };5556 return (57 <div58 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 <input74 ref={inputRef}75 type="file"76 accept={accept}77 multiple={multiple}78 hidden79 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 outlined2// container (dashed `outline` border, medium corner) and expresses hover and3// drag-over through the standard state layer instead of a fixed background.45.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);2627 &::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 }3839 &:hover {40 border-color: var(--md-sys-color-primary, var(--color-primary));41 }4243 &:hover::after {44 opacity: var(--md-sys-state-hover-opacity, 0.08);45 }4647 &:focus-visible {48 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));49 outline-offset: 2px;50 }5152 &:focus-visible::after {53 opacity: var(--md-sys-state-focus-opacity, 0.1);54 }5556 &:active::after {57 opacity: var(--md-sys-state-pressed-opacity, 0.1);58 }5960 &--dragging {61 &.BHT__FileDropzone {62 border-color: var(--md-sys-color-primary, var(--color-primary));6364 &::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";34export const BasicFileDropzone = () => {5 const [names, setNames] = useState<string[]>([]);67 return (8 <>9 <FileDropzone10 onFiles={(files) => setNames(files.map((file) => file.name))}11 />12 {names.length > 0 && <p>Chosen: {names.join(", ")}</p>}13 </>14 );15};1617export const ImagesOnlyFileDropzone = () => (18 <FileDropzone19 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";34function makeFile(name: string) {5 return new File(["content"], name, { type: "text/plain" });6}78describe("FileDropzone", () => {9 it("renders the label", () => {10 render(<FileDropzone onFiles={() => {}} label="Drop it here" />);11 expect(screen.getByText("Drop it here")).toBeTruthy();12 });1314 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 });1819 it("calls onFiles with the dropped files", () => {20 const handleFiles = vi.fn();21 render(<FileDropzone onFiles={handleFiles} />);22 const file = makeFile("photo.jpg");2324 fireEvent.drop(screen.getByRole("button"), {25 dataTransfer: { files: [file] },26 });2728 expect(handleFiles).toHaveBeenCalledWith([file]);29 });3031 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"]')!;3637 fireEvent.change(input, { target: { files: [file] } });3839 expect(handleFiles).toHaveBeenCalledWith([file]);40 });41});