140 lines
5.0 KiB
TypeScript
140 lines
5.0 KiB
TypeScript
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
|
|
import { UploadCloud } from "lucide-react";
|
|
import { resolveDroppedFiles } from "./resolveDroppedFiles";
|
|
|
|
type RejectedDropReason = "disabled" | "empty" | "unreadable";
|
|
|
|
export type FileDropZoneProps = {
|
|
accept?: string;
|
|
multiple?: boolean;
|
|
disabled?: boolean;
|
|
busy?: boolean;
|
|
progress?: number | null;
|
|
label?: ReactNode;
|
|
actionLabel?: ReactNode;
|
|
busyLabel?: ReactNode;
|
|
progressLabel?: ReactNode;
|
|
note?: ReactNode;
|
|
className?: string;
|
|
inputLabel?: string;
|
|
onRejectedDrop?: (reason: RejectedDropReason) => void;
|
|
onFiles: (files: File[]) => void | Promise<void>;
|
|
};
|
|
|
|
export default function FileDropZone({
|
|
accept,
|
|
multiple = true,
|
|
disabled = false,
|
|
busy = false,
|
|
progress = null,
|
|
label = "i18n:govoplan-core.drop_files_here.77348907",
|
|
actionLabel = "i18n:govoplan-core.or_click_to_select_files.91b05dc1",
|
|
busyLabel = "i18n:govoplan-core.uploading_files.6536791d",
|
|
progressLabel,
|
|
note,
|
|
className = "",
|
|
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
|
onRejectedDrop,
|
|
onFiles
|
|
}: FileDropZoneProps) {
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [dragActive, setDragActive] = useState(false);
|
|
const progressValue = typeof progress === "number" && Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : null;
|
|
const showProgress = busy || progressValue !== null;
|
|
const interactionDisabled = disabled || busy;
|
|
const zoneClassName = `file-drop-zone ${dragActive ? "is-active" : ""} ${showProgress ? "is-busy" : ""} ${className}`.trim();
|
|
const roundedProgress = progressValue === null ? null : Math.round(progressValue);
|
|
const progressStyle = progressValue === null ? undefined : { "--file-drop-progress": `${progressValue}%` } as CSSProperties;
|
|
|
|
async function handleFiles(fileList: FileList | File[]) {
|
|
if (interactionDisabled) return;
|
|
const files = Array.from(fileList);
|
|
if (files.length === 0) return;
|
|
try {
|
|
await onFiles(files);
|
|
} finally {
|
|
if (inputRef.current) inputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
|
|
if (!interactionDisabled) setDragActive(true);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={zoneClassName}
|
|
role="button"
|
|
tabIndex={interactionDisabled ? -1 : 0}
|
|
aria-label={inputLabel}
|
|
aria-disabled={interactionDisabled}
|
|
aria-busy={showProgress || undefined}
|
|
onClick={() => {
|
|
if (!interactionDisabled) inputRef.current?.click();
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if ((event.key === "Enter" || event.key === " ") && !interactionDisabled) {
|
|
event.preventDefault();
|
|
inputRef.current?.click();
|
|
}
|
|
}}
|
|
onDragEnter={prepareFileDrag}
|
|
onDragOver={prepareFileDrag}
|
|
onDragLeave={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
|
setDragActive(false);
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setDragActive(false);
|
|
if (interactionDisabled) {
|
|
onRejectedDrop?.("disabled");
|
|
return;
|
|
}
|
|
void resolveDroppedFiles(event.dataTransfer).then((files) => {
|
|
if (files.length === 0) {
|
|
onRejectedDrop?.("empty");
|
|
return;
|
|
}
|
|
void handleFiles(files);
|
|
}).catch(() => onRejectedDrop?.("unreadable"));
|
|
}}>
|
|
|
|
{showProgress ?
|
|
<span
|
|
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
|
|
role="progressbar"
|
|
aria-label="i18n:govoplan-core.upload_progress.927d75fe"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={roundedProgress ?? undefined}
|
|
style={progressStyle}>
|
|
|
|
<span>{roundedProgress === null ? "" : i18nMessage("i18n:govoplan-core.value.8c5f2c0e", { value0: roundedProgress })}</span>
|
|
</span> :
|
|
|
|
<UploadCloud size={28} aria-hidden="true" />
|
|
}
|
|
<strong>{showProgress ? busyLabel : label}</strong>
|
|
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "i18n:govoplan-core.uploading.d921a79a" : i18nMessage("i18n:govoplan-core.value_uploaded.2c4bcc60", { value0: roundedProgress })) : actionLabel}</span>
|
|
{note && <span className="muted small-text">{note}</span>}
|
|
</div>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
multiple={multiple}
|
|
hidden
|
|
onChange={(event) => event.target.files && void handleFiles(event.target.files)} />
|
|
|
|
</>);
|
|
|
|
}
|