@zag-js/file-upload
Version:
Core logic for the file-upload widget implemented as a state machine
644 lines (638 loc) • 20.8 kB
JavaScript
var anatomy$1 = require('@zag-js/anatomy');
var domQuery = require('@zag-js/dom-query');
var fileUtils = require('@zag-js/file-utils');
var i18nUtils = require('@zag-js/i18n-utils');
var types = require('@zag-js/types');
var utils = require('@zag-js/utils');
var core = require('@zag-js/core');
// src/file-upload.anatomy.ts
var anatomy = anatomy$1.createAnatomy("file-upload").parts(
"root",
"dropzone",
"item",
"itemDeleteTrigger",
"itemGroup",
"itemName",
"itemPreview",
"itemPreviewImage",
"itemSizeText",
"label",
"trigger",
"clearTrigger"
);
var parts = anatomy.build();
// src/file-upload.dom.ts
var getRootId = (ctx) => ctx.ids?.root ?? `file:${ctx.id}`;
var getDropzoneId = (ctx) => ctx.ids?.dropzone ?? `file:${ctx.id}:dropzone`;
var getHiddenInputId = (ctx) => ctx.ids?.hiddenInput ?? `file:${ctx.id}:input`;
var getTriggerId = (ctx) => ctx.ids?.trigger ?? `file:${ctx.id}:trigger`;
var getLabelId = (ctx) => ctx.ids?.label ?? `file:${ctx.id}:label`;
var getItemId = (ctx, id) => ctx.ids?.item?.(id) ?? `file:${ctx.id}:item:${id}`;
var getItemNameId = (ctx, id) => ctx.ids?.itemName?.(id) ?? `file:${ctx.id}:item-name:${id}`;
var getItemSizeTextId = (ctx, id) => ctx.ids?.itemSizeText?.(id) ?? `file:${ctx.id}:item-size:${id}`;
var getItemPreviewId = (ctx, id) => ctx.ids?.itemPreview?.(id) ?? `file:${ctx.id}:item-preview:${id}`;
var getRootEl = (ctx) => ctx.getById(getRootId(ctx));
var getHiddenInputEl = (ctx) => ctx.getById(getHiddenInputId(ctx));
var getDropzoneEl = (ctx) => ctx.getById(getDropzoneId(ctx));
function isEventWithFiles(event) {
const target = domQuery.getEventTarget(event);
if (!event.dataTransfer) return !!target && "files" in target;
return event.dataTransfer.types.some((type) => {
return type === "Files" || type === "application/x-moz-file";
});
}
function isFilesWithinRange(ctx, incomingCount, currentAcceptedFiles) {
const { prop, computed } = ctx;
if (!computed("multiple") && incomingCount > 1) return false;
if (!computed("multiple") && incomingCount + currentAcceptedFiles.length === 2) return true;
if (incomingCount + currentAcceptedFiles.length > prop("maxFiles")) return false;
return true;
}
function getEventFiles(ctx, files, currentAcceptedFiles = [], currentRejectedFiles = []) {
const { prop, computed } = ctx;
const acceptedFiles = [];
const rejectedFiles = [];
const validateParams = {
acceptedFiles: currentAcceptedFiles,
rejectedFiles: currentRejectedFiles
};
files.forEach((file) => {
const [accepted, acceptError] = fileUtils.isValidFileType(file, computed("acceptAttr"));
const [sizeMatch, sizeError] = fileUtils.isValidFileSize(file, prop("minFileSize"), prop("maxFileSize"));
const validateErrors = prop("validate")?.(file, validateParams);
const valid = validateErrors ? validateErrors.length === 0 : true;
if (accepted && sizeMatch && valid) {
acceptedFiles.push(file);
} else {
const errors = [acceptError, sizeError];
if (!valid) errors.push(...validateErrors ?? []);
rejectedFiles.push({ file, errors: errors.filter(Boolean) });
}
});
if (!isFilesWithinRange(ctx, acceptedFiles.length, currentAcceptedFiles)) {
acceptedFiles.forEach((file) => {
rejectedFiles.push({ file, errors: ["TOO_MANY_FILES"] });
});
acceptedFiles.splice(0);
}
return {
acceptedFiles,
rejectedFiles
};
}
function setInputFiles(inputEl, files) {
const win = domQuery.getWindow(inputEl);
try {
if ("DataTransfer" in win) {
const dataTransfer = new win.DataTransfer();
files.forEach((file) => {
dataTransfer.items.add(file);
});
inputEl.files = dataTransfer.files;
}
} catch {
}
}
// src/file-upload.connect.ts
var DEFAULT_ITEM_TYPE = "accepted";
function connect(service, normalize) {
const { state, send, prop, computed, scope, context } = service;
const disabled = !!prop("disabled");
const required = !!prop("required");
const allowDrop = prop("allowDrop");
const translations = prop("translations");
const dragging = state.matches("dragging");
const focused = state.matches("focused") && !disabled;
return {
dragging,
focused,
disabled: !!disabled,
transforming: context.get("transforming"),
openFilePicker() {
if (disabled) return;
send({ type: "OPEN" });
},
deleteFile(file, type = DEFAULT_ITEM_TYPE) {
send({ type: "FILE.DELETE", file, itemType: type });
},
acceptedFiles: context.get("acceptedFiles"),
rejectedFiles: context.get("rejectedFiles"),
setFiles(files) {
send({ type: "FILES.SET", files, count: files.length });
},
clearRejectedFiles() {
send({ type: "REJECTED_FILES.CLEAR" });
},
clearFiles() {
send({ type: "FILES.CLEAR" });
},
getFileSize(file) {
return i18nUtils.formatBytes(file.size, prop("locale"));
},
createFileUrl(file, cb) {
const win = scope.getWin();
const url = win.URL.createObjectURL(file);
cb(url);
return () => win.URL.revokeObjectURL(url);
},
setClipboardFiles(dt) {
if (disabled) return false;
const items = Array.from(dt?.items ?? []);
const files = items.reduce((acc, item) => {
if (item.kind !== "file") return acc;
const file = item.getAsFile();
if (!file) return acc;
return [...acc, file];
}, []);
if (!files.length) return false;
send({ type: "FILES.SET", files });
return true;
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
dir: prop("dir"),
id: getRootId(scope),
"data-disabled": domQuery.dataAttr(disabled),
"data-dragging": domQuery.dataAttr(dragging)
});
},
getDropzoneProps(props2 = {}) {
return normalize.element({
...parts.dropzone.attrs,
dir: prop("dir"),
id: getDropzoneId(scope),
tabIndex: disabled || props2.disableClick ? void 0 : 0,
role: props2.disableClick ? "application" : "button",
"aria-label": translations.dropzone,
"aria-disabled": disabled,
"data-invalid": domQuery.dataAttr(prop("invalid")),
"data-disabled": domQuery.dataAttr(disabled),
"data-dragging": domQuery.dataAttr(dragging),
onKeyDown(event) {
if (disabled) return;
if (event.defaultPrevented) return;
if (event.currentTarget !== domQuery.getEventTarget(event)) return;
if (props2.disableClick) return;
if (event.key !== "Enter" && event.key !== " ") return;
send({ type: "DROPZONE.CLICK", src: "keydown" });
},
onClick(event) {
if (disabled) return;
if (event.defaultPrevented) return;
if (props2.disableClick) return;
if (event.currentTarget !== domQuery.getEventTarget(event)) return;
if (event.currentTarget.localName === "label") {
event.preventDefault();
}
send({ type: "DROPZONE.CLICK" });
},
onDragOver(event) {
if (disabled) return;
if (!allowDrop) return;
event.preventDefault();
event.stopPropagation();
try {
event.dataTransfer.dropEffect = "copy";
} catch {
}
const hasFiles = isEventWithFiles(event);
if (!hasFiles) return;
const count = event.dataTransfer.items.length;
send({ type: "DROPZONE.DRAG_OVER", count });
},
onDragLeave(event) {
if (disabled) return;
if (!allowDrop) return;
if (domQuery.contains(event.currentTarget, event.relatedTarget)) return;
send({ type: "DROPZONE.DRAG_LEAVE" });
},
onDrop(event) {
if (disabled) return;
if (allowDrop) {
event.preventDefault();
event.stopPropagation();
}
const hasFiles = isEventWithFiles(event);
if (disabled || !hasFiles) return;
fileUtils.getFileEntries(event.dataTransfer.items, prop("directory")).then((files) => {
send({ type: "DROPZONE.DROP", files: utils.flatArray(files) });
});
},
onFocus() {
if (disabled) return;
send({ type: "DROPZONE.FOCUS" });
},
onBlur() {
if (disabled) return;
send({ type: "DROPZONE.BLUR" });
}
});
},
getTriggerProps() {
return normalize.button({
...parts.trigger.attrs,
dir: prop("dir"),
id: getTriggerId(scope),
disabled,
"data-disabled": domQuery.dataAttr(disabled),
"data-invalid": domQuery.dataAttr(prop("invalid")),
type: "button",
onClick(event) {
if (disabled) return;
if (domQuery.contains(getDropzoneEl(scope), event.currentTarget)) {
event.stopPropagation();
}
send({ type: "OPEN" });
}
});
},
getHiddenInputProps() {
return normalize.input({
id: getHiddenInputId(scope),
tabIndex: -1,
disabled,
type: "file",
required: prop("required"),
capture: prop("capture"),
name: prop("name"),
accept: computed("acceptAttr"),
webkitdirectory: prop("directory") ? "" : void 0,
multiple: computed("multiple") || prop("maxFiles") > 1,
onClick(event) {
event.stopPropagation();
event.currentTarget.value = "";
},
onInput(event) {
if (disabled) return;
const { files } = event.currentTarget;
send({ type: "FILE.SELECT", files: files ? Array.from(files) : [] });
},
style: domQuery.visuallyHiddenStyle
});
},
getItemGroupProps(props2 = {}) {
const { type = DEFAULT_ITEM_TYPE } = props2;
return normalize.element({
...parts.itemGroup.attrs,
dir: prop("dir"),
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type
});
},
getItemProps(props2) {
const { file, type = DEFAULT_ITEM_TYPE } = props2;
return normalize.element({
...parts.item.attrs,
dir: prop("dir"),
id: getItemId(scope, file.name),
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type
});
},
getItemNameProps(props2) {
const { file, type = DEFAULT_ITEM_TYPE } = props2;
return normalize.element({
...parts.itemName.attrs,
dir: prop("dir"),
id: getItemNameId(scope, file.name),
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type
});
},
getItemSizeTextProps(props2) {
const { file, type = DEFAULT_ITEM_TYPE } = props2;
return normalize.element({
...parts.itemSizeText.attrs,
dir: prop("dir"),
id: getItemSizeTextId(scope, file.name),
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type
});
},
getItemPreviewProps(props2) {
const { file, type = DEFAULT_ITEM_TYPE } = props2;
return normalize.element({
...parts.itemPreview.attrs,
dir: prop("dir"),
id: getItemPreviewId(scope, file.name),
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type
});
},
getItemPreviewImageProps(props2) {
const { file, url, type = DEFAULT_ITEM_TYPE } = props2;
const isImage = file.type.startsWith("image/");
if (!isImage) {
throw new Error("Preview Image is only supported for image files");
}
return normalize.img({
...parts.itemPreviewImage.attrs,
alt: translations.itemPreview?.(file),
src: url,
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type
});
},
getItemDeleteTriggerProps(props2) {
const { file, type = DEFAULT_ITEM_TYPE } = props2;
return normalize.button({
...parts.itemDeleteTrigger.attrs,
dir: prop("dir"),
type: "button",
disabled,
"data-disabled": domQuery.dataAttr(disabled),
"data-type": type,
"aria-label": translations.deleteFile?.(file),
onClick() {
if (disabled) return;
send({ type: "FILE.DELETE", file, itemType: type });
}
});
},
getLabelProps() {
return normalize.label({
...parts.label.attrs,
dir: prop("dir"),
id: getLabelId(scope),
htmlFor: getHiddenInputId(scope),
"data-disabled": domQuery.dataAttr(disabled),
"data-required": domQuery.dataAttr(required)
});
},
getClearTriggerProps() {
return normalize.button({
...parts.clearTrigger.attrs,
dir: prop("dir"),
type: "button",
disabled,
hidden: context.get("acceptedFiles").length === 0,
"data-disabled": domQuery.dataAttr(disabled),
onClick(event) {
if (event.defaultPrevented) return;
if (disabled) return;
send({ type: "FILES.CLEAR" });
}
});
}
};
}
var machine = core.createMachine({
props({ props: props2 }) {
return {
minFileSize: 0,
maxFileSize: Number.POSITIVE_INFINITY,
maxFiles: 1,
allowDrop: true,
preventDocumentDrop: true,
defaultAcceptedFiles: [],
...props2,
translations: {
dropzone: "dropzone",
itemPreview: (file) => `preview of ${file.name}`,
deleteFile: (file) => `delete file ${file.name}`,
...props2.translations
}
};
},
initialState() {
return "idle";
},
context({ prop, bindable, getContext }) {
return {
acceptedFiles: bindable(() => ({
defaultValue: prop("defaultAcceptedFiles"),
value: prop("acceptedFiles"),
isEqual: (a, b) => a.length === b?.length && a.every((file, i) => fileUtils.isFileEqual(file, b[i])),
hash(value) {
return value.map((file) => `${file.name}-${file.size}`).join(",");
},
onChange(value) {
const ctx = getContext();
prop("onFileAccept")?.({ files: value });
prop("onFileChange")?.({ acceptedFiles: value, rejectedFiles: ctx.get("rejectedFiles") });
}
})),
rejectedFiles: bindable(() => ({
defaultValue: [],
isEqual: (a, b) => a.length === b?.length && a.every((file, i) => fileUtils.isFileEqual(file.file, b[i].file)),
onChange(value) {
const ctx = getContext();
prop("onFileReject")?.({ files: value });
prop("onFileChange")?.({ acceptedFiles: ctx.get("acceptedFiles"), rejectedFiles: value });
}
})),
transforming: bindable(() => ({
defaultValue: false
}))
};
},
computed: {
acceptAttr: ({ prop }) => fileUtils.getAcceptAttrString(prop("accept")),
multiple: ({ prop }) => prop("maxFiles") > 1
},
watch({ track, context, action }) {
track([() => context.hash("acceptedFiles")], () => {
action(["syncInputElement"]);
});
},
on: {
"FILES.SET": {
actions: ["setFiles"]
},
"FILE.SELECT": {
actions: ["setEventFiles"]
},
"FILE.DELETE": {
actions: ["removeFile"]
},
"FILES.CLEAR": {
actions: ["clearFiles"]
},
"REJECTED_FILES.CLEAR": {
actions: ["clearRejectedFiles"]
}
},
effects: ["preventDocumentDrop"],
states: {
idle: {
on: {
OPEN: {
actions: ["openFilePicker"]
},
"DROPZONE.CLICK": {
actions: ["openFilePicker"]
},
"DROPZONE.FOCUS": {
target: "focused"
},
"DROPZONE.DRAG_OVER": {
target: "dragging"
}
}
},
focused: {
on: {
"DROPZONE.BLUR": {
target: "idle"
},
OPEN: {
actions: ["openFilePicker"]
},
"DROPZONE.CLICK": {
actions: ["openFilePicker"]
},
"DROPZONE.DRAG_OVER": {
target: "dragging"
}
}
},
dragging: {
on: {
"DROPZONE.DROP": {
target: "idle",
actions: ["setEventFiles"]
},
"DROPZONE.DRAG_LEAVE": {
target: "idle"
}
}
}
},
implementations: {
effects: {
preventDocumentDrop({ prop, scope }) {
if (!prop("preventDocumentDrop")) return;
if (!prop("allowDrop")) return;
if (prop("disabled")) return;
const doc = scope.getDoc();
const onDragOver = (event) => {
event?.preventDefault();
};
const onDrop = (event) => {
if (domQuery.contains(getRootEl(scope), domQuery.getEventTarget(event))) return;
event.preventDefault();
};
return utils.callAll(domQuery.addDomEvent(doc, "dragover", onDragOver, false), domQuery.addDomEvent(doc, "drop", onDrop, false));
}
},
actions: {
syncInputElement({ scope, context }) {
queueMicrotask(() => {
const inputEl = getHiddenInputEl(scope);
if (!inputEl) return;
setInputFiles(inputEl, context.get("acceptedFiles"));
const win = scope.getWin();
inputEl.dispatchEvent(new win.Event("change", { bubbles: true }));
});
},
openFilePicker({ scope }) {
domQuery.raf(() => {
getHiddenInputEl(scope)?.click();
});
},
setFiles(params) {
const { computed, context, event } = params;
const { acceptedFiles, rejectedFiles } = getEventFiles(params, event.files);
context.set(
"acceptedFiles",
computed("multiple") ? acceptedFiles : acceptedFiles.length > 0 ? [acceptedFiles[0]] : []
);
context.set("rejectedFiles", rejectedFiles);
},
setEventFiles(params) {
const { computed, context, event, prop } = params;
const currentAcceptedFiles = context.get("acceptedFiles");
const currentRejectedFiles = context.get("rejectedFiles");
const { acceptedFiles, rejectedFiles } = getEventFiles(
params,
event.files,
currentAcceptedFiles,
currentRejectedFiles
);
const set = (files) => {
if (computed("multiple")) {
context.set("acceptedFiles", (prev) => [...prev, ...files]);
context.set("rejectedFiles", rejectedFiles);
return;
}
if (files.length) {
context.set("acceptedFiles", [files[0]]);
context.set("rejectedFiles", rejectedFiles);
return;
}
if (rejectedFiles.length) {
context.set("acceptedFiles", context.get("acceptedFiles"));
context.set("rejectedFiles", rejectedFiles);
}
};
const transform = prop("transformFiles");
if (transform) {
context.set("transforming", true);
transform(acceptedFiles).then(set).catch((err) => {
utils.warn(`[zag-js/file-upload] error transforming files
${err}`);
}).finally(() => {
context.set("transforming", false);
});
} else {
set(acceptedFiles);
}
},
removeFile({ context, event }) {
if (event.itemType === "rejected") {
const rejectedFiles = context.get("rejectedFiles").filter((item) => !fileUtils.isFileEqual(item.file, event.file));
context.set("rejectedFiles", rejectedFiles);
} else {
const files = context.get("acceptedFiles").filter((file) => !fileUtils.isFileEqual(file, event.file));
context.set("acceptedFiles", files);
}
},
clearRejectedFiles({ context }) {
context.set("rejectedFiles", []);
},
clearFiles({ context }) {
context.set("acceptedFiles", []);
context.set("rejectedFiles", []);
}
}
}
});
var props = types.createProps()([
"accept",
"acceptedFiles",
"allowDrop",
"capture",
"defaultAcceptedFiles",
"dir",
"directory",
"disabled",
"getRootNode",
"id",
"ids",
"invalid",
"locale",
"maxFiles",
"maxFileSize",
"minFileSize",
"name",
"onFileAccept",
"onFileChange",
"onFileReject",
"preventDocumentDrop",
"required",
"transformFiles",
"translations",
"validate"
]);
var splitProps = utils.createSplitProps(props);
var itemProps = types.createProps()(["file", "type"]);
var splitItemProps = utils.createSplitProps(itemProps);
exports.anatomy = anatomy;
exports.connect = connect;
exports.itemProps = itemProps;
exports.machine = machine;
exports.props = props;
exports.splitItemProps = splitItemProps;
exports.splitProps = splitProps;
;