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>
);
};
Dropzone
A net-new component, `Dropzone`:
Net-new componentOpen in Storybook