zentrixui
Version:
ZentrixUI - A modern, highly customizable and accessible React file upload component library with multiple variants, JSON-based configuration, and excellent developer experience.
441 lines (440 loc) • 19.8 kB
JavaScript
import { jsxs, jsx } from "react/jsx-runtime";
import { useRef, useState, useCallback, useEffect } from "react";
import { useFileUpload } from "../file-upload-context.js";
import { isImageFile, validateFiles, formatFileSize } from "../../../utils/file-validation.js";
const PreviewUpload = ({
className = "",
style,
ariaLabel,
ariaDescribedBy,
children,
previewSize = "md",
showFileInfo = true,
allowReorder = false,
disabled: propDisabled,
multiple: propMultiple,
accept: propAccept,
maxSize: propMaxSize,
maxFiles: propMaxFiles,
onFileSelect,
onError,
onFileRemove
}) => {
const { config, actions, state } = useFileUpload();
const inputRef = useRef(null);
const [previews, setPreviews] = useState(/* @__PURE__ */ new Map());
const [focusedFileIndex, setFocusedFileIndex] = useState(-1);
const disabled = propDisabled ?? config.defaults.disabled ?? false;
const multiple = propMultiple ?? config.defaults.multiple ?? false;
const accept = propAccept ?? config.defaults.accept ?? "*";
const maxSize = propMaxSize ?? config.validation.maxSize;
const maxFiles = propMaxFiles ?? config.validation.maxFiles;
const generateImageThumbnail = useCallback(async (file) => {
return new Promise((resolve, reject) => {
if (!isImageFile(file)) {
reject(new Error("Not an image file"));
return;
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
const maxDimension = previewSize === "sm" ? 80 : previewSize === "lg" ? 200 : 120;
let { width, height } = img;
if (width > height) {
if (width > maxDimension) {
height = height * maxDimension / width;
width = maxDimension;
}
} else {
if (height > maxDimension) {
width = width * maxDimension / height;
height = maxDimension;
}
}
canvas.width = width;
canvas.height = height;
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", 0.8));
} else {
reject(new Error("Could not get canvas context"));
}
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
URL.revokeObjectURL(img.src);
reject(new Error("Failed to load image"));
};
img.src = URL.createObjectURL(file);
});
}, [previewSize]);
const generateFilePreview = useCallback(async (file) => {
if (isImageFile(file)) {
try {
const thumbnailUrl = await generateImageThumbnail(file);
return {
id: `preview_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
url: thumbnailUrl,
type: "image"
};
} catch (error) {
console.warn("Failed to generate thumbnail:", error);
return {
id: `preview_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
url: getFileIcon(file),
type: "icon"
};
}
} else {
return {
id: `preview_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
url: getFileIcon(file),
type: "icon"
};
}
}, [generateImageThumbnail]);
const getFileIcon = useCallback((file) => {
if (file.type.startsWith("image/")) return "🖼️";
if (file.type.startsWith("video/")) return "🎥";
if (file.type.startsWith("audio/")) return "🎵";
if (file.type.includes("pdf")) return "📄";
if (file.type.includes("text")) return "📝";
if (file.type.includes("zip") || file.type.includes("archive")) return "📦";
if (file.type.includes("word")) return "📝";
if (file.type.includes("excel") || file.type.includes("spreadsheet")) return "📊";
if (file.type.includes("powerpoint") || file.type.includes("presentation")) return "📽️";
return "📁";
}, []);
useEffect(() => {
const updatePreviews = async () => {
const newPreviews = /* @__PURE__ */ new Map();
for (const uploadFile of state.files) {
if (!previews.has(uploadFile.id)) {
const preview = await generateFilePreview(uploadFile.file);
newPreviews.set(uploadFile.id, preview);
} else {
newPreviews.set(uploadFile.id, previews.get(uploadFile.id));
}
}
setPreviews(newPreviews);
};
updatePreviews();
}, [state.files, generateFilePreview]);
const handleFileSelect = useCallback(async (event) => {
const files = Array.from(event.target.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);
}
event.target.value = "";
}, [config, maxSize, maxFiles, accept, state.files.length, actions, onFileSelect, onError]);
const handleButtonClick = useCallback(() => {
if (disabled || state.isUploading) return;
inputRef.current?.click();
}, [disabled, state.isUploading]);
const handleButtonKeyDown = useCallback((event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleButtonClick();
}
}, [handleButtonClick]);
const handleRemoveFile = useCallback((fileId, fileName) => {
actions.removeFile(fileId);
onFileRemove?.(fileId);
const preview = previews.get(fileId);
if (preview && preview.type === "image" && preview.url.startsWith("data:")) {
setPreviews((prev) => {
const newPreviews = new Map(prev);
newPreviews.delete(fileId);
return newPreviews;
});
}
if (config.accessibility.announceFileSelection) {
const announcement = `File ${fileName} removed`;
const announcer = document.createElement("div");
announcer.setAttribute("aria-live", "polite");
announcer.setAttribute("aria-atomic", "true");
announcer.className = "sr-only";
announcer.textContent = announcement;
document.body.appendChild(announcer);
setTimeout(() => document.body.removeChild(announcer), 1e3);
}
}, [actions, onFileRemove, previews, config.accessibility.announceFileSelection]);
const handleFileListKeyDown = useCallback((event, fileIndex, fileId, fileName) => {
switch (event.key) {
case "Delete":
case "Backspace":
event.preventDefault();
handleRemoveFile(fileId, fileName);
break;
case "ArrowDown":
event.preventDefault();
setFocusedFileIndex(Math.min(fileIndex + 1, state.files.length - 1));
break;
case "ArrowUp":
event.preventDefault();
setFocusedFileIndex(Math.max(fileIndex - 1, 0));
break;
case "Home":
event.preventDefault();
setFocusedFileIndex(0);
break;
case "End":
event.preventDefault();
setFocusedFileIndex(state.files.length - 1);
break;
}
}, [handleRemoveFile, state.files.length]);
const getPreviewSizeClasses = () => {
const sizeMap = {
sm: { container: "min-w-[150px]", media: "h-20", text: "text-xs" },
md: { container: "min-w-[200px]", media: "h-28", text: "text-sm" },
lg: { container: "min-w-[250px]", media: "h-36", text: "text-base" }
};
return sizeMap[previewSize] || sizeMap.md;
};
const sizeClasses = getPreviewSizeClasses();
return /* @__PURE__ */ jsxs("div", { className: `file-upload file-upload--preview ${className}`, style, children: [
/* @__PURE__ */ jsx(
"input",
{
ref: inputRef,
type: "file",
multiple,
accept,
disabled: disabled || state.isUploading,
onChange: handleFileSelect,
className: "sr-only",
"aria-hidden": "true",
tabIndex: -1
}
),
/* @__PURE__ */ jsxs(
"button",
{
type: "button",
onClick: handleButtonClick,
onKeyDown: handleButtonKeyDown,
disabled: disabled || state.isUploading,
"aria-label": ariaLabel || config.labels.selectFilesText,
"aria-describedby": ariaDescribedBy,
className: "inline-flex items-center justify-center gap-2 font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
children: [
state.isUploading && /* @__PURE__ */ jsxs("svg", { className: "animate-spin h-4 w-4", viewBox: "0 0 24 24", children: [
/* @__PURE__ */ jsx(
"circle",
{
className: "opacity-25",
cx: "12",
cy: "12",
r: "10",
stroke: "currentColor",
strokeWidth: "4",
fill: "none"
}
),
/* @__PURE__ */ jsx(
"path",
{
className: "opacity-75",
fill: "currentColor",
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
}
)
] }),
/* @__PURE__ */ jsxs("span", { children: [
children || (state.isUploading ? config.labels.progressText : config.labels.selectFilesText),
state.files.length > 0 && /* @__PURE__ */ jsxs("span", { className: "ml-1 text-xs opacity-75", children: [
"(",
state.files.length,
" ",
state.files.length === 1 ? "file" : "files",
")"
] })
] })
]
}
),
state.files.length > 0 && /* @__PURE__ */ jsx(
"div",
{
className: "mt-4 grid gap-4",
style: {
gridTemplateColumns: `repeat(auto-fill, minmax(${sizeClasses.container.replace("min-w-[", "").replace("]", "")}, 1fr))`
},
role: "list",
"aria-label": "Selected files with previews",
children: state.files.map((file, index) => {
const preview = previews.get(file.id);
const isFocused = focusedFileIndex === index;
return /* @__PURE__ */ jsxs(
"div",
{
className: `relative border rounded-lg p-4 bg-white transition-all duration-200 ${isFocused ? "ring-2 ring-blue-500 ring-offset-2" : "hover:shadow-md"}`,
role: "listitem",
tabIndex: config.accessibility.keyboardNavigation ? 0 : -1,
onKeyDown: (e) => handleFileListKeyDown(e, index, file.id, file.name),
onFocus: () => setFocusedFileIndex(index),
onBlur: () => setFocusedFileIndex(-1),
"aria-label": `File: ${file.name}, ${formatFileSize(file.size)}, ${file.status}`,
children: [
/* @__PURE__ */ jsx("div", { className: `w-full ${sizeClasses.media} flex items-center justify-center bg-gray-50 rounded-md mb-3 overflow-hidden`, children: preview?.type === "image" ? /* @__PURE__ */ jsx(
"img",
{
src: preview.url,
alt: `Preview of ${file.name}`,
className: "max-w-full max-h-full object-cover rounded-md",
loading: "lazy"
}
) : /* @__PURE__ */ jsx("span", { className: "text-4xl opacity-70", role: "img", "aria-label": `${file.type} file`, children: preview?.url || getFileIcon(file.file) }) }),
showFileInfo && /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
/* @__PURE__ */ jsx("div", { className: `font-medium text-gray-900 truncate ${sizeClasses.text}`, title: file.name, children: file.name }),
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs text-gray-500", children: [
/* @__PURE__ */ jsx("span", { children: formatFileSize(file.size) }),
/* @__PURE__ */ jsx("span", { className: "uppercase", children: file.type.split("/")[1] || "file" })
] })
] }),
file.status === "uploading" && /* @__PURE__ */ jsxs("div", { className: "mt-3 space-y-1", children: [
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
/* @__PURE__ */ jsx("span", { className: "text-blue-600", children: "Uploading..." }),
/* @__PURE__ */ jsxs("span", { className: "text-blue-600 font-medium", children: [
file.progress,
"%"
] })
] }),
/* @__PURE__ */ jsx("div", { className: "w-full bg-gray-200 rounded-full h-2 overflow-hidden", children: /* @__PURE__ */ jsx(
"div",
{
className: "bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out",
style: { width: `${file.progress}%` },
role: "progressbar",
"aria-valuenow": file.progress,
"aria-valuemin": 0,
"aria-valuemax": 100,
"aria-label": `Upload progress: ${file.progress}%`
}
) })
] }),
file.status === "success" && /* @__PURE__ */ jsxs("div", { className: "mt-3 flex items-center text-green-600 text-sm", children: [
/* @__PURE__ */ jsx("svg", { className: "w-4 h-4 mr-1", fill: "currentColor", viewBox: "0 0 20 20", children: /* @__PURE__ */ jsx(
"path",
{
fillRule: "evenodd",
d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",
clipRule: "evenodd"
}
) }),
/* @__PURE__ */ jsx("span", { children: config.labels.successText })
] }),
file.status === "error" && /* @__PURE__ */ jsxs("div", { className: "mt-3 space-y-2", children: [
/* @__PURE__ */ jsxs("div", { className: "flex items-center text-red-600 text-sm", children: [
/* @__PURE__ */ jsx("svg", { className: "w-4 h-4 mr-1", fill: "currentColor", viewBox: "0 0 20 20", children: /* @__PURE__ */ jsx(
"path",
{
fillRule: "evenodd",
d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z",
clipRule: "evenodd"
}
) }),
/* @__PURE__ */ jsx("span", { className: "text-xs", children: file.error || config.labels.errorText })
] }),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: () => actions.retryUpload(file.id),
className: "text-xs text-red-600 hover:text-red-800 border border-red-300 hover:border-red-400 px-2 py-1 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1",
"aria-label": `Retry upload for ${file.name}`,
children: config.labels.retryText
}
)
] }),
file.status === "pending" && /* @__PURE__ */ jsx("div", { className: "mt-3 text-gray-500 text-sm", children: "Ready to upload" }),
(file.status === "pending" || file.status === "error") && /* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: () => handleRemoveFile(file.id, file.name),
className: "absolute top-2 right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center text-sm font-bold transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
"aria-label": `Remove ${file.name}`,
title: `Remove ${file.name}`,
children: "×"
}
)
]
},
file.id
);
})
}
),
state.files.some((f) => f.status === "pending") && !config.features.autoUpload && /* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: actions.uploadFiles,
disabled: state.isUploading,
className: "w-full mt-4 bg-green-600 text-white py-3 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 font-medium transition-colors",
children: state.isUploading ? /* @__PURE__ */ jsxs("span", { className: "flex items-center justify-center gap-2", children: [
/* @__PURE__ */ jsxs("svg", { className: "animate-spin h-4 w-4", viewBox: "0 0 24 24", children: [
/* @__PURE__ */ jsx(
"circle",
{
className: "opacity-25",
cx: "12",
cy: "12",
r: "10",
stroke: "currentColor",
strokeWidth: "4",
fill: "none"
}
),
/* @__PURE__ */ jsx(
"path",
{
className: "opacity-75",
fill: "currentColor",
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
}
)
] }),
config.labels.progressText
] }) : `Upload All Files (${state.files.filter((f) => f.status === "pending").length})`
}
),
/* @__PURE__ */ jsx("div", { "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: state.files.length > 0 && /* @__PURE__ */ jsxs("span", { children: [
state.files.length,
" ",
state.files.length === 1 ? "file" : "files",
" selected.",
state.files.filter((f) => f.status === "pending").length > 0 && ` ${state.files.filter((f) => f.status === "pending").length} ready to upload.`,
state.files.filter((f) => f.status === "success").length > 0 && ` ${state.files.filter((f) => f.status === "success").length} uploaded successfully.`,
state.files.filter((f) => f.status === "error").length > 0 && ` ${state.files.filter((f) => f.status === "error").length} failed to upload.`
] }) })
] });
};
PreviewUpload.displayName = "PreviewUpload";
export {
PreviewUpload
};
//# sourceMappingURL=preview-upload.js.map