Skip to content
Merged
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
60 changes: 23 additions & 37 deletions packages/uui-file-dropzone/lib/uui-file-dropzone.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,27 +107,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 @@ -137,37 +116,44 @@ 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));
}
}

return { files, folders, rejectedFiles };
}

/**
* Get the directory entry from a DataTransferItem.
* Get the filesystem entry (file or directory) 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) {
Expand Down
64 changes: 64 additions & 0 deletions packages/uui-file-dropzone/lib/uui-file-dropzone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,68 @@ describe('UUIFileDropzoneElement', () => {
}
});
});

describe('_processRootEntries', () => {
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;
}

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).to.equal(2);
expect(result.folders[0].folderName).to.equal('folderA');
expect(result.folders[1].folderName).to.equal('folderB');
});

it('skips folders when multiple=false', async () => {
element.multiple = false;
const result = await (element as any)._processRootEntries([
mockFolderEntry('folderA'),
]);
expect(result.folders.length).to.equal(0);
});

it('returns all accepted files', async () => {
const result = await (element as any)._processRootEntries([
mockFileEntry('a.txt'),
mockFileEntry('b.txt'),
]);
expect(result.files.length).to.equal(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).to.equal(1);
expect(result.files[0].name).to.equal('photo.jpg');
expect(result.rejectedFiles.length).to.equal(1);
expect(result.rejectedFiles[0].name).to.equal('doc.txt');
});
});
});