Skip to content

Commit d6e3626

Browse files
iamzkevinClaudioGSDB
authored andcommitted
Added feature to upload folder. Initial import.
1 parent 8eeade2 commit d6e3626

10 files changed

Lines changed: 481 additions & 9 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState } from 'react';
4+
5+
import { Box, ColumnLayout, FileInput, SpaceBetween } from '~components';
6+
7+
import ScreenshotArea from '../utils/screenshot-area';
8+
9+
export default function FolderModeScenario() {
10+
const [folderFiles, setFolderFiles] = useState<File[]>([]);
11+
const [filteredFolderFiles, setFilteredFolderFiles] = useState<File[]>([]);
12+
const [regularFiles, setRegularFiles] = useState<File[]>([]);
13+
14+
return (
15+
<ScreenshotArea>
16+
<Box padding="l">
17+
<h1>File input - Folder mode</h1>
18+
<SpaceBetween size="xl">
19+
<ColumnLayout columns={3}>
20+
<div>
21+
<h3>Folder mode (all files)</h3>
22+
<FileInput mode="folder" value={folderFiles} onChange={event => setFolderFiles(event.detail.value)}>
23+
Choose folder
24+
</FileInput>
25+
<Box margin={{ top: 's' }}>
26+
<strong>Selected files ({folderFiles.length}):</strong>
27+
{folderFiles.map((file, index) => (
28+
<div key={index}>{(file as any).webkitRelativePath || file.name}</div>
29+
))}
30+
</Box>
31+
</div>
32+
33+
<div>
34+
<h3>Folder mode (images only)</h3>
35+
<FileInput
36+
mode="folder"
37+
accept=".jpg,.jpeg,.png,.gif,image/*"
38+
value={filteredFolderFiles}
39+
onChange={event => setFilteredFolderFiles(event.detail.value)}
40+
>
41+
Choose folder (images)
42+
</FileInput>
43+
<Box margin={{ top: 's' }}>
44+
<strong>Selected files ({filteredFolderFiles.length}):</strong>
45+
{filteredFolderFiles.map((file, index) => (
46+
<div key={index}>{(file as any).webkitRelativePath || file.name}</div>
47+
))}
48+
</Box>
49+
</div>
50+
51+
<div>
52+
<h3>Regular file mode (comparison)</h3>
53+
<FileInput
54+
mode="file"
55+
multiple={true}
56+
value={regularFiles}
57+
onChange={event => setRegularFiles(event.detail.value)}
58+
>
59+
Choose files
60+
</FileInput>
61+
<Box margin={{ top: 's' }}>
62+
<strong>Selected files ({regularFiles.length}):</strong>
63+
{regularFiles.map((file, index) => (
64+
<div key={index}>{file.name}</div>
65+
))}
66+
</Box>
67+
</div>
68+
</ColumnLayout>
69+
</SpaceBetween>
70+
</Box>
71+
</ScreenshotArea>
72+
);
73+
}

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11905,6 +11905,14 @@ The event \`detail\` contains the current value of the component.",
1190511905
"functions": [],
1190611906
"name": "FileDropzone",
1190711907
"properties": [
11908+
{
11909+
"description": "Specifies the file types to accept when files or folders are dropped.
11910+
Follows the same format as the native input accept attribute.
11911+
Examples: ".jpg,.png", "image/*", "application/pdf"",
11912+
"name": "accept",
11913+
"optional": true,
11914+
"type": "string",
11915+
},
1190811916
{
1190911917
"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).",
1191011918
"description": "Adds the specified classes to the root element of the component.",
@@ -12052,6 +12060,25 @@ single form field.",
1205212060
"optional": true,
1205312061
"type": "boolean",
1205412062
},
12063+
{
12064+
"description": "Specifies the selection mode for the file input.
12065+
- \`'file'\`: Standard file selection (default)
12066+
- \`'folder'\`: Enables folder selection using the \`webkitdirectory\` attribute.
12067+
When in folder mode, multiple selection is automatically enabled regardless
12068+
of the \`multiple\` prop value, and files are filtered by the \`accept\` criteria
12069+
after selection (since native accept filtering doesn't work with webkitdirectory).",
12070+
"inlineType": {
12071+
"name": ""file" | "folder"",
12072+
"type": "union",
12073+
"values": [
12074+
"file",
12075+
"folder",
12076+
],
12077+
},
12078+
"name": "mode",
12079+
"optional": true,
12080+
"type": "string",
12081+
},
1205512082
{
1205612083
"description": "Specifies the native file input \`multiple\` attribute to allow users entering more than one file.",
1205712084
"name": "multiple",

src/file-dropzone/__tests__/file-dropzone.test.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ function createDragEvent(type: string, files = [file1, file2]) {
3030
(event as any).dataTransfer = {
3131
types: ['Files'],
3232
files: type === 'drop' ? files : [],
33-
items: files.map(() => ({ kind: 'file' })),
33+
items: files.map(file => ({
34+
kind: 'file',
35+
getAsFile: () => file,
36+
webkitGetAsEntry: () => null, // No folder support in basic tests
37+
})),
3438
};
3539
return event;
3640
}
@@ -118,11 +122,13 @@ describe('File upload dropzone', () => {
118122
expect(dropzone).not.toHaveClass(selectors.hovered);
119123
});
120124

121-
test('dropzone fires onChange on drop', () => {
125+
test('dropzone fires onChange on drop', async () => {
122126
const dropzone = renderFileDropzone({ children: 'Drop files here' }).getElement();
123127

124128
fireEvent(dropzone, createDragEvent('drop'));
125129

126-
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [file1, file2] } }));
130+
await waitFor(() => {
131+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [file1, file2] } }));
132+
});
127133
});
128134
});

src/file-dropzone/interfaces.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export interface FileDropzoneProps extends BaseComponentProps {
1313
* Children of the Dropzone.
1414
*/
1515
children: React.ReactNode;
16+
/**
17+
* Specifies the file types to accept when files or folders are dropped.
18+
* Follows the same format as the native input accept attribute.
19+
* Examples: ".jpg,.png", "image/*", "application/pdf"
20+
*/
21+
accept?: string;
1622
}
1723

1824
export namespace FileDropzoneProps {

src/file-dropzone/internal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ import clsx from 'clsx';
77
import { getBaseProps } from '../internal/base-component';
88
import { fireNonCancelableEvent } from '../internal/events';
99
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js';
10+
import { filterByAccept } from '../internal/utils/accept-filter';
11+
import { processDataTransfer } from '../internal/utils/folder-traversal';
1012
import { FileDropzoneProps } from './interfaces';
1113

1214
import styles from './styles.css.js';
1315

1416
export default function InternalFileDropzone({
1517
onChange,
1618
children,
19+
accept,
1720
__internalRootRef,
1821
...restProps
1922
}: FileDropzoneProps & InternalBaseComponentProps) {
@@ -37,11 +40,17 @@ export default function InternalFileDropzone({
3740
}
3841
};
3942

40-
const onDrop = (event: React.DragEvent) => {
43+
const onDrop = async (event: React.DragEvent) => {
4144
event.preventDefault();
4245
setDropzoneHovered(false);
4346

44-
fireNonCancelableEvent(onChange, { value: Array.from(event.dataTransfer.files) });
47+
// Process DataTransfer to handle both files and folders
48+
const allFiles = await processDataTransfer(event.dataTransfer);
49+
50+
// Apply accept filter to the collected files
51+
const filteredFiles = filterByAccept(allFiles, accept);
52+
53+
fireNonCancelableEvent(onChange, { value: filteredFiles });
4554
};
4655

4756
return (

src/file-input/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,19 @@ import InternalFileInput from './internal';
1111
export { FileInputProps };
1212

1313
const FileInput = React.forwardRef(
14-
({ multiple, variant, ...props }: FileInputProps, ref: React.Ref<FileInputProps.Ref>) => {
14+
({ multiple, variant, mode, ...props }: FileInputProps, ref: React.Ref<FileInputProps.Ref>) => {
1515
const baseComponentProps = useBaseComponent('FileInput', {
1616
props: {
1717
multiple,
1818
variant,
19+
mode,
1920
},
2021
});
2122
return (
2223
<InternalFileInput
2324
multiple={multiple}
2425
variant={variant}
26+
mode={mode}
2527
{...props}
2628
{...baseComponentProps}
2729
ref={ref}

src/file-input/interfaces.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ export interface FileInputProps extends BaseComponentProps, FormFieldCommonValid
1010
*/
1111
variant?: 'button' | 'icon';
1212

13+
/**
14+
* Specifies the selection mode for the file input.
15+
* - `'file'`: Standard file selection (default)
16+
* - `'folder'`: Enables folder selection using the `webkitdirectory` attribute.
17+
* When in folder mode, multiple selection is automatically enabled regardless
18+
* of the `multiple` prop value, and files are filtered by the `accept` criteria
19+
* after selection (since native accept filtering doesn't work with webkitdirectory).
20+
* @default 'file'
21+
*/
22+
mode?: 'file' | 'folder';
23+
1324
/**
1425
* Adds `aria-label` to the file input element. Use this to provide an accessible name for file inputs
1526
* that don't have visible text, and to distinguish between multiple file inputs with identical visible text.

src/file-input/internal.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { fireNonCancelableEvent } from '../internal/events';
1919
import checkControlled from '../internal/hooks/check-controlled';
2020
import useForwardFocus from '../internal/hooks/forward-focus';
2121
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js';
22+
import { filterByAccept } from '../internal/utils/accept-filter';
2223
import { joinStrings } from '../internal/utils/strings';
2324
import { GeneratedAnalyticsMetadataFileInputComponent } from './analytics-metadata/interfaces';
2425
import { FileInputProps } from './interfaces';
@@ -38,6 +39,7 @@ const InternalFileInput = React.forwardRef(
3839
ariaRequired,
3940
ariaLabel,
4041
multiple = false,
42+
mode = 'file',
4143
value,
4244
onChange,
4345
variant = 'button',
@@ -60,6 +62,9 @@ const InternalFileInput = React.forwardRef(
6062
const selfControlId = useUniqueId('upload-input');
6163
const controlId = formFieldContext.controlId ?? selfControlId;
6264

65+
// In folder mode, always enable multiple selection
66+
const effectiveMultiple = mode === 'folder' ? true : multiple;
67+
6368
useForwardFocus(ref, uploadInputRef);
6469

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

7277
const onUploadInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
73-
fireNonCancelableEvent(onChange, { value: target.files ? Array.from(target.files) : [] });
78+
let files = target.files ? Array.from(target.files) : [];
79+
80+
// In folder mode, filter files by accept criteria since native accept doesn't work with webkitdirectory
81+
if (mode === 'folder' && accept) {
82+
files = filterByAccept(files, accept);
83+
}
84+
85+
fireNonCancelableEvent(onChange, { value: files });
7486
};
7587

7688
checkControlled('FileInput', 'value', value, 'onChange', onChange);
@@ -134,13 +146,14 @@ const InternalFileInput = React.forwardRef(
134146
ref={uploadInputRef}
135147
type="file"
136148
hidden={false}
137-
multiple={multiple}
138-
accept={accept}
149+
multiple={effectiveMultiple}
150+
accept={mode === 'folder' ? undefined : accept}
139151
onChange={onUploadInputChange}
140152
onFocus={onUploadInputFocus}
141153
onBlur={onUploadInputBlur}
142154
className={clsx(styles['file-input'], styles.hidden, __inputClassName)}
143155
tabIndex={tabIndex}
156+
{...(mode === 'folder' && { webkitdirectory: '' })}
144157
{...nativeAttributes}
145158
/>
146159

0 commit comments

Comments
 (0)