← All proposals

Dropzone

A net-new component, `Dropzone`:

Net-new componentOpen in Storybook

proposals/Dropzone/Dropzone.tsx184 lines · View on GitHub
import {
	ChangeEvent,
	ComponentPropsWithoutRef,
	DragEvent,
	useId,
	useRef,
	useState,
} from 'react';

import { Icon } from '../Icon/Icon';

import './dropzone.css';

// Stand-in for the v2 `mergeClasses` util, which the published package does
// not export. The port uses the real one.
const mergeClasses = (classes: Array<string | null | undefined>) =>
	classes.filter(Boolean).join(' ');

/**
 * Whether a file matches an `accept` string, as on a file input: a list of
 * extensions (`.pdf`), exact types (`image/png`) and wildcards (`image/*`).
 */
const matchesAccept = (file: File, accept?: string) => {
	if (!accept) return true;
	const name = file.name.toLowerCase();
	return accept
		.split(',')
		.map((rule) => rule.trim().toLowerCase())
		.filter(Boolean)
		.some((rule) => {
			if (rule.startsWith('.')) return name.endsWith(rule);
			if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1));
			return file.type === rule;
		});
};

export interface DropzoneProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'onDrop'
> {
	/**
	 * Which files to accept, as on a file input (for example `image/*,.pdf`).
	 * Applies to the file dialog and filters dropped files.
	 */
	accept?: string;
	/**
	 * Text of the "browse" button, the end of the sentence (for example
	 * "browse"). Provide it translated.
	 */
	browseLabel: string;
	/**
	 * Additional class names.
	 */
	className?: string;
	/**
	 * Is the dropzone disabled? Drops and the browse button do nothing.
	 */
	disabled?: boolean;
	/**
	 * The text before the browse button (for example "Drop an image or brief,
	 * or"). Provide it translated.
	 */
	label: string;
	/**
	 * Can more than one file be added at once?
	 */
	multiple?: boolean;
	/**
	 * Called with the files from a drop or from the file dialog (filtered by
	 * `accept`). The screen owns the list of files.
	 */
	onFilesDrop?: (files: File[]) => void;
}

/**
 * Dropzone UI component. A compact area to add files by dragging them in or by
 * browsing.
 */
export const Dropzone = ({
	accept,
	browseLabel,
	className,
	disabled = false,
	label,
	multiple = false,
	onFilesDrop,
	...props
}: DropzoneProps) => {
	const baseClass = 'pds-dropzone';
	const inputRef = useRef<HTMLInputElement>(null);
	const inputId = useId();

	// dragenter and dragleave also fire for every child the pointer crosses, so
	// count them to know when the pointer has really left the zone.
	const dragDepth = useRef(0);
	const [isDragOver, setIsDragOver] = useState(false);

	const hasFiles = (event: DragEvent) =>
		Array.from(event.dataTransfer.types).includes('Files');

	const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
		if (disabled || !hasFiles(event)) return;
		dragDepth.current += 1;
		setIsDragOver(true);
	};

	const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
		if (disabled || !hasFiles(event)) return;
		// Required to make the zone a valid drop target.
		event.preventDefault();
		event.dataTransfer.dropEffect = 'copy';
	};

	const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
		if (disabled || !hasFiles(event)) return;
		dragDepth.current = Math.max(0, dragDepth.current - 1);
		if (dragDepth.current === 0) setIsDragOver(false);
	};

	const deliver = (files: File[]) => {
		const accepted = files.filter((file) => matchesAccept(file, accept));
		const chosen = multiple ? accepted : accepted.slice(0, 1);
		if (chosen.length) onFilesDrop?.(chosen);
	};

	const handleDrop = (event: DragEvent<HTMLDivElement>) => {
		if (disabled || !hasFiles(event)) return;
		dragDepth.current = 0;
		setIsDragOver(false);
		event.preventDefault();
		deliver(Array.from(event.dataTransfer.files));
	};

	const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
		deliver(Array.from(event.target.files ?? []));
		// So choosing the same file again still fires a change.
		event.target.value = '';
	};

	return (
		<div
			className={mergeClasses([
				baseClass,
				'pds-copy-s',
				isDragOver ? `${baseClass}--drag-over` : null,
				disabled ? `${baseClass}--disabled` : null,
				className,
			])}
			onDragEnter={handleDragEnter}
			onDragLeave={handleDragLeave}
			onDragOver={handleDragOver}
			onDrop={handleDrop}
			{...props}
		>
			<Icon
				aria-hidden='true'
				className={`${baseClass}__icon`}
				iconName='upload'
				size='s'
			/>
			<span className={`${baseClass}__label`}>{label}</span>
			<button
				className={`${baseClass}__browse`}
				disabled={disabled}
				onClick={() => inputRef.current?.click()}
				type='button'
			>
				{browseLabel}
			</button>
			<input
				ref={inputRef}
				accept={accept}
				className='visually-hidden'
				disabled={disabled}
				id={inputId}
				multiple={multiple}
				onChange={handleChange}
				tabIndex={-1}
				type='file'
			/>
		</div>
	);
};