diff --git a/pages/file-input/folder-mode.page.tsx b/pages/file-input/folder-mode.page.tsx new file mode 100644 index 0000000000..0e1d155363 --- /dev/null +++ b/pages/file-input/folder-mode.page.tsx @@ -0,0 +1,73 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Box, ColumnLayout, FileInput, SpaceBetween } from '~components'; + +import ScreenshotArea from '../utils/screenshot-area'; + +export default function FolderModeScenario() { + const [folderFiles, setFolderFiles] = useState([]); + const [filteredFolderFiles, setFilteredFolderFiles] = useState([]); + const [regularFiles, setRegularFiles] = useState([]); + + return ( + + +

File input - Folder mode

+ + +
+

Folder mode (all files)

+ setFolderFiles(event.detail.value)}> + Choose folder + + + Selected files ({folderFiles.length}): + {folderFiles.map((file, index) => ( +
{(file as any).webkitRelativePath || file.name}
+ ))} +
+
+ +
+

Folder mode (images only)

+ setFilteredFolderFiles(event.detail.value)} + > + Choose folder (images) + + + Selected files ({filteredFolderFiles.length}): + {filteredFolderFiles.map((file, index) => ( +
{(file as any).webkitRelativePath || file.name}
+ ))} +
+
+ +
+

Regular file mode (comparison)

+ setRegularFiles(event.detail.value)} + > + Choose files + + + Selected files ({regularFiles.length}): + {regularFiles.map((file, index) => ( +
{file.name}
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b98b5b5542..e7e73db9e7 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -11905,6 +11905,14 @@ The event \`detail\` contains the current value of the component.", "functions": [], "name": "FileDropzone", "properties": [ + { + "description": "Specifies the file types to accept when files or folders are dropped. +Follows the same format as the native input accept attribute. +Examples: ".jpg,.png", "image/*", "application/pdf"", + "name": "accept", + "optional": true, + "type": "string", + }, { "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", "description": "Adds the specified classes to the root element of the component.", @@ -12052,6 +12060,25 @@ single form field.", "optional": true, "type": "boolean", }, + { + "description": "Specifies the selection mode for the file input. +- \`'file'\`: Standard file selection (default) +- \`'folder'\`: Enables folder selection using the \`webkitdirectory\` attribute. + When in folder mode, multiple selection is automatically enabled regardless + of the \`multiple\` prop value, and files are filtered by the \`accept\` criteria + after selection (since native accept filtering doesn't work with webkitdirectory).", + "inlineType": { + "name": ""file" | "folder"", + "type": "union", + "values": [ + "file", + "folder", + ], + }, + "name": "mode", + "optional": true, + "type": "string", + }, { "description": "Specifies the native file input \`multiple\` attribute to allow users entering more than one file.", "name": "multiple", diff --git a/src/file-dropzone/__tests__/file-dropzone.test.tsx b/src/file-dropzone/__tests__/file-dropzone.test.tsx index 58ece19b82..5109a04124 100644 --- a/src/file-dropzone/__tests__/file-dropzone.test.tsx +++ b/src/file-dropzone/__tests__/file-dropzone.test.tsx @@ -30,7 +30,11 @@ function createDragEvent(type: string, files = [file1, file2]) { (event as any).dataTransfer = { types: ['Files'], files: type === 'drop' ? files : [], - items: files.map(() => ({ kind: 'file' })), + items: files.map(file => ({ + kind: 'file', + getAsFile: () => file, + webkitGetAsEntry: () => null, // No folder support in basic tests + })), }; return event; } @@ -118,11 +122,13 @@ describe('File upload dropzone', () => { expect(dropzone).not.toHaveClass(selectors.hovered); }); - test('dropzone fires onChange on drop', () => { + test('dropzone fires onChange on drop', async () => { const dropzone = renderFileDropzone({ children: 'Drop files here' }).getElement(); fireEvent(dropzone, createDragEvent('drop')); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [file1, file2] } })); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [file1, file2] } })); + }); }); }); diff --git a/src/file-dropzone/interfaces.tsx b/src/file-dropzone/interfaces.tsx index 70bd900e79..9f8aa93819 100644 --- a/src/file-dropzone/interfaces.tsx +++ b/src/file-dropzone/interfaces.tsx @@ -13,6 +13,12 @@ export interface FileDropzoneProps extends BaseComponentProps { * Children of the Dropzone. */ children: React.ReactNode; + /** + * Specifies the file types to accept when files or folders are dropped. + * Follows the same format as the native input accept attribute. + * Examples: ".jpg,.png", "image/*", "application/pdf" + */ + accept?: string; } export namespace FileDropzoneProps { diff --git a/src/file-dropzone/internal.tsx b/src/file-dropzone/internal.tsx index a67f52651d..19c4f84ea6 100644 --- a/src/file-dropzone/internal.tsx +++ b/src/file-dropzone/internal.tsx @@ -7,6 +7,8 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js'; +import { filterByAccept } from '../internal/utils/accept-filter'; +import { processDataTransfer } from '../internal/utils/folder-traversal'; import { FileDropzoneProps } from './interfaces'; import styles from './styles.css.js'; @@ -14,6 +16,7 @@ import styles from './styles.css.js'; export default function InternalFileDropzone({ onChange, children, + accept, __internalRootRef, ...restProps }: FileDropzoneProps & InternalBaseComponentProps) { @@ -37,11 +40,17 @@ export default function InternalFileDropzone({ } }; - const onDrop = (event: React.DragEvent) => { + const onDrop = async (event: React.DragEvent) => { event.preventDefault(); setDropzoneHovered(false); - fireNonCancelableEvent(onChange, { value: Array.from(event.dataTransfer.files) }); + // Process DataTransfer to handle both files and folders + const allFiles = await processDataTransfer(event.dataTransfer); + + // Apply accept filter to the collected files + const filteredFiles = filterByAccept(allFiles, accept); + + fireNonCancelableEvent(onChange, { value: filteredFiles }); }; return ( diff --git a/src/file-input/index.tsx b/src/file-input/index.tsx index b88ed36232..7bf2989dd4 100644 --- a/src/file-input/index.tsx +++ b/src/file-input/index.tsx @@ -11,17 +11,19 @@ import InternalFileInput from './internal'; export { FileInputProps }; const FileInput = React.forwardRef( - ({ multiple, variant, ...props }: FileInputProps, ref: React.Ref) => { + ({ multiple, variant, mode, ...props }: FileInputProps, ref: React.Ref) => { const baseComponentProps = useBaseComponent('FileInput', { props: { multiple, variant, + mode, }, }); return ( setIsFocused(false); const onUploadInputChange = ({ target }: ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: target.files ? Array.from(target.files) : [] }); + let files = target.files ? Array.from(target.files) : []; + + // In folder mode, filter files by accept criteria since native accept doesn't work with webkitdirectory + if (mode === 'folder' && accept) { + files = filterByAccept(files, accept); + } + + fireNonCancelableEvent(onChange, { value: files }); }; checkControlled('FileInput', 'value', value, 'onChange', onChange); @@ -134,13 +146,14 @@ const InternalFileInput = React.forwardRef( ref={uploadInputRef} type="file" hidden={false} - multiple={multiple} - accept={accept} + multiple={effectiveMultiple} + accept={mode === 'folder' ? undefined : accept} onChange={onUploadInputChange} onFocus={onUploadInputFocus} onBlur={onUploadInputBlur} className={clsx(styles['file-input'], styles.hidden, __inputClassName)} tabIndex={tabIndex} + {...(mode === 'folder' && { webkitdirectory: '' })} {...nativeAttributes} /> diff --git a/src/internal/utils/accept-filter.ts b/src/internal/utils/accept-filter.ts new file mode 100644 index 0000000000..b4e79bc887 --- /dev/null +++ b/src/internal/utils/accept-filter.ts @@ -0,0 +1,144 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Parsed accept pattern types + */ +interface ParsedPatterns { + extensions: string[]; + mimeTypes: string[]; + wildcardMimeTypes: string[]; +} + +/** + * Parses a comma-separated accept string into categorized patterns. + * @param accept - The accept string (e.g., ".jpg,.png,image/*,application/pdf") + * @returns Parsed patterns categorized by type + */ +export function parseAcceptPatterns(accept: string): ParsedPatterns { + const extensions: string[] = []; + const mimeTypes: string[] = []; + const wildcardMimeTypes: string[] = []; + + if (!accept || accept.trim() === '') { + return { extensions, mimeTypes, wildcardMimeTypes }; + } + + const patterns = accept.split(',').map(p => p.trim().toLowerCase()); + + for (const pattern of patterns) { + if (!pattern) { + continue; + } + + if (pattern.startsWith('.')) { + // File extension pattern (e.g., ".jpg", ".png") + extensions.push(pattern); + } else if (pattern.endsWith('/*')) { + // Wildcard MIME type pattern (e.g., "image/*", "audio/*") + wildcardMimeTypes.push(pattern.slice(0, -2)); // Remove "/*" to get the type prefix + } else if (pattern.includes('/')) { + // Specific MIME type pattern (e.g., "image/jpeg", "application/pdf") + mimeTypes.push(pattern); + } + } + + return { extensions, mimeTypes, wildcardMimeTypes }; +} + +/** + * Extracts the file extension from a filename. + * @param filename - The filename to extract extension from + * @returns The extension including the dot (e.g., ".jpg"), or empty string if none + */ +function getFileExtension(filename: string): string { + const lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex === -1 || lastDotIndex === filename.length - 1) { + return ''; + } + return filename.slice(lastDotIndex).toLowerCase(); +} + +/** + * Checks if a file matches any of the specified extensions. + * @param file - The file to check + * @param extensions - Array of extensions to match (e.g., [".jpg", ".png"]) + * @returns true if the file matches any extension + */ +export function matchesExtension(file: File, extensions: string[]): boolean { + if (extensions.length === 0) { + return false; + } + + const fileExtension = getFileExtension(file.name); + if (!fileExtension) { + return false; + } + + return extensions.some(ext => ext.toLowerCase() === fileExtension); +} + +/** + * Checks if a file matches any of the specified MIME types. + * Supports both exact MIME types and wildcard patterns. + * @param file - The file to check + * @param mimeTypes - Array of exact MIME types (e.g., ["image/jpeg"]) + * @param wildcardMimeTypes - Array of wildcard type prefixes (e.g., ["image"]) + * @returns true if the file matches any MIME type pattern + */ +export function matchesMimeType(file: File, mimeTypes: string[], wildcardMimeTypes: string[] = []): boolean { + const fileMimeType = file.type.toLowerCase(); + + // Check exact MIME type matches + if (mimeTypes.length > 0 && mimeTypes.some(mime => mime === fileMimeType)) { + return true; + } + + // Check wildcard MIME type matches (e.g., "image/*" matches "image/jpeg") + if (wildcardMimeTypes.length > 0 && fileMimeType) { + const fileTypePrefix = fileMimeType.split('/')[0]; + if (wildcardMimeTypes.some(prefix => prefix === fileTypePrefix)) { + return true; + } + } + + return false; +} + +/** + * Checks if a file matches the accept criteria. + * @param file - The file to check + * @param accept - The accept string (e.g., ".jpg,.png", "image/*") + * @returns true if the file matches any pattern, or if accept is undefined/empty + */ +export function matchesAccept(file: File, accept: string | undefined): boolean { + // If no accept criteria, all files match + if (!accept || accept.trim() === '') { + return true; + } + + const { extensions, mimeTypes, wildcardMimeTypes } = parseAcceptPatterns(accept); + + // If no valid patterns were parsed, accept all files + if (extensions.length === 0 && mimeTypes.length === 0 && wildcardMimeTypes.length === 0) { + return true; + } + + // File matches if it matches ANY of the patterns (OR logic) + return matchesExtension(file, extensions) || matchesMimeType(file, mimeTypes, wildcardMimeTypes); +} + +/** + * Filters an array of files by accept criteria. + * @param files - Array of files to filter + * @param accept - The accept string (e.g., ".jpg,.png", "image/*") + * @returns Filtered array of files matching the accept criteria + */ +export function filterByAccept(files: File[], accept: string | undefined): File[] { + // If no accept criteria, return all files + if (!accept || accept.trim() === '') { + return files; + } + + return files.filter(file => matchesAccept(file, accept)); +} diff --git a/src/internal/utils/folder-traversal.ts b/src/internal/utils/folder-traversal.ts new file mode 100644 index 0000000000..04232582cd --- /dev/null +++ b/src/internal/utils/folder-traversal.ts @@ -0,0 +1,181 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Type guard to check if a FileSystemEntry is a directory entry. + * @param entry - The FileSystemEntry to check + * @returns true if the entry is a FileSystemDirectoryEntry + */ +export function isFileSystemDirectoryEntry(entry: FileSystemEntry | null): entry is FileSystemDirectoryEntry { + return entry !== null && entry.isDirectory; +} + +/** + * Type guard to check if a FileSystemEntry is a file entry. + * @param entry - The FileSystemEntry to check + * @returns true if the entry is a FileSystemFileEntry + */ +export function isFileSystemFileEntry(entry: FileSystemEntry | null): entry is FileSystemFileEntry { + return entry !== null && entry.isFile; +} + +/** + * Reads all entries from a FileSystemDirectoryReader. + * The reader's readEntries method may not return all entries in a single call, + * so we need to call it repeatedly until it returns an empty array. + * @param reader - The FileSystemDirectoryReader to read from + * @returns Promise resolving to array of all entries in the directory + */ +export function readDirectoryEntries(reader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + const allEntries: FileSystemEntry[] = []; + + const readBatch = () => { + reader.readEntries( + (entries: FileSystemEntry[]) => { + if (entries.length === 0) { + // No more entries, we're done + resolve(allEntries); + } else { + // Add entries to our collection and read more + allEntries.push(...entries); + readBatch(); + } + }, + (error: DOMException) => { + reject(error); + } + ); + }; + + readBatch(); + }); +} + +/** + * Converts a FileSystemFileEntry to a File object. + * @param entry - The FileSystemFileEntry to convert + * @returns Promise resolving to the File object + */ +export function fileEntryToFile(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + entry.file( + (file: File) => { + resolve(file); + }, + (error: DOMException) => { + reject(error); + } + ); + }); +} + +/** + * Recursively traverses a directory and collects all files. + * @param entry - The FileSystemDirectoryEntry to traverse + * @returns Promise resolving to array of all files in the directory and subdirectories + */ +export async function traverseDirectory(entry: FileSystemDirectoryEntry): Promise { + const files: File[] = []; + const reader = entry.createReader(); + const entries = await readDirectoryEntries(reader); + + for (const childEntry of entries) { + if (isFileSystemFileEntry(childEntry)) { + try { + const file = await fileEntryToFile(childEntry); + files.push(file); + } catch { + // Skip files that can't be read (e.g., permission errors) + // Continue processing other files + } + } else if (isFileSystemDirectoryEntry(childEntry)) { + try { + const subFiles = await traverseDirectory(childEntry); + files.push(...subFiles); + } catch { + // Skip directories that can't be traversed + // Continue processing other entries + } + } + } + + return files; +} + +/** + * Processes a single DataTransferItem and extracts files. + * If the item is a folder, it recursively traverses and collects all files. + * If the item is a file, it returns that single file. + * @param item - The DataTransferItem to process + * @returns Promise resolving to array of files (may be empty if item can't be processed) + */ +export async function processDataTransferItem(item: DataTransferItem): Promise { + // Check if webkitGetAsEntry is available for folder support + if (item.webkitGetAsEntry) { + const entry = item.webkitGetAsEntry(); + + if (isFileSystemDirectoryEntry(entry)) { + // It's a folder - traverse it recursively + try { + return await traverseDirectory(entry); + } catch { + // If traversal fails, return empty array + return []; + } + } else if (isFileSystemFileEntry(entry)) { + // It's a file - convert and return + try { + const file = await fileEntryToFile(entry); + return [file]; + } catch { + // If file conversion fails, try fallback + } + } + } + + // Fallback: use getAsFile() for regular files + const file = item.getAsFile(); + if (file) { + return [file]; + } + + return []; +} + +/** + * Processes a DataTransfer object and extracts all files, including from folders. + * This is the main entry point for handling drop events with folder support. + * @param dataTransfer - The DataTransfer object from a drop event + * @returns Promise resolving to array of all files + */ +export async function processDataTransfer(dataTransfer: DataTransfer): Promise { + const allFiles: File[] = []; + const items = dataTransfer.items; + + // Check if DataTransferItemList is available and has items + if (items && items.length > 0) { + // Process each item - could be files or folders + const itemPromises: Promise[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + // Only process items that are files (kind === 'file') + if (item.kind === 'file') { + itemPromises.push(processDataTransferItem(item)); + } + } + + const results = await Promise.all(itemPromises); + for (const files of results) { + allFiles.push(...files); + } + } else { + // Fallback: use dataTransfer.files directly (no folder support) + for (let i = 0; i < dataTransfer.files.length; i++) { + allFiles.push(dataTransfer.files[i]); + } + } + + return allFiles; +}