melt
Version:
The next generation of Melt UI. Built for Svelte 5.
225 lines (224 loc) • 7.79 kB
JavaScript
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();
}
},
};
}
}