import {
DROPZONE_CHANGE_EVENT,
DROPZONE_REJECT_EVENT,
type DropzoneChangeDetail,
type DropzoneOptions,
type DropzoneRejectDetail,
type DropzoneRejectReason,
type DropzoneState,
} from './file-dropzone.types';
const SELECTORS = {
input: '[data-c42-dropzone-input]',
zone: '[data-c42-dropzone-zone]',
list: '[data-c42-dropzone-list]',
item: '[data-c42-dropzone-item]',
name: '[data-c42-dropzone-name]',
size: '[data-c42-dropzone-size]',
preview: '[data-c42-dropzone-preview]',
remove: '[data-c42-dropzone-remove]',
error: '[data-c42-dropzone-error]',
} as const;
/**
* Headless file dropzone. Wires a hidden file input to a clickable / drag-drop
* zone, validates files (size, accepted types, count, optional custom rule),
* keeps the accepted set and optionally renders a previews list. Reflects drag
* state via `data-state="dragging"`; no visual styling in JS.
*
* Markup:
* ```html
*
*
*
Drop files or click
*
*
* ```
*/
export class FileDropzone {
private readonly root: HTMLElement;
private readonly input: HTMLInputElement;
private readonly zone: HTMLElement;
private readonly list: HTMLElement | null;
private readonly errorEl: HTMLElement | null;
private readonly multiple: boolean;
private readonly maxFiles: number;
private readonly maxSize: number;
private readonly acceptExt: string[];
private readonly acceptMime: string[];
private readonly validateFn?: (file: File) => true | string;
private readonly previews: boolean;
private disabled: boolean;
private accepted: File[] = [];
private errorTimer: ReturnType | null = null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: DropzoneOptions = {}) {
const input = root.querySelector(SELECTORS.input);
if (!input) {
throw new Error('[42/file-dropzone] Needs a file input element.');
}
this.root = root;
this.input = input;
this.zone = root.querySelector(SELECTORS.zone) ?? root;
this.list = root.querySelector(SELECTORS.list);
this.errorEl = root.querySelector(SELECTORS.error);
this.multiple = options.multiple ?? false;
this.maxFiles = options.maxFiles ?? (this.multiple ? Infinity : 1);
this.maxSize = options.maxSize ?? Infinity;
this.validateFn = options.validate;
this.previews = options.previews ?? false;
this.disabled = options.disabled ?? false;
const tokens = (options.accept ?? '')
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
this.acceptExt = tokens.filter((t) => t.startsWith('.'));
this.acceptMime = tokens.filter((t) => t.includes('/'));
this.init();
}
private init(): void {
if (this.multiple) {
this.input.setAttribute('multiple', '');
}
this.input.disabled = this.disabled;
this.root.dataset.state = 'idle';
this.root.toggleAttribute('data-disabled', this.disabled);
const onZoneClick = (): void => this.openDialog();
const onInputChange = (): void => this.onInputChange();
const onDragOver = (event: Event): void => this.onDragOver(event as DragEvent);
const onDragLeave = (event: Event): void => this.onDragLeave(event as DragEvent);
const onDrop = (event: Event): void => this.onDrop(event as DragEvent);
const onListClick = (event: Event): void => this.onListClick(event);
this.zone.addEventListener('click', onZoneClick);
this.input.addEventListener('change', onInputChange);
this.zone.addEventListener('dragover', onDragOver);
this.zone.addEventListener('dragleave', onDragLeave);
this.zone.addEventListener('drop', onDrop);
this.list?.addEventListener('click', onListClick);
this.cleanups.push(
() => this.zone.removeEventListener('click', onZoneClick),
() => this.input.removeEventListener('change', onInputChange),
() => this.zone.removeEventListener('dragover', onDragOver),
() => this.zone.removeEventListener('dragleave', onDragLeave),
() => this.zone.removeEventListener('drop', onDrop),
() => this.list?.removeEventListener('click', onListClick),
);
}
/* ---------- interaction ---------- */
private onInputChange(): void {
if (this.input.files) {
this.addFiles(this.input.files);
}
this.input.value = '';
}
private onDragOver(event: DragEvent): void {
if (this.disabled) {
return;
}
event.preventDefault();
this.root.dataset.state = 'dragging';
this.zone.dataset.state = 'dragging';
}
private onDragLeave(event: DragEvent): void {
event.preventDefault();
this.root.dataset.state = 'idle';
this.zone.dataset.state = 'idle';
}
private onDrop(event: DragEvent): void {
event.preventDefault();
this.root.dataset.state = 'idle';
this.zone.dataset.state = 'idle';
if (this.disabled || !event.dataTransfer) {
return;
}
this.addFiles(event.dataTransfer.files);
}
private onListClick(event: Event): void {
const button = (event.target as HTMLElement).closest(SELECTORS.remove);
if (!button) {
return;
}
const index = Number(button.dataset.index);
if (!Number.isNaN(index)) {
this.removeAt(index);
}
}
/* ---------- validation ---------- */
private reject(file: File, reason: DropzoneRejectReason, message?: string): void {
const detail: DropzoneRejectDetail = { file, reason, message };
this.root.dispatchEvent(new CustomEvent(DROPZONE_REJECT_EVENT, { detail, bubbles: true }));
this.setError(this.getErrorMessage(reason, file));
}
private getErrorMessage(reason: DropzoneRejectReason, file: File): string {
switch (reason) {
case 'too-large':
return `"${file.name}" exceeds max file size`;
case 'unaccepted-type':
return `"${file.name}" is not an accepted file type`;
case 'max-files':
return `Maximum of ${this.maxFiles} files exceeded`;
case 'invalid':
return `"${file.name}" is invalid`;
}
}
private setError(message: string): void {
this.root.dataset.state = 'error';
this.zone.dataset.state = 'error';
if (this.errorEl) {
this.errorEl.textContent = message;
this.errorEl.removeAttribute('hidden');
}
if (this.errorTimer) clearTimeout(this.errorTimer);
this.errorTimer = setTimeout(() => this.clearError(), 4000);
}
clearError(): void {
this.root.dataset.state = 'idle';
this.zone.dataset.state = 'idle';
if (this.errorEl) {
this.errorEl.textContent = '';
this.errorEl.setAttribute('hidden', '');
}
this.errorTimer = null;
}
private matchesAccept(file: File): boolean {
if (this.acceptExt.length === 0 && this.acceptMime.length === 0) {
return true;
}
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
const extOk = this.acceptExt.some((ext) => name.endsWith(ext));
const mimeOk = this.acceptMime.some((pattern) => {
if (pattern.endsWith('/*')) {
return type.startsWith(pattern.slice(0, -1));
}
return type === pattern;
});
return extOk || mimeOk;
}
/** Returns true if the file passes every rule; otherwise emits reject. */
private validate(file: File): boolean {
if (file.size > this.maxSize) {
this.reject(file, 'too-large');
return false;
}
if (!this.matchesAccept(file)) {
this.reject(file, 'unaccepted-type');
return false;
}
if (this.validateFn) {
const result = this.validateFn(file);
if (result !== true) {
this.reject(file, 'invalid', result);
return false;
}
}
return true;
}
/* ---------- rendering ---------- */
private render(): void {
if (!this.list) {
return;
}
const frag = document.createDocumentFragment();
this.accepted.forEach((file, index) => {
const item = document.createElement('li');
item.dataset.c42DropzoneItem = '';
if (this.previews && file.type.startsWith('image/')) {
const img = document.createElement('img');
img.dataset.c42DropzonePreview = '';
img.alt = file.name;
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result as string;
};
reader.readAsDataURL(file);
item.appendChild(img);
}
const name = document.createElement('span');
name.dataset.c42DropzoneName = '';
name.textContent = file.name;
item.appendChild(name);
const size = document.createElement('span');
size.dataset.c42DropzoneSize = '';
size.textContent = this.formatSize(file.size);
item.appendChild(size);
const remove = document.createElement('button');
remove.type = 'button';
remove.dataset.c42DropzoneRemove = '';
remove.dataset.index = String(index);
remove.setAttribute('aria-label', `Remove ${file.name}`);
item.appendChild(remove);
frag.appendChild(item);
});
this.list.replaceChildren(frag);
}
/* ---------- public API ---------- */
/** Validate and add files to the accepted set, emitting change/reject. */
addFiles(files: FileList | File[]): void {
if (this.disabled) {
return;
}
const incoming = Array.from(files);
const valid = incoming.filter((file) => this.validate(file));
if (valid.length === 0) {
return;
}
let changed = false;
if (this.multiple) {
for (const file of valid) {
if (this.accepted.length >= this.maxFiles) {
this.reject(file, 'max-files');
continue;
}
this.accepted.push(file);
changed = true;
}
} else {
this.accepted = [valid[0]];
changed = true;
}
if (changed) {
this.render();
this.emitChange();
}
}
removeAt(index: number): void {
if (index < 0 || index >= this.accepted.length) {
return;
}
this.accepted.splice(index, 1);
this.render();
this.emitChange();
}
/** Remove a file by reference. */
removeFile(file: File): void {
this.removeAt(this.accepted.indexOf(file));
}
clear(): void {
if (this.accepted.length === 0) {
return;
}
this.accepted = [];
this.render();
this.emitChange();
}
openDialog(): void {
if (!this.disabled) {
this.input.click();
}
}
setDisabled(disabled: boolean): void {
this.disabled = disabled;
this.input.disabled = disabled;
this.root.toggleAttribute('data-disabled', disabled);
}
private emitChange(): void {
const detail: DropzoneChangeDetail = { files: [...this.accepted] };
this.root.dispatchEvent(new CustomEvent(DROPZONE_CHANGE_EVENT, { detail, bubbles: true }));
}
get files(): File[] {
return [...this.accepted];
}
getState(): DropzoneState {
return { count: this.accepted.length, disabled: this.disabled };
}
on(event: string, handler: (event: E) => void): () => void {
const listener = handler as EventListener;
this.root.addEventListener(event, listener);
const off = (): void => this.root.removeEventListener(event, listener);
this.cleanups.push(off);
return off;
}
destroy(): void {
if (this.errorTimer) clearTimeout(this.errorTimer);
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}