fix(files): make dropped-file handling explicit

This commit is contained in:
2026-07-20 16:57:29 +02:00
parent 78b4afdec4
commit b5cfba666c
6 changed files with 345 additions and 6 deletions

View File

@@ -21,6 +21,7 @@
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"

View File

@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
const normalized = source.replace(/\s+/g, " ");
function assertIncludes(fragment, message) {
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
}
assertIncludes(
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
"drag enter/over must prevent the browser default and stop propagation"
);
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
assertIncludes(
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
"drag leave must prevent the browser default and stop propagation"
);
assertIncludes(
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
"drop must prevent the browser default and stop propagation before resolving files"
);
assertIncludes(
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
"disabled drops must route the disabled rejection reason"
);
assertIncludes(
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
"drop must route the event DataTransfer through the shared resolver"
);
assertIncludes(
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
"resolved files and empty drops must be routed through their explicit callbacks"
);
assertIncludes(
".catch(() => onRejectedDrop?.(\"unreadable\"))",
"resolver failures must route the unreadable rejection reason"
);
console.log("FileDropZone event and result routing structure is intact.");

View File

@@ -1,5 +1,8 @@
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
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;
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
note?: ReactNode;
className?: string;
inputLabel?: string;
onRejectedDrop?: (reason: RejectedDropReason) => void;
onFiles: (files: File[]) => void | Promise<void>;
};
@@ -30,6 +34,7 @@ export default function FileDropZone({
note,
className = "",
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
onRejectedDrop,
onFiles
}: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -52,6 +57,13 @@ export default function FileDropZone({
}
}
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
if (!interactionDisabled) setDragActive(true);
}
return (
<>
<div
@@ -70,15 +82,29 @@ export default function FileDropZone({
inputRef.current?.click();
}
}}
onDragOver={(event) => {
onDragEnter={prepareFileDrag}
onDragOver={prepareFileDrag}
onDragLeave={(event) => {
event.preventDefault();
if (!interactionDisabled) setDragActive(true);
event.stopPropagation();
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setDragActive(false);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setDragActive(false);
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
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 ?
@@ -110,4 +136,4 @@ export default function FileDropZone({
</>);
}
}

View File

@@ -0,0 +1,101 @@
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
type FileSystemDirectoryHandleLike = {
kind: "directory";
values?: () => AsyncIterable<FileSystemHandleLike>;
};
type WebKitFileEntryLike = {
isFile: true;
isDirectory: false;
file: (
success: (file: File) => void,
error?: (error: DOMException) => void
) => void;
};
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
type DropItemWithFileHandles = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
webkitGetAsEntry?: () => unknown;
};
/**
* Resolve a browser file drop exactly once, preferring the standard file list
* before progressively trying item and directory-drag compatibility APIs.
*/
export async function resolveDroppedFiles(
dataTransfer: Pick<DataTransfer, "files" | "items">
): Promise<File[]> {
const directFiles = Array.from(dataTransfer.files);
if (directFiles.length > 0) return directFiles;
const items = Array.from(dataTransfer.items).filter(
(item) => item.kind === "file"
) as DropItemWithFileHandles[];
const itemFiles = items
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (itemFiles.length > 0) return itemFiles;
const handleFiles = await filesFromDataTransferHandles(items);
if (handleFiles.length > 0) return handleFiles;
return filesFromWebKitEntries(items);
}
async function filesFromDataTransferHandles(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const files: File[] = [];
for (const item of items) {
let handle: FileSystemHandleLike | null | undefined;
try {
handle = await item.getAsFileSystemHandle?.();
} catch {
handle = null;
}
if (!handle) continue;
files.push(...await filesFromFileSystemHandle(handle));
}
return files;
}
async function filesFromFileSystemHandle(
handle: FileSystemHandleLike
): Promise<File[]> {
if (handle.kind === "file") return [await handle.getFile()];
const values = handle.values?.();
if (!values) return [];
const files: File[] = [];
for await (const child of values) {
files.push(...await filesFromFileSystemHandle(child));
}
return files;
}
async function filesFromWebKitEntries(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const entries = items.flatMap((item) => {
const entry = item.webkitGetAsEntry?.() as unknown;
return isWebKitEntryLike(entry) ? [entry] : [];
});
const files = await Promise.all(entries.map(fileFromWebKitEntry));
return files.filter((file): file is File => Boolean(file));
}
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
if (!value || typeof value !== "object") return false;
const entry = value as { isFile?: unknown; isDirectory?: unknown };
return (
typeof entry.isFile === "boolean" &&
typeof entry.isDirectory === "boolean"
);
}
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
if (!entry.isFile) return Promise.resolve(null);
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}

View File

@@ -0,0 +1,150 @@
import { resolveDroppedFiles } from "../src/components/resolveDroppedFiles";
type FileDropItem = Omit<DataTransferItem, "getAsFileSystemHandle" | "webkitGetAsEntry"> & {
getAsFileSystemHandle?: () => Promise<unknown>;
webkitGetAsEntry?: () => unknown;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertFiles(actual: File[], expected: File[], message: string): void {
assert(actual.length === expected.length, `${message}: expected ${expected.length} file(s), got ${actual.length}`);
expected.forEach((file, index) => {
assert(actual[index] === file, `${message}: file ${index + 1} did not preserve object identity`);
});
}
function transfer(files: File[] = [], items: FileDropItem[] = []): Pick<DataTransfer, "files" | "items"> {
return {
files: files as unknown as FileList,
items: items as unknown as DataTransferItemList
};
}
function item(overrides: Partial<FileDropItem> = {}): FileDropItem {
return {
kind: "file",
type: "application/octet-stream",
getAsFile: () => null,
getAsString: () => undefined,
...overrides
} as FileDropItem;
}
function namedFile(name: string): File {
return new File([name], name, { type: "text/plain" });
}
async function* handles(...entries: unknown[]): AsyncIterable<unknown> {
for (const entry of entries) yield entry;
}
async function run(): Promise<void> {
const direct = namedFile("direct.txt");
let directItemCalls = 0;
const directResult = await resolveDroppedFiles(transfer([direct], [item({
getAsFile: () => {
directItemCalls += 1;
return direct;
}
})]));
assertFiles(directResult, [direct], "native DataTransfer.files takes precedence");
assert(directItemCalls === 0, "native files must not also be resolved from items");
const itemA = namedFile("item-a.txt");
const itemB = namedFile("item-b.txt");
let itemHandleCalls = 0;
const itemResult = await resolveDroppedFiles(transfer([], [
item({
getAsFile: () => itemA,
getAsFileSystemHandle: async () => {
itemHandleCalls += 1;
return { kind: "file", getFile: async () => itemA };
}
}),
item({ getAsFile: () => itemB }),
item({ kind: "string", getAsFile: () => null })
]));
assertFiles(itemResult, [itemA, itemB], "DataTransferItem.getAsFile fallback resolves file items");
assert(itemHandleCalls === 0, "item files must not also be resolved from file-system handles");
const topLevelFile = namedFile("top-level.txt");
const rootFile = namedFile("root.txt");
const nestedFile = namedFile("nested.txt");
const deepFile = namedFile("deep.txt");
const deepDirectory = {
kind: "directory",
values: () => handles({ kind: "file", getFile: async () => deepFile })
};
const nestedDirectory = {
kind: "directory",
values: () => handles(
{ kind: "file", getFile: async () => nestedFile },
deepDirectory
)
};
const rootDirectory = {
kind: "directory",
values: () => handles(
{ kind: "file", getFile: async () => rootFile },
nestedDirectory
)
};
const handleResult = await resolveDroppedFiles(transfer([], [
item({
getAsFileSystemHandle: async () => ({
kind: "file",
getFile: async () => topLevelFile
})
}),
item({ getAsFileSystemHandle: async () => rootDirectory })
]));
assertFiles(
handleResult,
[topLevelFile, rootFile, nestedFile, deepFile],
"File System Access file handles resolve and directory handles recurse in enumeration order"
);
const legacy = namedFile("legacy.txt");
const legacyResult = await resolveDroppedFiles(transfer([], [item({
getAsFileSystemHandle: async () => {
throw new Error("file-system handles unavailable");
},
webkitGetAsEntry: () => ({
isFile: true,
isDirectory: false,
file: (resolve: (file: File) => void) => resolve(legacy)
})
})]));
assertFiles(legacyResult, [legacy], "legacy webkit file entry remains a final fallback");
const emptyResult = await resolveDroppedFiles(transfer([], [
item({ kind: "string" }),
item({
getAsFileSystemHandle: async () => ({ kind: "directory" }),
webkitGetAsEntry: () => ({ isFile: false, isDirectory: true })
})
]));
assertFiles(emptyResult, [], "empty and non-readable directory paths resolve to an empty drop");
let unreadableRejected = false;
try {
await resolveDroppedFiles(transfer([], [item({
getAsFileSystemHandle: async () => ({
kind: "file",
getFile: async () => {
throw new Error("permission denied");
}
})
})]));
} catch (error) {
unreadableRejected = error instanceof Error && error.message === "permission denied";
}
assert(unreadableRejected, "an unreadable file handle rejects so the component can report the unreadable reason");
}
void run().catch((error) => {
throw error;
});

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".file-drop-test-build",
"rootDir": "."
},
"include": [
"tests/file-drop-resolver.test.ts",
"src/components/resolveDroppedFiles.ts"
]
}