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`; } }