Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 22 additions & 36 deletions src/components/file-dropzone/file-dropzone.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
}
}

Expand All @@ -165,7 +151,7 @@ export class UUIFileDropzoneElement extends LabelMixin('', LitElement) {
* Get the directory entry from a DataTransferItem.
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for _getEntry still says it returns a "directory entry", but the method now returns FileSystemEntry (can be a file or directory). Updating the doc comment/wording would avoid confusion for future readers.

Suggested change
* Get the directory entry from a DataTransferItem.
* Get the FileSystemEntry (file or directory) from a DataTransferItem.

Copilot uses AI. Check for mistakes.
* @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) {
Expand Down
66 changes: 66 additions & 0 deletions src/components/file-dropzone/file-dropzone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});