// ============================================================================ // Stylescape | Drop Zone Manager // ============================================================================ // Adds file drag-and-drop to a `.ss-c-dropzone`. The zone's surface is the // label of a file input, so clicking or pressing Enter/Space on the // (focusable) input opens the picker without this script. The manager adds: // - drag-over and invalid states (`ss-c-dropzone--dragover` / `--invalid`) // - `accept`, `multiple` and max-size checks on dropped files // - dropped files copied into the input (so the form submits them), plus // `change` and an `ss:dropzone:files` CustomEvent // - an optional file list and status message // // For moving elements between containers see DragAndDropManager. // ============================================================================ /** * Configuration options for DropZoneManager */ export interface DropZoneOptions { /** Selector for the file input inside the zone */ inputSelector?: string; /** Maximum size per file in bytes (also `data-ss-dropzone-max-size`) */ maxSize?: number; /** Class while files are dragged over the zone */ dragOverClass?: string; /** Class when the dragged or dropped files are rejected */ invalidClass?: string; /** Render the chosen files into `.ss-c-dropzone__files` */ listFiles?: boolean; /** Called with accepted files (dropped or picked) */ onFiles?: (files: File[], zone: HTMLElement) => void; /** Called when files are rejected */ onInvalid?: (reason: DropZoneRejection, zone: HTMLElement) => void; } /** Why files were rejected */ export interface DropZoneRejection { code: "type" | "count" | "size" | "disabled"; message: string; files: File[]; } /** Detail of the `ss:dropzone:files` event */ export interface DropZoneFilesDetail { files: File[]; } const PREFIX = "ss-c-dropzone"; /** * File drop zone with validation and keyboard-accessible fallback. * * @example JavaScript * ```typescript * const zone = new DropZoneManager("#upload-zone", { * maxSize: 10 * 1024 * 1024, * onFiles: (files) => upload(files), * }) * ``` * * @example HTML with data-ss * ```html *
* * *

* *
* ``` */ export class DropZoneManager { private zone: HTMLElement | null; private input: HTMLInputElement | null = null; private options: Required< Omit > & Pick; private depth = 0; constructor( selectorOrElement: string | HTMLElement, options: DropZoneOptions = {}, ) { this.zone = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; const attrMax = this.zone?.dataset.ssDropzoneMaxSize; this.options = { inputSelector: options.inputSelector ?? `.${PREFIX}__input, input[type="file"]`, maxSize: options.maxSize ?? (attrMax !== undefined ? Number(attrMax) : undefined), dragOverClass: options.dragOverClass ?? `${PREFIX}--dragover`, invalidClass: options.invalidClass ?? `${PREFIX}--invalid`, listFiles: options.listFiles ?? true, onFiles: options.onFiles, onInvalid: options.onInvalid, }; if (!this.zone) { console.warn("[Stylescape] DropZoneManager element not found"); return; } this.init(); } // ======================================================================== // Public Properties // ======================================================================== /** Files currently held by the input */ get files(): File[] { return Array.from(this.input?.files ?? []); } // ======================================================================== // Public Methods // ======================================================================== /** * Check files against the input's `accept`, `multiple` and the max size. * Returns the rejection, or null when they are acceptable. */ public validate(files: File[]): DropZoneRejection | null { const input = this.input; if (!input) return null; if (input.disabled) { return { code: "disabled", message: "Uploads are disabled.", files, }; } if (!input.multiple && files.length > 1) { return { code: "count", message: "Drop one file only.", files }; } const wrongType = files.filter( (file) => !this.accepts(file.type, file.name), ); if (wrongType.length) { return { code: "type", message: `File type not accepted: ${wrongType.map((f) => f.name).join(", ")}.`, files: wrongType, }; } const max = this.options.maxSize; const tooBig = max !== undefined ? files.filter((file) => file.size > max) : []; if (tooBig.length && max !== undefined) { return { code: "size", message: `Larger than ${formatBytes(max)}: ${tooBig.map((f) => f.name).join(", ")}.`, files: tooBig, }; } return null; } /** * Destroy the manager */ public destroy(): void { const zone = this.zone; if (zone) { zone.removeEventListener("dragenter", this.handleDragEnter); zone.removeEventListener("dragover", this.handleDragOver); zone.removeEventListener("dragleave", this.handleDragLeave); zone.removeEventListener("drop", this.handleDrop); zone.classList.remove( this.options.dragOverClass, this.options.invalidClass, ); } this.input?.removeEventListener("change", this.handleChange); this.zone = null; this.input = null; } // ======================================================================== // Private Methods // ======================================================================== private init(): void { if (!this.zone) return; this.input = this.zone.querySelector( this.options.inputSelector, ); if (!this.input) { console.warn( "[Stylescape] DropZoneManager: no file input in zone", ); return; } this.zone.addEventListener("dragenter", this.handleDragEnter); this.zone.addEventListener("dragover", this.handleDragOver); this.zone.addEventListener("dragleave", this.handleDragLeave); this.zone.addEventListener("drop", this.handleDrop); this.input.addEventListener("change", this.handleChange); } private isFileDrag(event: DragEvent): boolean { const types = event.dataTransfer?.types; return !!types && Array.from(types).includes("Files"); } // Only MIME types are known before the drop (no names), so this can // reject e.g. a video on an image zone, but extension-only `accept` // lists are checked on drop. private dragIsValid(event: DragEvent): boolean { const input = this.input; if (!input || input.disabled) return false; const items = Array.from(event.dataTransfer?.items ?? []).filter( (item) => item.kind === "file", ); if (!input.multiple && items.length > 1) return false; return items.every((item) => this.accepts(item.type, null)); } private handleDragEnter = (event: DragEvent): void => { if (!this.isFileDrag(event)) return; event.preventDefault(); this.depth += 1; if (this.depth === 1) { const valid = this.dragIsValid(event); this.setState(valid ? "dragover" : "invalid"); } }; private handleDragOver = (event: DragEvent): void => { if (!this.isFileDrag(event)) return; // Required for the drop event to fire. event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = this.zone?.classList.contains( this.options.invalidClass, ) ? "none" : "copy"; } }; // dragleave also fires when moving onto a child; count enter/leave pairs. private handleDragLeave = (event: DragEvent): void => { if (!this.isFileDrag(event)) return; this.depth = Math.max(0, this.depth - 1); if (this.depth === 0) this.setState(null); }; private handleDrop = (event: DragEvent): void => { if (!this.isFileDrag(event)) return; event.preventDefault(); this.depth = 0; this.setState(null); const files = Array.from(event.dataTransfer?.files ?? []); if (!files.length) return; const rejection = this.validate(files); if (rejection) { this.reject(rejection); return; } if (this.assignToInput(files)) { // Picking through the dialog fires `change` natively; do the // same so form code has a single hook (it calls accept()). this.input?.dispatchEvent(new Event("change", { bubbles: true })); } else { this.accept(files); } }; private handleChange = (): void => { const files = this.files; const rejection = files.length ? this.validate(files) : null; if (rejection) { this.reject(rejection); return; } this.accept(files); }; private accept(files: File[]): void { const zone = this.zone; if (!zone) return; this.setState(null); this.input?.removeAttribute("aria-invalid"); this.setStatus( files.length === 0 ? "" : files.length === 1 ? `1 file selected: ${files[0].name}` : `${files.length} files selected`, ); if (this.options.listFiles) this.renderList(files); zone.dispatchEvent( new CustomEvent("ss:dropzone:files", { bubbles: true, detail: { files }, }), ); this.options.onFiles?.(files, zone); } private reject(rejection: DropZoneRejection): void { const zone = this.zone; if (!zone) return; this.setState("invalid"); this.input?.setAttribute("aria-invalid", "true"); this.setStatus(rejection.message); zone.dispatchEvent( new CustomEvent("ss:dropzone:invalid", { bubbles: true, detail: rejection, }), ); this.options.onInvalid?.(rejection, zone); } // FileList is read-only; a DataTransfer is the only way to build one. // Where DataTransfer cannot be constructed (old browsers, jsdom) the // files still reach code through the events and callbacks. private assignToInput(files: File[]): boolean { const input = this.input; if (!input || typeof DataTransfer === "undefined") return false; const transfer = new DataTransfer(); files.forEach((file) => transfer.items.add(file)); input.files = transfer.files; return true; } private setState(state: "dragover" | "invalid" | null): void { this.zone?.classList.toggle( this.options.dragOverClass, state === "dragover", ); this.zone?.classList.toggle( this.options.invalidClass, state === "invalid", ); } private setStatus(message: string): void { const status = this.zone?.querySelector( `.${PREFIX}__status`, ); if (status) status.textContent = message; } private renderList(files: File[]): void { const list = this.zone?.querySelector( `.${PREFIX}__files`, ); if (!list) return; list.replaceChildren( ...files.map((file) => { const item = document.createElement("li"); item.className = `${PREFIX}__file`; const name = document.createElement("span"); name.className = `${PREFIX}__file-name`; name.textContent = file.name; const size = document.createElement("span"); size.className = `${PREFIX}__file-size`; size.textContent = formatBytes(file.size); item.append(name, size); return item; }), ); } /** Match a MIME type and/or file name against the input's `accept`. */ private accepts(type: string, name: string | null): boolean { const accept = this.input?.accept.trim(); if (!accept) return true; const mime = type.toLowerCase(); return accept .split(",") .map((token) => token.trim().toLowerCase()) .filter(Boolean) .some((token) => { if (token.startsWith(".")) { // Unknown before the drop: let the drop decide. return name === null ? true : name.toLowerCase().endsWith(token); } if (token.endsWith("/*")) { return mime.startsWith(token.slice(0, -1)); } // Some systems report no MIME type during the drag. return mime === token || (name === null && mime === ""); }); } } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB"]; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; } export default DropZoneManager;