zentrixui
Version:
ZentrixUI - A modern, highly customizable and accessible React file upload component library with multiple variants, JSON-based configuration, and excellent developer experience.
374 lines (373 loc) • 15 kB
JavaScript
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
import { useRef, useState, useCallback, useEffect } from "react";
import { useFileUpload } from "../file-upload-context.js";
import { validateFiles } from "../../../utils/file-validation.js";
import { UploadFeedback } from "../feedback/upload-feedback.js";
const MultiFileUpload = ({
className = "",
style,
ariaLabel,
ariaDescribedBy,
children,
disabled: propDisabled,
multiple: propMultiple = true,
// Multi-file upload should default to multiple
accept: propAccept,
maxSize: propMaxSize,
maxFiles: propMaxFiles,
listLayout = "list",
sortable = false,
bulkActions = true,
onFileSelect,
onError,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
...props
}) => {
const { config, actions, state } = useFileUpload();
const inputRef = useRef(null);
const [selectedFileIds, setSelectedFileIds] = useState(/* @__PURE__ */ new Set());
const [isDragOver, setIsDragOver] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const disabled = propDisabled ?? config.defaults.disabled ?? false;
const multiple = propMultiple ?? config.defaults.multiple ?? true;
const accept = propAccept ?? config.defaults.accept ?? "*";
const maxSize = propMaxSize ?? config.validation.maxSize;
const maxFiles = propMaxFiles ?? config.validation.maxFiles;
const handleFileSelect = useCallback(async (files) => {
if (files.length === 0) return;
try {
const validationResult = await validateFiles(files, {
...config,
validation: {
...config.validation,
maxSize,
maxFiles,
allowedTypes: accept === "*" ? ["*"] : accept.split(",").map((t) => t.trim())
}
}, state.files.length);
if (validationResult.validFiles.length > 0) {
actions.selectFiles(validationResult.validFiles);
onFileSelect?.(validationResult.validFiles);
}
if (validationResult.rejectedFiles.length > 0) {
const errorMessage = validationResult.rejectedFiles.map((rf) => `${rf.file.name}: ${rf.errors.map((e) => e.message).join(", ")}`).join("\n");
onError?.(errorMessage);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "File validation failed";
onError?.(errorMessage);
}
}, [config, maxSize, maxFiles, accept, state.files.length, actions, onFileSelect, onError]);
const handleInputChange = useCallback(async (event) => {
const files = Array.from(event.target.files || []);
await handleFileSelect(files);
event.target.value = "";
}, [handleFileSelect]);
const handleDragEnter = useCallback((event) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(true);
onDragEnter?.(event);
}, [onDragEnter]);
const handleDragLeave = useCallback((event) => {
event.preventDefault();
event.stopPropagation();
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsDragOver(false);
}
onDragLeave?.(event);
}, [onDragLeave]);
const handleDragOver = useCallback((event) => {
event.preventDefault();
event.stopPropagation();
onDragOver?.(event);
}, [onDragOver]);
const handleDrop = useCallback(async (event) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
const files = Array.from(event.dataTransfer.files);
await handleFileSelect(files);
onDrop?.(event);
}, [handleFileSelect, onDrop]);
const handleSelectAll = useCallback(() => {
const allFileIds = new Set(state.files.map((f) => f.id));
setSelectedFileIds(allFileIds);
}, [state.files]);
const handleDeselectAll = useCallback(() => {
setSelectedFileIds(/* @__PURE__ */ new Set());
}, []);
const handleRemoveSelected = useCallback(() => {
selectedFileIds.forEach((fileId) => {
actions.removeFile(fileId);
});
setSelectedFileIds(/* @__PURE__ */ new Set());
}, [selectedFileIds, actions]);
const handleUploadSelected = useCallback(async () => {
const selectedFiles = state.files.filter((f) => selectedFileIds.has(f.id) && f.status === "pending");
if (selectedFiles.length === 0) return;
await actions.uploadFiles();
}, [selectedFileIds, state.files, actions]);
const handleRemoveAll = useCallback(() => {
actions.clearAll();
setSelectedFileIds(/* @__PURE__ */ new Set());
}, [actions]);
const handleUploadAll = useCallback(async () => {
await actions.uploadFiles();
}, [actions]);
const handleFileToggle = useCallback((fileId) => {
setSelectedFileIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(fileId)) {
newSet.delete(fileId);
} else {
newSet.add(fileId);
}
return newSet;
});
}, []);
const handleKeyDown = useCallback((event) => {
if (state.files.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, state.files.length - 1));
break;
case "ArrowUp":
event.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
break;
case " ":
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < state.files.length) {
const fileId = state.files[focusedIndex].id;
handleFileToggle(fileId);
}
break;
case "Enter":
event.preventDefault();
inputRef.current?.click();
break;
case "Delete":
case "Backspace":
event.preventDefault();
if (selectedFileIds.size > 0) {
handleRemoveSelected();
} else if (focusedIndex >= 0 && focusedIndex < state.files.length) {
const fileId = state.files[focusedIndex].id;
actions.removeFile(fileId);
}
break;
case "a":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
handleSelectAll();
}
break;
}
}, [state.files, focusedIndex, selectedFileIds, handleFileToggle, handleRemoveSelected, handleSelectAll, actions]);
useEffect(() => {
if (focusedIndex >= state.files.length) {
setFocusedIndex(Math.max(0, state.files.length - 1));
}
}, [state.files.length, focusedIndex]);
const getContainerClasses = () => {
const baseClasses = [
"multi-file-upload",
"border-2 border-dashed rounded-lg p-6",
"transition-colors duration-200",
"focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500"
];
const stateClasses = [];
if (isDragOver) {
stateClasses.push("border-blue-500 bg-blue-50");
} else {
stateClasses.push("border-gray-300 hover:border-gray-400");
}
if (disabled) {
stateClasses.push("opacity-50 cursor-not-allowed");
}
return [...baseClasses, ...stateClasses, className].filter(Boolean).join(" ");
};
const hasFiles = state.files.length > 0;
const hasSelectedFiles = selectedFileIds.size > 0;
const hasPendingFiles = state.files.some((f) => f.status === "pending");
const selectedPendingFiles = state.files.filter((f) => selectedFileIds.has(f.id) && f.status === "pending");
return /* @__PURE__ */ jsxs(
"div",
{
className: getContainerClasses(),
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
onKeyDown: handleKeyDown,
tabIndex: 0,
role: "application",
"aria-label": ariaLabel || "Multi-file upload area",
"aria-describedby": ariaDescribedBy,
style,
children: [
/* @__PURE__ */ jsx(
"input",
{
ref: inputRef,
type: "file",
multiple,
accept,
disabled: disabled || state.isUploading,
onChange: handleInputChange,
className: "sr-only",
"aria-hidden": "true"
}
),
/* @__PURE__ */ jsxs("div", { className: "text-center", children: [
/* @__PURE__ */ jsx("div", { className: "mx-auto h-12 w-12 text-gray-400 mb-4", children: /* @__PURE__ */ jsx("svg", { fill: "none", stroke: "currentColor", viewBox: "0 0 48 48", "aria-hidden": "true", children: /* @__PURE__ */ jsx(
"path",
{
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
}
) }) }),
/* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: () => inputRef.current?.click(),
disabled: disabled || state.isUploading,
className: "text-blue-600 hover:text-blue-700 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1",
children: config.labels.selectFilesText || "Select files"
}
),
/* @__PURE__ */ jsx("p", { className: "text-sm text-gray-600", children: "or drag and drop files here" }),
maxFiles > 1 && /* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-500", children: [
"Maximum ",
maxFiles,
" files, up to ",
(maxSize / 1024 / 1024).toFixed(0),
"MB each"
] })
] })
] }),
hasFiles && /* @__PURE__ */ jsxs("div", { className: "mt-6", children: [
bulkActions && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-4 p-3 bg-gray-50 rounded-md", children: [
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
/* @__PURE__ */ jsxs("span", { className: "text-sm text-gray-700", children: [
state.files.length,
" file",
state.files.length !== 1 ? "s" : "",
hasSelectedFiles && /* @__PURE__ */ jsxs("span", { className: "ml-2 text-blue-600", children: [
"(",
selectedFileIds.size,
" selected)"
] })
] }),
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: handleSelectAll,
className: "text-xs text-blue-600 hover:text-blue-700 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded px-2 py-1",
children: "Select All"
}
),
hasSelectedFiles && /* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: handleDeselectAll,
className: "text-xs text-gray-600 hover:text-gray-700 focus:outline-none focus:ring-1 focus:ring-gray-500 rounded px-2 py-1",
children: "Deselect All"
}
)
] })
] }),
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
hasSelectedFiles && /* @__PURE__ */ jsxs(Fragment, { children: [
selectedPendingFiles.length > 0 && /* @__PURE__ */ jsxs(
"button",
{
type: "button",
onClick: handleUploadSelected,
disabled: state.isUploading,
className: "text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500",
children: [
"Upload Selected (",
selectedPendingFiles.length,
")"
]
}
),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: handleRemoveSelected,
className: "text-xs bg-red-600 text-white px-3 py-1 rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500",
children: "Remove Selected"
}
)
] }),
hasPendingFiles && /* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: handleUploadAll,
disabled: state.isUploading,
className: "text-xs bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-green-500",
children: "Upload All"
}
),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: handleRemoveAll,
className: "text-xs text-red-600 hover:text-red-700 px-2 py-1 rounded focus:outline-none focus:ring-1 focus:ring-red-500",
children: "Remove All"
}
)
] })
] }),
/* @__PURE__ */ jsx(
UploadFeedback,
{
files: state.files,
isUploading: state.isUploading,
showIndividualProgress: true,
showOverallProgress: state.files.length > 1,
showStatusIndicators: true,
showFileNames: true,
enableAccessibilityAnnouncements: config.accessibility?.announceProgress ?? true,
onRetry: actions.retryUpload,
onRemove: actions.removeFile
}
),
/* @__PURE__ */ jsx("div", { className: "mt-4 text-xs text-gray-500 space-y-1", children: /* @__PURE__ */ jsxs("p", { children: [
/* @__PURE__ */ jsx("kbd", { className: "px-1 py-0.5 bg-gray-100 rounded", children: "↑↓" }),
" Navigate • ",
/* @__PURE__ */ jsx("kbd", { className: "px-1 py-0.5 bg-gray-100 rounded", children: "Space" }),
" Select • ",
/* @__PURE__ */ jsx("kbd", { className: "px-1 py-0.5 bg-gray-100 rounded", children: "Ctrl+A" }),
" Select All • ",
/* @__PURE__ */ jsx("kbd", { className: "px-1 py-0.5 bg-gray-100 rounded", children: "Del" }),
" Remove"
] }) })
] }),
children
]
}
);
};
MultiFileUpload.displayName = "MultiFileUpload";
export {
MultiFileUpload
};
//# sourceMappingURL=multi-file-upload.js.map