diff --git a/src/components/file-dropzone/file-dropzone.element.ts b/src/components/file-dropzone/file-dropzone.element.ts index b41d0ac27..2550f9d50 100644 --- a/src/components/file-dropzone/file-dropzone.element.ts +++ b/src/components/file-dropzone/file-dropzone.element.ts @@ -105,27 +105,6 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) { this.addEventListener('drop', this._onDrop, false); } - /** - * Process a single file entry and categorize it as accepted or rejected. - * @param entry - The data transfer item containing the file - * @param files - Array to store accepted files - * @param rejectedFiles - Array to store rejected files - */ - private _processFileEntry( - entry: DataTransferItem, - files: File[], - rejectedFiles: File[], - ): void { - const file = entry.getAsFile(); - if (!file) return; - - if (this._isAccepted(file)) { - files.push(file); - } else { - rejectedFiles.push(file); - } - } - /** * Check if folder upload should be processed based on component settings. * @returns true if folder upload is allowed and multiple files are enabled @@ -135,26 +114,33 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) { } private async _getAllEntries(dataTransferItemList: DataTransferItemList) { - // Use BFS to traverse entire directory/file structure - const queue = [...dataTransferItemList]; + // Phase 1: Extract ALL FileSystemEntry refs synchronously. + // DataTransferItem.webkitGetAsEntry() returns null after the first await + // because the browser expires the drag data store. FileSystemEntry objects + // obtained here remain valid indefinitely. + const rootEntries = [...dataTransferItemList] + .filter(item => item?.kind === 'file') + .map(item => this._getEntry(item)) + .filter((entry): entry is FileSystemEntry => entry !== null); + + return this._processRootEntries(rootEntries); + } + private async _processRootEntries(rootEntries: FileSystemEntry[]) { const folders: UUIFileFolder[] = []; const files: File[] = []; const rejectedFiles: File[] = []; - for (const entry of queue) { - if (entry?.kind !== 'file') continue; - - const fileEntry = this._getEntry(entry); - if (!fileEntry) continue; - - if (!fileEntry.isDirectory) { - // Entry is a file - this._processFileEntry(entry, files, rejectedFiles); + for (const entry of rootEntries) { + if (!entry.isDirectory) { + const file = await this._getAsFile(entry as FileSystemFileEntry); + if (this._isAccepted(file)) { + files.push(file); + } else { + rejectedFiles.push(file); + } } else if (this._shouldProcessFolder()) { - // Entry is a directory - const structure = await this._mkdir(fileEntry); - folders.push(structure); + folders.push(await this._mkdir(entry as FileSystemDirectoryEntry)); } } @@ -165,7 +151,7 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) { * Get the directory entry from a DataTransferItem. * @remark Supports both WebKit and non-WebKit browsers. */ - private _getEntry(entry: DataTransferItem): FileSystemDirectoryEntry | null { + private _getEntry(entry: DataTransferItem): FileSystemEntry | null { let dir: FileSystemDirectoryEntry | null = null; if ('webkitGetAsEntry' in entry) { diff --git a/src/components/file-dropzone/file-dropzone.test.ts b/src/components/file-dropzone/file-dropzone.test.ts index 1a77c6cae..52749f5e1 100644 --- a/src/components/file-dropzone/file-dropzone.test.ts +++ b/src/components/file-dropzone/file-dropzone.test.ts @@ -290,4 +290,70 @@ describe('UUIFileDropzoneElement', () => { } }); }); + + describe('_processRootEntries', () => { + // Creates a minimal FileSystemFileEntry mock whose .file() yields a real File + function mockFileEntry( + name: string, + type = 'text/plain', + ): FileSystemFileEntry { + return { + isFile: true, + isDirectory: false, + name, + file: (cb: (f: File) => void) => cb(new File([''], name, { type })), + } as unknown as FileSystemFileEntry; + } + + // Creates a minimal FileSystemDirectoryEntry mock with no children + function mockFolderEntry(name: string): FileSystemDirectoryEntry { + return { + isFile: false, + isDirectory: true, + name, + createReader: () => ({ + readEntries: (cb: (entries: FileSystemEntry[]) => void) => cb([]), + }), + } as unknown as FileSystemDirectoryEntry; + } + + it('returns all folders when multiple=true', async () => { + element.multiple = true; + const result = await (element as any)._processRootEntries([ + mockFolderEntry('folderA'), + mockFolderEntry('folderB'), + ]); + expect(result.folders.length, 'should return 2 folders').toBe(2); + expect(result.folders[0].folderName).toBe('folderA'); + expect(result.folders[1].folderName).toBe('folderB'); + }); + + it('skips folders when multiple=false', async () => { + element.multiple = false; + const result = await (element as any)._processRootEntries([ + mockFolderEntry('folderA'), + ]); + expect(result.folders.length).toBe(0); + }); + + it('returns all accepted files', async () => { + const result = await (element as any)._processRootEntries([ + mockFileEntry('a.txt'), + mockFileEntry('b.txt'), + ]); + expect(result.files.length).toBe(2); + }); + + it('separates accepted and rejected files by mime type', async () => { + element.accept = 'image/*'; + const result = await (element as any)._processRootEntries([ + mockFileEntry('photo.jpg', 'image/jpeg'), + mockFileEntry('doc.txt', 'text/plain'), + ]); + expect(result.files.length).toBe(1); + expect(result.files[0].name).toBe('photo.jpg'); + expect(result.rejectedFiles.length).toBe(1); + expect(result.rejectedFiles[0].name).toBe('doc.txt'); + }); + }); });