UNPKG

melt

Version:

The next generation of Melt UI. Built for Svelte 5.

225 lines (224 loc) 7.79 kB
import { dataAttr } from "../utils/attribute"; import { extract } from "../utils/extract"; import { areFilesEqual } from "../utils/file"; import { createBuilderMetadata } from "../utils/identifiers"; import { SelectionState } from "../utils/selection-state.svelte"; import { watch } from "runed"; const { dataAttrs, createIds } = createBuilderMetadata("fileupload", ["dropzone", "input"]); export class FileUpload { #props; multiple = $derived(extract(this.#props.multiple, false)); accept = $derived(extract(this.#props.accept, undefined)); maxSize = $derived(extract(this.#props.maxSize, undefined)); disabled = $derived(extract(this.#props.disabled, false)); avoidDuplicates = $derived(extract(this.#props.avoidDuplicates, false)); /* State */ #isDragging = $state(false); ids = $state(createIds()); #selected; constructor(props = {}) { this.#props = props; this.#selected = new SelectionState({ value: props.selected, onChange: props.onSelectedChange, multiple: props.multiple, }); } get isDragging() { return this.#isDragging; } /** * Gets the currently selected files */ get selected() { return this.#selected.current; } /** * Sets the currently selected files */ set selected(value) { this.#selected.current = value; } /** * Clears the currently selected files */ clear() { this.#selected.clear(); } /** * Removes a file from the selection */ remove(file) { this.#selected.delete(file); } async has(file) { const files = this.#selected.toArray(); const promises = files.map((f) => areFilesEqual(f, file)); const results = await Promise.all(promises); return results.some(Boolean); } #handleFiles = async (files) => { if (!files) return; const fileArray = Array.from(files); const validFiles = []; for (const file of fileArray) { if (this.avoidDuplicates && (await this.has(file))) continue; // Check file type if accept is specified if (this.accept) { const acceptTypes = this.accept.split(",").map((t) => t.trim()); const isValidType = acceptTypes.some((type) => { if (type.startsWith(".")) { // Extension check return file.name.toLowerCase().endsWith(type.toLowerCase()); } else if (type.endsWith("/*")) { // Mime type group check const group = type.split("/")[0]; return file.type.startsWith(`${group}/`); } else { // Exact mime type check return file.type === type; } }); if (!isValidType) { this.#props.onError?.({ type: "type", file, message: `File type ${file.type} is not accepted`, }); continue; } } // Check file size if maxSize is specified if (this.maxSize && file.size > this.maxSize) { this.#props.onError?.({ type: "size", file, message: `File size ${file.size} exceeds maximum size of ${this.maxSize}`, }); continue; } // Run custom validation if provided if (this.#props.validate && !this.#props.validate(file)) { this.#props.onError?.({ type: "validation", file, message: `File failed custom validation`, }); continue; } // File passed all validations validFiles.push(file); this.#props.onAccept?.(file); } if (!validFiles.length) return; if (this.multiple) { this.#selected.addAll(validFiles); } else { const firstFile = validFiles[0]; if (firstFile) this.#selected.add(firstFile); } }; /** The dropzone element, where you can drag files into, or click to open the file picker. */ get dropzone() { return { [dataAttrs.dropzone]: "", "data-dragging": dataAttr(this.#isDragging), "data-disabled": dataAttr(this.disabled), ondragenter: (e) => { if (this.disabled) return; e.preventDefault(); if (!this.#isDragging) { this.#isDragging = true; } }, ondragleave: (e) => { if (this.disabled) return; e.preventDefault(); // Check if we're actually leaving the dropzone const relatedTarget = e.relatedTarget; const dropzone = e.currentTarget; // Only set dragging to false if we're actually leaving the dropzone // and not just moving between its children if (!relatedTarget || !dropzone.contains(relatedTarget)) { this.#isDragging = false; } }, ondragover: (e) => { if (this.disabled) return; e.preventDefault(); }, ondrop: (e) => { if (this.disabled) return; e.preventDefault(); this.#isDragging = false; if (e.dataTransfer?.files) { this.#handleFiles(e.dataTransfer.files); } }, onclick: () => { if (this.disabled) return; const input = document.getElementById(this.ids.input); if (input) { input.click(); } }, }; } /** The hidden file input element. */ get input() { watch(() => $state.snapshot(this.#selected.toArray()), () => { const input = document.getElementById(this.ids.input); if (!input) return; const set = this.#selected.toSet(); const dt = new DataTransfer(); for (const file of set) { dt.items.add(file); } input.files = dt.files; }); return { [dataAttrs.input]: "", id: this.ids.input, type: "file", accept: this.accept, multiple: this.multiple, style: "display: none;", disabled: this.disabled, onchange: (e) => { if (this.disabled) return; const input = e.target; const files = input.files; input.files = null; this.#handleFiles(files); }, }; } /** An optional trigger element, which can be used to open the file picker. */ get trigger() { return { "data-disabled": dataAttr(this.disabled), onclick: () => { if (this.disabled) return; const input = document.getElementById(this.ids.input); if (input) { input.click(); } }, }; } }