Skip to content
Open
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
73 changes: 73 additions & 0 deletions pages/file-input/folder-mode.page.tsx
Original file line number Diff line number Diff line change
@@ -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<File[]>([]);
const [filteredFolderFiles, setFilteredFolderFiles] = useState<File[]>([]);
const [regularFiles, setRegularFiles] = useState<File[]>([]);

return (
<ScreenshotArea>
<Box padding="l">
<h1>File input - Folder mode</h1>
<SpaceBetween size="xl">
<ColumnLayout columns={3}>
<div>
<h3>Folder mode (all files)</h3>
<FileInput mode="folder" value={folderFiles} onChange={event => setFolderFiles(event.detail.value)}>
Choose folder
</FileInput>
<Box margin={{ top: 's' }}>
<strong>Selected files ({folderFiles.length}):</strong>
{folderFiles.map((file, index) => (
<div key={index}>{(file as any).webkitRelativePath || file.name}</div>
))}
</Box>
</div>

<div>
<h3>Folder mode (images only)</h3>
<FileInput
mode="folder"
accept=".jpg,.jpeg,.png,.gif,image/*"
value={filteredFolderFiles}
onChange={event => setFilteredFolderFiles(event.detail.value)}
>
Choose folder (images)
</FileInput>
<Box margin={{ top: 's' }}>
<strong>Selected files ({filteredFolderFiles.length}):</strong>
{filteredFolderFiles.map((file, index) => (
<div key={index}>{(file as any).webkitRelativePath || file.name}</div>
))}
</Box>
</div>

<div>
<h3>Regular file mode (comparison)</h3>
<FileInput
mode="file"
multiple={true}
value={regularFiles}
onChange={event => setRegularFiles(event.detail.value)}
>
Choose files
</FileInput>
<Box margin={{ top: 's' }}>
<strong>Selected files ({regularFiles.length}):</strong>
{regularFiles.map((file, index) => (
<div key={index}>{file.name}</div>
))}
</Box>
</div>
</ColumnLayout>
</SpaceBetween>
</Box>
</ScreenshotArea>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions src/file-dropzone/__tests__/file-dropzone.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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] } }));
});
});
});
6 changes: 6 additions & 0 deletions src/file-dropzone/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions src/file-dropzone/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ 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';

export default function InternalFileDropzone({
onChange,
children,
accept,
__internalRootRef,
...restProps
}: FileDropzoneProps & InternalBaseComponentProps) {
Expand All @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion src/file-input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ import InternalFileInput from './internal';
export { FileInputProps };

const FileInput = React.forwardRef(
({ multiple, variant, ...props }: FileInputProps, ref: React.Ref<FileInputProps.Ref>) => {
({ multiple, variant, mode, ...props }: FileInputProps, ref: React.Ref<FileInputProps.Ref>) => {
const baseComponentProps = useBaseComponent('FileInput', {
props: {
multiple,
variant,
mode,
},
});
return (
<InternalFileInput
multiple={multiple}
variant={variant}
mode={mode}
{...props}
{...baseComponentProps}
ref={ref}
Expand Down
11 changes: 11 additions & 0 deletions src/file-input/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ export interface FileInputProps extends BaseComponentProps, FormFieldCommonValid
*/
variant?: 'button' | 'icon';

/**
* 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).
* @default 'file'
*/
mode?: 'file' | 'folder';

/**
* Adds `aria-label` to the file input element. Use this to provide an accessible name for file inputs
* that don't have visible text, and to distinguish between multiple file inputs with identical visible text.
Expand Down
19 changes: 16 additions & 3 deletions src/file-input/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { fireNonCancelableEvent } from '../internal/events';
import checkControlled from '../internal/hooks/check-controlled';
import useForwardFocus from '../internal/hooks/forward-focus';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js';
import { filterByAccept } from '../internal/utils/accept-filter';
import { joinStrings } from '../internal/utils/strings';
import { GeneratedAnalyticsMetadataFileInputComponent } from './analytics-metadata/interfaces';
import { FileInputProps } from './interfaces';
Expand All @@ -38,6 +39,7 @@ const InternalFileInput = React.forwardRef(
ariaRequired,
ariaLabel,
multiple = false,
mode = 'file',
value,
onChange,
variant = 'button',
Expand All @@ -60,6 +62,9 @@ const InternalFileInput = React.forwardRef(
const selfControlId = useUniqueId('upload-input');
const controlId = formFieldContext.controlId ?? selfControlId;

// In folder mode, always enable multiple selection
const effectiveMultiple = mode === 'folder' ? true : multiple;

useForwardFocus(ref, uploadInputRef);

const [isFocused, setIsFocused] = useState(false);
Expand All @@ -70,7 +75,14 @@ const InternalFileInput = React.forwardRef(
const onUploadInputBlur = () => setIsFocused(false);

const onUploadInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
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);
Expand Down Expand Up @@ -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}
/>

Expand Down
Loading
Loading