├── .github
└── workflows
│ ├── canary.yaml
│ └── publish.yaml
├── .gitignore
├── LICENSE
├── README.md
├── eslint.config.js
├── package.json
├── packages
├── example
│ ├── .gitignore
│ ├── README.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── src
│ │ ├── App.tsx
│ │ ├── UseFilePicker.tsx
│ │ ├── UseImperativeFilePicker.tsx
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── use-file-picker
│ ├── .gitignore
│ ├── package.json
│ ├── src
│ ├── helpers
│ │ ├── encodings.ts
│ │ └── openFileDialog.ts
│ ├── index.ts
│ ├── interfaces.ts
│ ├── types.ts
│ ├── useFilePicker.ts
│ ├── useImperativeFilePicker.ts
│ ├── validators.ts
│ └── validators
│ │ ├── FileTypeValidator
│ │ └── index.ts
│ │ ├── fileAmountLimitValidator
│ │ └── index.ts
│ │ ├── fileSizeValidator
│ │ └── index.ts
│ │ ├── imageDimensionsValidator
│ │ └── index.ts
│ │ ├── index.ts
│ │ ├── persistentFileAmountLimitValidator
│ │ └── index.ts
│ │ ├── useValidators.ts
│ │ └── validatorBase.ts
│ ├── test
│ ├── AmountOfFilesValidator.test.tsx
│ ├── FilePicker.test.tsx
│ ├── FilePickerTestComponents.tsx
│ ├── FileSizeValidator.test.tsx
│ ├── FileTypeValidator.test.tsx
│ ├── ImperativeFilePicker.test.tsx
│ ├── setup.ts
│ └── testUtils.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
└── turbo.json
/.github/workflows/canary.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Canary
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - 'master'
7 |
8 | jobs:
9 | publish-canary:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | with:
14 | fetch-depth: 0
15 |
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: '22'
20 |
21 | - name: Install pnpm
22 | uses: pnpm/action-setup@v2
23 | with:
24 | version: 10
25 |
26 | - name: Get pnpm store directory
27 | shell: bash
28 | run: |
29 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
30 |
31 | - name: Setup pnpm cache
32 | uses: actions/cache@v3
33 | with:
34 | path: ${{ env.STORE_PATH }}
35 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
36 | restore-keys: |
37 | ${{ runner.os }}-pnpm-store-
38 |
39 | - name: Install dependencies
40 | run: pnpm install
41 |
42 | - name: Build package
43 | run: pnpm build
44 | working-directory: packages/use-file-picker
45 |
46 | - name: Test package
47 | run: pnpm test
48 | working-directory: packages/use-file-picker
49 |
50 | - name: Lint package
51 | run: pnpm lint
52 |
53 | - name: Bump version for canary
54 | working-directory: packages/use-file-picker
55 | run: |
56 | # Get current version from package.json
57 | CURRENT_VERSION=$(node -p "require('./package.json').version")
58 | # Remove any existing pre-release identifiers
59 | BASE_VERSION=$(echo $CURRENT_VERSION | sed 's/-.*$//')
60 |
61 | # Create canary version with PR number and run number
62 | CANARY_VERSION="${BASE_VERSION}-canary.${GITHUB_PR_NUMBER}.${GITHUB_RUN_NUMBER}"
63 |
64 | # Update package.json with new version
65 | npm pkg set version=$CANARY_VERSION
66 | env:
67 | GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
68 | GITHUB_RUN_NUMBER: ${{ github.run_number }}
69 |
70 | - name: Configure NPM
71 | run: |
72 | echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc
73 | working-directory: packages/use-file-picker
74 | env:
75 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
76 |
77 | - name: Publish canary
78 | run: pnpm publish --tag canary --no-git-checks
79 | working-directory: packages/use-file-picker
80 | env:
81 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
82 |
83 | - name: Check if package.json version is already published
84 | run: |
85 | PACKAGE_VERSION=$(node -p "require('./package.json').version")
86 | LATEST_VERSION=$(npm view use-file-picker version)
87 | if [ "$LATEST_VERSION" = "$PACKAGE_VERSION" ]; then
88 | echo "Version $PACKAGE_VERSION is already published"
89 | exit 1
90 | fi
91 | working-directory: packages/use-file-picker
92 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Package
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | with:
14 | fetch-depth: 0
15 |
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: '22'
20 |
21 | - name: Install pnpm
22 | uses: pnpm/action-setup@v2
23 | with:
24 | version: 10
25 |
26 | - name: Get pnpm store directory
27 | shell: bash
28 | run: |
29 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
30 |
31 | - name: Setup pnpm cache
32 | uses: actions/cache@v3
33 | with:
34 | path: ${{ env.STORE_PATH }}
35 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
36 | restore-keys: |
37 | ${{ runner.os }}-pnpm-store-
38 |
39 | - name: Install dependencies
40 | run: pnpm install
41 |
42 | - name: Build package
43 | run: pnpm build
44 | working-directory: packages/use-file-picker
45 |
46 | - name: Test package
47 | run: pnpm test
48 | working-directory: packages/use-file-picker
49 |
50 | - name: Lint package
51 | run: pnpm lint
52 |
53 | - name: Configure NPM
54 | run: |
55 | echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc
56 | working-directory: packages/use-file-picker
57 | env:
58 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
59 |
60 | - name: Publish package
61 | run: pnpm publish --no-git-checks
62 | working-directory: packages/use-file-picker
63 | env:
64 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .turbo
5 | /**/dist
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Milosz Jankiewicz, Kamil Planer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
Welcome to use-file-picker 👋
2 |
3 | ## _Simple react hook to open browser file selector._
4 |
5 | [](https://www.npmjs.com/package/use-file-picker)  [](https://twitter.com/twitter.com/jaaneek)
6 |
7 | **🏠 [Homepage](https://github.com/Jaaneek/useFilePicker 'use-file-picker Github')**
8 |
9 | ## Documentation
10 |
11 | - [Install](#install)
12 | - [Usage](#usage)
13 | - [Simple txt file content reading](#simple-txt-file-content-reading)
14 | - [Reading and rendering Images](#reading-and-rendering-images)
15 | - [On change callbacks](#on-change-callbacks)
16 | - [Advanced usage](#advanced-usage)
17 | - [Callbacks](#callbacks)
18 | - [Keeping the previously selected files and removing them from selection on demand](#keeping-the-previously-selected-files-and-removing-them-from-selection-on-demand)
19 | - [API](#api)
20 | - [Props](#props)
21 | - [Returns](#returns)
22 | - [Built-in validators](#built-in-validators)
23 | - [Custom validation](#custom-validation)
24 | - [Example validator](#example-validator)
25 | - [Authors](#authors)
26 | - [🤝 Contributing](#-contributing)
27 | - [Show your support](#show-your-support)
28 |
29 | ## Install
30 |
31 | `npm i use-file-picker`
32 | or
33 | `yarn add use-file-picker`
34 |
35 | ## Usage
36 |
37 | ### Simple txt file content reading
38 |
39 | ```jsx
40 | import { useFilePicker } from 'use-file-picker';
41 | import React from 'react';
42 |
43 | export default function App() {
44 | const { openFilePicker, filesContent, loading } = useFilePicker({
45 | accept: '.txt',
46 | });
47 |
48 | if (loading) {
49 | return Loading...
;
50 | }
51 |
52 | return (
53 |
54 |
openFilePicker()}>Select files
55 |
56 | {filesContent.map((file, index) => (
57 |
58 |
{file.name}
59 |
{file.content}
60 |
61 |
62 | ))}
63 |
64 | );
65 | }
66 | ```
67 |
68 | ### Reading and rendering Images
69 |
70 | ```ts
71 | import { useFilePicker } from 'use-file-picker';
72 | import {
73 | FileAmountLimitValidator,
74 | FileTypeValidator,
75 | FileSizeValidator,
76 | ImageDimensionsValidator,
77 | } from 'use-file-picker/validators';
78 |
79 | export default function App() {
80 | const { openFilePicker, filesContent, loading, errors } = useFilePicker({
81 | readAs: 'DataURL',
82 | accept: 'image/*',
83 | multiple: true,
84 | validators: [
85 | new FileAmountLimitValidator({ max: 1 }),
86 | new FileTypeValidator(['jpg', 'png']),
87 | new FileSizeValidator({ maxFileSize: 50 * 1024 * 1024 /* 50 MB */ }),
88 | new ImageDimensionsValidator({
89 | maxHeight: 900, // in pixels
90 | maxWidth: 1600,
91 | minHeight: 600,
92 | minWidth: 768,
93 | }),
94 | ],
95 | });
96 |
97 | if (loading) {
98 | return Loading...
;
99 | }
100 |
101 | if (errors.length) {
102 | return Error...
;
103 | }
104 |
105 | return (
106 |
107 |
openFilePicker()}>Select files
108 |
109 | {filesContent.map((file, index) => (
110 |
111 |
{file.name}
112 |
113 |
114 |
115 | ))}
116 |
117 | );
118 | }
119 | ```
120 |
121 | ### On change callbacks
122 |
123 | ```ts
124 | import { useFilePicker } from 'use-file-picker';
125 |
126 | export default function App() {
127 | const { openFilePicker, filesContent, loading, errors } = useFilePicker({
128 | readAs: 'DataURL',
129 | accept: 'image/*',
130 | multiple: true,
131 | onFilesSelected: ({ plainFiles, filesContent, errors }) => {
132 | // this callback is always called, even if there are errors
133 | console.log('onFilesSelected', plainFiles, filesContent, errors);
134 | },
135 | onFilesRejected: ({ errors }) => {
136 | // this callback is called when there were validation errors
137 | console.log('onFilesRejected', errors);
138 | },
139 | onFilesSuccessfullySelected: ({ plainFiles, filesContent }) => {
140 | // this callback is called when there were no validation errors
141 | console.log('onFilesSuccessfullySelected', plainFiles, filesContent);
142 | },
143 | });
144 |
145 | if (loading) {
146 | return Loading...
;
147 | }
148 |
149 | if (errors.length) {
150 | return Error...
;
151 | }
152 |
153 | return (
154 |
155 |
openFilePicker()}>Select files
156 |
157 | {filesContent.map((file, index) => (
158 |
159 |
{file.name}
160 |
161 |
162 |
163 | ))}
164 |
165 | );
166 | }
167 | ```
168 |
169 | ### Advanced usage
170 |
171 | ```ts
172 | import { useFilePicker } from 'use-file-picker';
173 | import React from 'react';
174 |
175 | export default function App() {
176 | const { openFilePicker, filesContent, loading, errors, plainFiles, clear } = useFilePicker({
177 | multiple: true,
178 | readAs: 'DataURL', // availible formats: "Text" | "BinaryString" | "ArrayBuffer" | "DataURL"
179 | // accept: '.ics,.pdf',
180 | accept: ['.json', '.pdf'],
181 | validators: [new FileAmountLimitValidator({ min: 2, max: 3 })],
182 | // readFilesContent: false, // ignores file content
183 | });
184 |
185 | if (errors.length) {
186 | return (
187 |
188 |
openFilePicker()}>Something went wrong, retry!
189 | {errors.map(err => (
190 |
191 | {err.name}: {err.reason}
192 | /* e.g. "name":"FileAmountLimitError", "reason":"MAX_AMOUNT_OF_FILES_EXCEEDED" */
193 |
194 | ))}
195 |
196 | );
197 | }
198 |
199 | if (loading) {
200 | return Loading...
;
201 | }
202 |
203 | return (
204 |
205 |
openFilePicker()}>Select file
206 |
clear()}>Clear
207 |
208 | Number of selected files:
209 | {plainFiles.length}
210 |
211 | {/* If readAs is set to DataURL, You can display an image */}
212 | {!!filesContent.length &&
}
213 |
214 | {plainFiles.map(file => (
215 |
{file.name}
216 | ))}
217 |
218 | );
219 | }
220 | ```
221 |
222 | ### Callbacks
223 |
224 | You can hook your logic into callbacks that will be fired at specific events during the lifetime of the hook. useFilePicker accepts these callbacks:
225 |
226 | - onFilesSelected
227 | - onFilesRejected
228 | - onFilesSuccessfullySelected
229 | - onClear
230 |
231 | These are described in more detail in the [Props](#props) section.
232 |
233 | ```ts
234 | import { useFilePicker } from 'use-file-picker';
235 |
236 | export default function App() {
237 | const { openFilePicker, filesContent, loading, errors, plainFiles, clear } = useFilePicker({
238 | multiple: true,
239 | readAs: 'DataURL',
240 | accept: ['.json', '.pdf'],
241 | onFilesSelected: ({ plainFiles, filesContent, errors }) => {
242 | // this callback is always called, even if there are errors
243 | console.log('onFilesSelected', plainFiles, filesContent, errors);
244 | },
245 | onFilesRejected: ({ errors }) => {
246 | // this callback is called when there were validation errors
247 | console.log('onFilesRejected', errors);
248 | },
249 | onFilesSuccessfullySelected: ({ plainFiles, filesContent }) => {
250 | // this callback is called when there were no validation errors
251 | console.log('onFilesSuccessfullySelected', plainFiles, filesContent);
252 | },
253 | onClear: () => {
254 | // this callback is called when the selection is cleared
255 | console.log('onClear');
256 | },
257 | });
258 | }
259 | ```
260 |
261 | `useImperativePicker` hook also accepts the callbacks listed above. Additionally, it accepts the `onFileRemoved` callback, which is called when a file is removed from the list of selected files.
262 |
263 | ```ts
264 | import { useImperativeFilePicker } from 'use-file-picker';
265 |
266 | export default function App() {
267 | const { openFilePicker, filesContent, loading, errors, plainFiles, clear } = useImperativeFilePicker({
268 | onFileRemoved: (removedFile, removedIndex) => {
269 | // this callback is called when a file is removed from the list of selected files
270 | console.log('onFileRemoved', removedFile, removedIndex);
271 | },
272 | });
273 | }
274 | ```
275 |
276 | ### Keeping the previously selected files and removing them from selection on demand
277 |
278 | If you want to keep the previously selected files and remove them from the selection, you can use a separate hook called `useImperativeFilePicker` that is also exported in this package. For files removal, you can use `removeFileByIndex` or `removeFileByReference` functions.
279 |
280 | ```ts
281 | import React from 'react';
282 | import { useImperativeFilePicker } from 'use-file-picker';
283 |
284 | const Imperative = () => {
285 | const { openFilePicker, filesContent, loading, errors, plainFiles, clear, removeFileByIndex, removeFileByReference } =
286 | useImperativeFilePicker({
287 | multiple: true,
288 | readAs: 'Text',
289 | readFilesContent: true,
290 | });
291 |
292 | if (errors.length) {
293 | return (
294 |
295 |
openFilePicker()}>Something went wrong, retry!
296 |
297 | {console.log(errors)}
298 | {errors.map(err => (
299 |
300 | {err.name}: {err.reason}
301 | /* e.g. "name":"FileAmountLimitError", "reason":"MAX_AMOUNT_OF_FILES_EXCEEDED" */
302 |
303 | ))}
304 |
305 |
306 | );
307 | }
308 |
309 | if (loading) {
310 | return Loading...
;
311 | }
312 |
313 | return (
314 |
315 |
openFilePicker()}>Select file
316 |
clear()}>Clear
317 |
318 | Amount of selected files:
319 | {plainFiles.length}
320 |
321 | Amount of filesContent:
322 | {filesContent.length}
323 |
324 | {plainFiles.map((file, i) => (
325 |
326 |
327 |
{file.name}
328 |
removeFileByReference(file)}>
329 | Remove by reference
330 |
331 |
removeFileByIndex(i)}>
332 | Remove by index
333 |
334 |
335 |
{filesContent[i]?.content}
336 |
337 | ))}
338 |
339 | );
340 | };
341 | ```
342 |
343 | ## API
344 |
345 | ### Props
346 |
347 | | Prop name | Description | Default value | Example values |
348 | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------ |
349 | | multiple | Allow user to pick multiple files at once | true | true, false |
350 | | accept | Set type of files that user can choose from the list | "\*" | [".png", ".txt"], "image/\*", ".txt" |
351 | | readAs | Set a return type of [filesContent](#returns) | "Text" | "DataURL", "Text", "BinaryString", "ArrayBuffer" |
352 | | readFilesContent | Ignores files content and omits reading process if set to false | true | true, false |
353 | | validators | Add validation logic. You can use some of the [built-in validators](#built-in-validators) like FileAmountLimitValidator or create your own [custom validation](#custom-validation) logic | [] | [MyValidator, MySecondValidator] |
354 | | initializeWithCustomParameters | allows for customization of the input element that is created by the file picker. It accepts a function that takes in the input element as a parameter and can be used to set any desired attributes or styles on the element. | n/a | (input) => input.setAttribute("disabled", "") |
355 | | encoding | Specifies the encoding to use when reading text files. Only applicable when readAs is set to "Text". Available options include all standard encodings. | "utf-8" | "latin1", "utf-8", "windows-1252" |
356 | | onFilesSelected | A callback function that is called when files are successfully selected. The function is passed an array of objects with information about each successfully selected file | n/a | (data) => console.log(data) |
357 | | onFilesSuccessfullySelected | A callback function that is called when files are successfully selected. The function is passed an array of objects with information about each successfully selected file | n/a | (data) => console.log(data) |
358 | | onFilesRejected | A callback function that is called when files are rejected due to validation errors or other issues. The function is passed an array of objects with information about each rejected file | n/a | (data) => console.log(data) |
359 | | onClear | A callback function that is called when the selection is cleared. | n/a | () => console.log('selection cleared') |
360 |
361 | ### Returns
362 |
363 | | Name | Description |
364 | | -------------- | ---------------------------------------------------------------------------------------- |
365 | | openFilePicker | Opens file selector |
366 | | clear | Clears all files and errors |
367 | | filesContent | Get files array of type FileContent |
368 | | plainFiles | Get array of the [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) objects |
369 | | loading | True if the reading files is in progress, otherwise False |
370 | | errors | Get errors array of type FileError if any appears |
371 |
372 | ### Built-in validators
373 |
374 | useFilePicker has some built-in validators that can be used out of the box. These are:
375 |
376 | - FileAmountLimitValidator - allows to select a specific number of files (min and max) that will pass validation. This will work great with simple useFilePicker use cases, it will run on every file selection.
377 | - FileTypeValidator - allows to select files with a specific extension that will pass validation.
378 | - FileSizeValidator - allows to select files of a specific size (min and max) in bytes that will pass validation.
379 | - ImageDimensionsValidator - allows to select images of a specific size (min and max) that will pass validation.
380 | - PersistentFileAmountLimitValidator - allows to select a specific number of files (min and max) that will pass validation but it takes into consideration the previously selected files. This will work great with useImperativeFilePicker hook when you want to keep track of how many files are selected even when user is allowed to trigger selection multiple times before submitting the files.
381 |
382 | ### Custom validation
383 |
384 | useFilePicker allows for injection of custom validation logic. Validation is divided into two steps:
385 |
386 | - **validateBeforeParsing** - takes place before parsing. You have access to config passed as argument to useFilePicker hook and all plain file objects of selected files by user. Called once for all files after selection.
387 | - **validateAfterParsing** - takes place after parsing (or is never called if readFilesContent is set to false).You have access to config passed as argument to useFilePicker hook, FileWithPath object that is currently being validated and the reader object that has loaded that file. Called individually for every file.
388 |
389 | ```ts
390 | interface Validator {
391 | validateBeforeParsing(config: UseFilePickerConfig, plainFiles: File[]): Promise;
392 | validateAfterParsing(config: UseFilePickerConfig, file: FileWithPath, reader: FileReader): Promise;
393 | }
394 | ```
395 |
396 | Validators must return Promise object - resolved promise means that file passed validation, rejected promise means that file did not pass.
397 |
398 | Since version 2.0, validators also have optional callback handlers that will be run when an important event occurs during the selection process. These are:
399 |
400 | ```ts
401 | /**
402 | * lifecycle method called after user selection (regardless of validation result)
403 | */
404 | onFilesSelected(
405 | _data: SelectedFilesOrErrors, CustomErrors>
406 | ): Promise | void {}
407 | /**
408 | * lifecycle method called after successful validation
409 | */
410 | onFilesSuccessfullySelected(_data: SelectedFiles>): Promise | void {}
411 | /**
412 | * lifecycle method called after failed validation
413 | */
414 | onFilesRejected(_data: FileErrors): Promise | void {}
415 | /**
416 | * lifecycle method called after the selection is cleared
417 | */
418 | onClear(): Promise | void {}
419 |
420 | /**
421 | * This method is called when file is removed from the list of selected files.
422 | * Invoked only by the useImperativeFilePicker hook
423 | * @param _removedFile removed file
424 | * @param _removedIndex index of removed file
425 | */
426 | onFileRemoved(_removedFile: File, _removedIndex: number): Promise | void {}
427 | ```
428 |
429 | #### Example validator
430 |
431 | ```ts
432 | /**
433 | * validateBeforeParsing allows the user to select only an even number of files
434 | * validateAfterParsing allows the user to select only files that have not been modified in the last 24 hours
435 | */
436 | class CustomValidator extends Validator {
437 | async validateBeforeParsing(config: ConfigType, plainFiles: File) {
438 | return new Promise((res, rej) => (plainFiles.length % 2 === 0 ? res() : rej({ oddNumberOfFiles: true })));
439 | }
440 | async validateAfterParsing(config: ConfigType, file: FileWithPath, reader: FileReader) {
441 | return new Promise((res, rej) =>
442 | file.lastModified < new Date().getTime() - 24 * 60 * 60 * 1000
443 | ? res()
444 | : rej({ fileRecentlyModified: true, lastModified: file.lastModified })
445 | );
446 | }
447 | onFilesSuccessfullySelected(data: SelectedFiles>) {
448 | console.log(data);
449 | }
450 | }
451 | ```
452 |
453 | ## Authors
454 |
455 | 👤 **Milosz Jankiewicz**
456 |
457 | - Twitter: [@twitter.com/jaaneek/](https://twitter.com/jaaneek)
458 | - Github: [@Jaaneek](https://github.com/Jaaneek)
459 | - LinkedIn: [@https://www.linkedin.com/in/jaaneek](https://www.linkedin.com/in/mi%C5%82osz-jankiewicz-554562168/)
460 |
461 | 👤 **Kamil Planer**
462 |
463 | - Github: [@MrKampla](https://github.com/MrKampla)
464 | - LinkedIn: [@https://www.linkedin.com/in/kamil-planer/](https://www.linkedin.com/in/kamil-planer/)
465 |
466 | ## [](https://github.com/Jaaneek/useFilePicker#-contributing)🤝 Contributing
467 |
468 | Contributions, issues and feature requests are welcome!
469 | Feel free to check the [issues page](https://github.com/Jaaneek/useFilePicker/issues).
470 |
471 | ## [](https://github.com/Jaaneek/useFilePicker#show-your-support)Show your support
472 |
473 | Give a ⭐️ if this project helped you!
474 |
475 | 
476 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import tseslint from 'typescript-eslint';
4 | import pluginReact from 'eslint-plugin-react';
5 | import { defineConfig } from 'eslint/config';
6 |
7 | export default defineConfig([
8 | {
9 | files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
10 | plugins: { js },
11 | extends: ['js/recommended'],
12 | ignores: ['**/dist/**', '**/node_modules/**', '**/build/**'],
13 | },
14 | {
15 | files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
16 | languageOptions: { globals: globals.browser },
17 | ignores: ['**/dist/**', '**/node_modules/**', '**/build/**'],
18 | },
19 | ...tseslint.configs.recommended.map(config => ({
20 | ...config,
21 | ignores: ['**/dist/**', '**/node_modules/**', '**/build/**'],
22 | })),
23 | {
24 | ...pluginReact.configs.flat.recommended,
25 | rules: {
26 | ...pluginReact.configs.flat.recommended.rules,
27 | 'react/react-in-jsx-scope': 'off',
28 | },
29 | settings: {
30 | ...pluginReact.configs.flat.recommended.settings,
31 | react: {
32 | version: '19',
33 | },
34 | },
35 | ignores: ['**/dist/**', '**/node_modules/**', '**/build/**'],
36 | },
37 | ]);
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@use-file-picker/base",
3 | "private": true,
4 | "description": "Simple react hook to open browser file selector.",
5 | "version": "0.0.1",
6 | "license": "MIT",
7 | "author": "Miłosz Jankiewicz, Kamil Planer",
8 | "homepage": "https://github.com/Jaaneek/useFilePicker",
9 | "repository": {
10 | "url": "https://github.com/Jaaneek/useFilePicker",
11 | "type": "git"
12 | },
13 | "type": "module",
14 | "scripts": {
15 | "dev": "turbo dev",
16 | "build": "turbo build",
17 | "test": "turbo test",
18 | "lint": "eslint .",
19 | "lint:fix": "eslint . --fix",
20 | "type-check": "turbo type-check"
21 | },
22 | "prettier": {
23 | "printWidth": 120,
24 | "semi": true,
25 | "singleQuote": true,
26 | "trailingComma": "es5",
27 | "arrowParens": "avoid",
28 | "endOfLine": "lf"
29 | },
30 | "packageManager": "pnpm@10.11.0",
31 | "engines": {
32 | "node": ">=12"
33 | },
34 | "devDependencies": {
35 | "@eslint/js": "^9.27.0",
36 | "eslint": "^9.27.0",
37 | "eslint-plugin-react": "^7.37.5",
38 | "globals": "^16.2.0",
39 | "pkgroll": "^2.12.2",
40 | "prettier": "^3.5.3",
41 | "turbo": "^2.5.3",
42 | "typescript": "^5.8.3",
43 | "typescript-eslint": "^8.32.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/example/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/example/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config({
16 | extends: [
17 | // Remove ...tseslint.configs.recommended and replace with this
18 | ...tseslint.configs.recommendedTypeChecked,
19 | // Alternatively, use this for stricter rules
20 | ...tseslint.configs.strictTypeChecked,
21 | // Optionally, add this for stylistic rules
22 | ...tseslint.configs.stylisticTypeChecked,
23 | ],
24 | languageOptions: {
25 | // other options...
26 | parserOptions: {
27 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
28 | tsconfigRootDir: import.meta.dirname,
29 | },
30 | },
31 | })
32 | ```
33 |
34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35 |
36 | ```js
37 | // eslint.config.js
38 | import reactX from 'eslint-plugin-react-x'
39 | import reactDom from 'eslint-plugin-react-dom'
40 |
41 | export default tseslint.config({
42 | plugins: {
43 | // Add the react-x and react-dom plugins
44 | 'react-x': reactX,
45 | 'react-dom': reactDom,
46 | },
47 | rules: {
48 | // other rules...
49 | // Enable its recommended typescript rules
50 | ...reactX.configs['recommended-typescript'].rules,
51 | ...reactDom.configs.recommended.rules,
52 | },
53 | })
54 | ```
55 |
--------------------------------------------------------------------------------
/packages/example/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/packages/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Use file picker example
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/packages/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite --host",
8 | "lint": "eslint .",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^19.1.0",
13 | "react-dom": "^19.1.0",
14 | "use-file-picker": "workspace:*"
15 | },
16 | "devDependencies": {
17 | "@eslint/js": "^9.25.0",
18 | "@types/react": "^19.1.2",
19 | "@types/react-dom": "^19.1.2",
20 | "@vitejs/plugin-react-swc": "^3.9.0",
21 | "eslint": "^9.25.0",
22 | "eslint-plugin-react-hooks": "^5.2.0",
23 | "eslint-plugin-react-refresh": "^0.4.19",
24 | "globals": "^16.0.0",
25 | "typescript": "~5.8.3",
26 | "typescript-eslint": "^8.30.1",
27 | "vite": "^6.3.5"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { UseFilePicker } from './UseFilePicker';
2 | import UseImperativeFilePicker from './UseImperativeFilePicker';
3 |
4 | function App() {
5 | return (
6 | <>
7 | useFilePicker
8 |
9 | useImperativeFilePicker
10 |
11 | >
12 | );
13 | }
14 |
15 | export default App;
16 |
--------------------------------------------------------------------------------
/packages/example/src/UseFilePicker.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unused-vars */
2 | import { useFilePicker } from 'use-file-picker';
3 | import { Validator, ImageDimensionsValidator, FileAmountLimitValidator } from 'use-file-picker/validators';
4 | import { type UseFilePickerConfig, type FileWithPath } from 'use-file-picker';
5 | import { useState } from 'react';
6 |
7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8 | // @ts-ignore
9 | class CustomValidator extends Validator {
10 | /**
11 | * Validation takes place before parsing. You have access to config passed as argument to useFilePicker hook
12 | * and all plain file objects of selected files by user. Called once for all files after selection.
13 | * Example validator below allowes only even amount of files
14 | * @returns {Promise} resolve means that file passed validation, reject means that file did not pass
15 | */
16 | async validateBeforeParsing(_config: UseFilePickerConfig, plainFiles: File[]): Promise {
17 | return new Promise((res, rej) => (plainFiles.length % 2 === 0 ? res(undefined) : rej({ oddNumberOfFiles: true })));
18 | }
19 | /**
20 | * Validation takes place after parsing (or is never called if readFilesContent is set to false).
21 | * You have access to config passed as argument to useFilePicker hook, FileWithPath object that is currently
22 | * being validated and the reader object that has loaded that file. Called individually for every file.
23 | * Example validator below allowes only if file hasn't been modified in last 24 hours
24 | * @returns {Promise} resolve means that file passed validation, reject means that file did not pass
25 | */
26 | async validateAfterParsing(_config: UseFilePickerConfig, file: FileWithPath, _reader: FileReader): Promise {
27 | return new Promise((res, rej) =>
28 | file.lastModified < new Date().getTime() - 24 * 60 * 60 * 1000
29 | ? res(undefined)
30 | : rej({ fileRecentlyModified: true, lastModified: file.lastModified })
31 | );
32 | }
33 | }
34 |
35 | export const UseFilePicker = () => {
36 | const [selectionMode, setSelectionMode] = useState<'file' | 'dir'>('file');
37 | const { openFilePicker, filesContent, loading, errors, plainFiles, clear } = useFilePicker({
38 | multiple: true,
39 | readAs: 'Text', // availible formats: "Text" | "BinaryString" | "ArrayBuffer" | "DataURL"
40 | initializeWithCustomParameters(inputElement) {
41 | inputElement.webkitdirectory = selectionMode === 'dir';
42 | inputElement.addEventListener('cancel', () => {
43 | alert('cancel');
44 | });
45 | },
46 | // accept: ['.png', '.jpeg', '.heic'],
47 | // readFilesContent: false, // ignores file content,
48 | validators: [
49 | new FileAmountLimitValidator({ min: 1, max: 3 }),
50 | // new FileSizeValidator({ maxFileSize: 100_000 /* 100kb in bytes */ }),
51 | new ImageDimensionsValidator({ maxHeight: 600 }),
52 | ],
53 | onFilesSelected: ({ plainFiles, filesContent, errors }) => {
54 | // this callback is always called, even if there are errors
55 | console.log('onFilesSelected', plainFiles, filesContent, errors);
56 | },
57 | onFilesRejected: ({ errors }) => {
58 | // this callback is called when there were validation errors
59 | console.log('onFilesRejected', errors);
60 | },
61 | onFilesSuccessfullySelected: ({ plainFiles, filesContent }) => {
62 | // this callback is called when there were no validation errors
63 | console.log('onFilesSuccessfullySelected', plainFiles, filesContent);
64 | },
65 | });
66 |
67 | if (loading) {
68 | return Loading...
;
69 | }
70 |
71 | return (
72 |
73 |
setSelectionMode(selectionMode === 'file' ? 'dir' : 'file')}>
74 | {selectionMode === 'file' ? 'FILE' : 'DIR'}
75 |
76 |
openFilePicker()}>Select file
77 |
clear()}>Clear
78 |
79 | Amount of selected files:
80 | {plainFiles.length}
81 |
82 | {errors.length ? (
83 |
84 |
Errors:
85 | {errors.map((error, index) => (
86 |
87 |
{index + 1}.
88 | {Object.entries(error).map(([key, value]) => (
89 |
90 | {key}: {typeof value === 'string' ? value : Array.isArray(value) ? value.join(', ') : null}
91 |
92 | ))}
93 |
94 | ))}
95 |
96 | ) : null}
97 | {/* If readAs is set to DataURL, You can display an image */}
98 | {filesContent.length ?
: null}
99 | {filesContent.length ?
{filesContent[0].content}
: null}
100 |
101 | {plainFiles.map(file => (
102 |
{file.name}
103 | ))}
104 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/packages/example/src/UseImperativeFilePicker.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useImperativeFilePicker } from 'use-file-picker';
3 | import { PersistentFileAmountLimitValidator } from 'use-file-picker/validators';
4 |
5 | export const UseImperativeFilePicker = () => {
6 | // for imperative file picker, if you want to limit amount of files selected by user, you need to pass persistent validator
7 | const validators = React.useMemo(() => [new PersistentFileAmountLimitValidator({ min: 2, max: 3 })], []);
8 | const [selectionMode, setSelectionMode] = useState<'file' | 'dir'>('file');
9 | const { openFilePicker, filesContent, loading, errors, plainFiles, clear, removeFileByIndex, removeFileByReference } =
10 | useImperativeFilePicker({
11 | multiple: true,
12 | readAs: 'DataURL',
13 | readFilesContent: true,
14 | validators,
15 | initializeWithCustomParameters(inputElement) {
16 | inputElement.webkitdirectory = selectionMode === 'dir';
17 | },
18 | onFilesSelected: ({ plainFiles, filesContent, errors }) => {
19 | // this callback is always called, even if there are errors
20 | console.log('onFilesSelected', plainFiles, filesContent, errors);
21 | },
22 | onFilesRejected: ({ errors }) => {
23 | // this callback is called when there were validation errors
24 | console.log('onFilesRejected', errors);
25 | },
26 | onFilesSuccessfullySelected: ({ plainFiles, filesContent }) => {
27 | // this callback is called when there were no validation errors
28 | console.log('onFilesSuccessfullySelected', plainFiles, filesContent);
29 | },
30 | onFileRemoved(file, removedIndex) {
31 | // this callback is called when file is removed
32 | console.log('onFileRemoved', file, removedIndex);
33 | },
34 | });
35 |
36 | if (loading) {
37 | return Loading...
;
38 | }
39 |
40 | return (
41 |
42 |
setSelectionMode(selectionMode === 'file' ? 'dir' : 'file')}>
43 | {selectionMode === 'file' ? 'FILE' : 'DIR'}
44 |
45 |
openFilePicker()}>Select file
46 |
clear()}>Clear
47 |
48 | Amount of selected files:
49 | {plainFiles.length}
50 |
51 | Amount of filesContent:
52 | {filesContent.length}
53 |
54 | {!!errors.length && (
55 | <>
56 | Errors:
57 | {errors.map((error, index) => (
58 |
59 |
{index + 1}.
60 | {Object.entries(error).map(([key, value]) => (
61 |
62 | {key}: {typeof value === 'string' ? value : Array.isArray(value) ? value.join(', ') : null}
63 |
64 | ))}
65 |
66 | ))}
67 | >
68 | )}
69 |
70 | {plainFiles.map((file, i) => (
71 |
72 |
73 |
{file.name}
74 |
removeFileByReference(file)}>
75 | Remove by reference
76 |
77 |
removeFileByIndex(i)}>
78 | Remove by index
79 |
80 |
81 |
{filesContent[i]?.content}
82 |
83 | ))}
84 |
85 | );
86 | };
87 |
88 | export default UseImperativeFilePicker;
89 |
--------------------------------------------------------------------------------
/packages/example/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import App from './App.tsx';
4 |
5 | createRoot(document.getElementById('root')!).render(
6 |
7 |
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/packages/example/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/example/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/packages/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/example/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "erasableSyntaxOnly": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/example/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | optimizeDeps: {
8 | // enables hot reload for use-file-picker package
9 | exclude: ['use-file-picker'],
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/packages/use-file-picker/.gitignore:
--------------------------------------------------------------------------------
1 | README.md
2 | LICENSE
--------------------------------------------------------------------------------
/packages/use-file-picker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-file-picker",
3 | "description": "Simple react hook to open browser file selector.",
4 | "version": "2.1.4",
5 | "license": "MIT",
6 | "author": "Milosz Jankiewicz, Kamil Planer",
7 | "homepage": "https://github.com/Jaaneek/useFilePicker",
8 | "repository": {
9 | "url": "https://github.com/Jaaneek/useFilePicker",
10 | "type": "git"
11 | },
12 | "type": "module",
13 | "main": "./dist/index.cjs",
14 | "module": "./dist/index.mjs",
15 | "types": "./dist/index.d.ts",
16 | "exports": {
17 | ".": {
18 | "require": {
19 | "types": "./dist/index.d.ts",
20 | "default": "./dist/index.cjs"
21 | },
22 | "import": {
23 | "types": "./dist/index.d.ts",
24 | "default": "./dist/index.mjs"
25 | }
26 | },
27 | "./validators": {
28 | "require": {
29 | "types": "./dist/validators/index.d.ts",
30 | "default": "./dist/validators/index.cjs"
31 | },
32 | "import": {
33 | "types": "./dist/validators/index.d.ts",
34 | "default": "./dist/validators/index.mjs"
35 | }
36 | },
37 | "./types": {
38 | "types": "./dist/interfaces.d.ts"
39 | }
40 | },
41 | "scripts": {
42 | "postbuild": "cp ../../README.md ../../LICENSE .",
43 | "build": "pkgroll --clean-dist --sourcemap",
44 | "dev": "pkgroll --watch",
45 | "test:watch": "vitest",
46 | "test": "vitest run",
47 | "type-check": "tsc --noEmit"
48 | },
49 | "files": [
50 | "dist"
51 | ],
52 | "peerDependencies": {
53 | "@types/react": ">=16",
54 | "@types/react-dom": ">=16",
55 | "react": ">=16"
56 | },
57 | "packageManager": "pnpm@10.11.0",
58 | "engines": {
59 | "node": ">=12"
60 | },
61 | "keywords": [
62 | "file",
63 | "fileselector",
64 | "file-selector",
65 | "file-picker",
66 | "filepicker",
67 | "file-input",
68 | "react-file",
69 | "react-file-picker",
70 | "react-file-selector"
71 | ],
72 | "devDependencies": {
73 | "@testing-library/dom": "^10.4.0",
74 | "@testing-library/jest-dom": "^6.6.3",
75 | "@testing-library/react": "^16.3.0",
76 | "@testing-library/user-event": "^14.6.1",
77 | "@vitejs/plugin-react": "^4.5.0",
78 | "happy-dom": "^17.4.7",
79 | "pkgroll": "^2.12.2",
80 | "prettier": "^3.5.3",
81 | "ts-xor": "^1.3.0",
82 | "typescript": "^5.8.3",
83 | "vitest": "^3.1.4"
84 | },
85 | "dependencies": {
86 | "file-selector": "^2.1.2"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/helpers/encodings.ts:
--------------------------------------------------------------------------------
1 | export const ENCODINGS = [
2 | {
3 | encodings: [
4 | {
5 | labels: ['unicode-1-1-utf-8', 'unicode11utf8', 'unicode20utf8', 'utf-8', 'utf8', 'x-unicode20utf8'],
6 | name: 'UTF-8',
7 | },
8 | ],
9 | heading: 'The Default Encoding',
10 | },
11 | {
12 | encodings: [
13 | {
14 | labels: ['866', 'cp866', 'csibm866', 'ibm866'],
15 | name: 'IBM866',
16 | },
17 | {
18 | labels: [
19 | 'csisolatin2',
20 | 'iso-8859-2',
21 | 'iso-ir-101',
22 | 'iso8859-2',
23 | 'iso88592',
24 | 'iso_8859-2',
25 | 'iso_8859-2:1987',
26 | 'l2',
27 | 'latin2',
28 | ],
29 | name: 'ISO-8859-2',
30 | },
31 | {
32 | labels: [
33 | 'csisolatin3',
34 | 'iso-8859-3',
35 | 'iso-ir-109',
36 | 'iso8859-3',
37 | 'iso88593',
38 | 'iso_8859-3',
39 | 'iso_8859-3:1988',
40 | 'l3',
41 | 'latin3',
42 | ],
43 | name: 'ISO-8859-3',
44 | },
45 | {
46 | labels: [
47 | 'csisolatin4',
48 | 'iso-8859-4',
49 | 'iso-ir-110',
50 | 'iso8859-4',
51 | 'iso88594',
52 | 'iso_8859-4',
53 | 'iso_8859-4:1988',
54 | 'l4',
55 | 'latin4',
56 | ],
57 | name: 'ISO-8859-4',
58 | },
59 | {
60 | labels: [
61 | 'csisolatincyrillic',
62 | 'cyrillic',
63 | 'iso-8859-5',
64 | 'iso-ir-144',
65 | 'iso8859-5',
66 | 'iso88595',
67 | 'iso_8859-5',
68 | 'iso_8859-5:1988',
69 | ],
70 | name: 'ISO-8859-5',
71 | },
72 | {
73 | labels: [
74 | 'arabic',
75 | 'asmo-708',
76 | 'csiso88596e',
77 | 'csiso88596i',
78 | 'csisolatinarabic',
79 | 'ecma-114',
80 | 'iso-8859-6',
81 | 'iso-8859-6-e',
82 | 'iso-8859-6-i',
83 | 'iso-ir-127',
84 | 'iso8859-6',
85 | 'iso88596',
86 | 'iso_8859-6',
87 | 'iso_8859-6:1987',
88 | ],
89 | name: 'ISO-8859-6',
90 | },
91 | {
92 | labels: [
93 | 'csisolatingreek',
94 | 'ecma-118',
95 | 'elot_928',
96 | 'greek',
97 | 'greek8',
98 | 'iso-8859-7',
99 | 'iso-ir-126',
100 | 'iso8859-7',
101 | 'iso88597',
102 | 'iso_8859-7',
103 | 'iso_8859-7:1987',
104 | 'sun_eu_greek',
105 | ],
106 | name: 'ISO-8859-7',
107 | },
108 | {
109 | labels: [
110 | 'csiso88598e',
111 | 'csisolatinhebrew',
112 | 'hebrew',
113 | 'iso-8859-8',
114 | 'iso-8859-8-e',
115 | 'iso-ir-138',
116 | 'iso8859-8',
117 | 'iso88598',
118 | 'iso_8859-8',
119 | 'iso_8859-8:1988',
120 | 'visual',
121 | ],
122 | name: 'ISO-8859-8',
123 | },
124 | {
125 | labels: ['csiso88598i', 'iso-8859-8-i', 'logical'],
126 | name: 'ISO-8859-8-I',
127 | },
128 | {
129 | labels: ['csisolatin6', 'iso-8859-10', 'iso-ir-157', 'iso8859-10', 'iso885910', 'l6', 'latin6'],
130 | name: 'ISO-8859-10',
131 | },
132 | {
133 | labels: ['iso-8859-13', 'iso8859-13', 'iso885913'],
134 | name: 'ISO-8859-13',
135 | },
136 | {
137 | labels: ['iso-8859-14', 'iso8859-14', 'iso885914'],
138 | name: 'ISO-8859-14',
139 | },
140 | {
141 | labels: ['csisolatin9', 'iso-8859-15', 'iso8859-15', 'iso885915', 'iso_8859-15', 'l9'],
142 | name: 'ISO-8859-15',
143 | },
144 | {
145 | labels: ['iso-8859-16'],
146 | name: 'ISO-8859-16',
147 | },
148 | {
149 | labels: ['cskoi8r', 'koi', 'koi8', 'koi8-r', 'koi8_r'],
150 | name: 'KOI8-R',
151 | },
152 | {
153 | labels: ['koi8-ru', 'koi8-u'],
154 | name: 'KOI8-U',
155 | },
156 | {
157 | labels: ['csmacintosh', 'mac', 'macintosh', 'x-mac-roman'],
158 | name: 'macintosh',
159 | },
160 | {
161 | labels: ['dos-874', 'iso-8859-11', 'iso8859-11', 'iso885911', 'tis-620', 'windows-874'],
162 | name: 'windows-874',
163 | },
164 | {
165 | labels: ['cp1250', 'windows-1250', 'x-cp1250'],
166 | name: 'windows-1250',
167 | },
168 | {
169 | labels: ['cp1251', 'windows-1251', 'x-cp1251'],
170 | name: 'windows-1251',
171 | },
172 | {
173 | labels: [
174 | 'ansi_x3.4-1968',
175 | 'ascii',
176 | 'cp1252',
177 | 'cp819',
178 | 'csisolatin1',
179 | 'ibm819',
180 | 'iso-8859-1',
181 | 'iso-ir-100',
182 | 'iso8859-1',
183 | 'iso88591',
184 | 'iso_8859-1',
185 | 'iso_8859-1:1987',
186 | 'l1',
187 | 'latin1',
188 | 'us-ascii',
189 | 'windows-1252',
190 | 'x-cp1252',
191 | ],
192 | name: 'windows-1252',
193 | },
194 | {
195 | labels: ['cp1253', 'windows-1253', 'x-cp1253'],
196 | name: 'windows-1253',
197 | },
198 | {
199 | labels: [
200 | 'cp1254',
201 | 'csisolatin5',
202 | 'iso-8859-9',
203 | 'iso-ir-148',
204 | 'iso8859-9',
205 | 'iso88599',
206 | 'iso_8859-9',
207 | 'iso_8859-9:1989',
208 | 'l5',
209 | 'latin5',
210 | 'windows-1254',
211 | 'x-cp1254',
212 | ],
213 | name: 'windows-1254',
214 | },
215 | {
216 | labels: ['cp1255', 'windows-1255', 'x-cp1255'],
217 | name: 'windows-1255',
218 | },
219 | {
220 | labels: ['cp1256', 'windows-1256', 'x-cp1256'],
221 | name: 'windows-1256',
222 | },
223 | {
224 | labels: ['cp1257', 'windows-1257', 'x-cp1257'],
225 | name: 'windows-1257',
226 | },
227 | {
228 | labels: ['cp1258', 'windows-1258', 'x-cp1258'],
229 | name: 'windows-1258',
230 | },
231 | {
232 | labels: ['x-mac-cyrillic', 'x-mac-ukrainian'],
233 | name: 'x-mac-cyrillic',
234 | },
235 | ],
236 | heading: 'Legacy single-byte encodings',
237 | },
238 | {
239 | encodings: [
240 | {
241 | labels: [
242 | 'chinese',
243 | 'csgb2312',
244 | 'csiso58gb231280',
245 | 'gb2312',
246 | 'gb_2312',
247 | 'gb_2312-80',
248 | 'gbk',
249 | 'iso-ir-58',
250 | 'x-gbk',
251 | ],
252 | name: 'GBK',
253 | },
254 | {
255 | labels: ['gb18030'],
256 | name: 'gb18030',
257 | },
258 | ],
259 | heading: 'Legacy multi-byte Chinese (simplified) encodings',
260 | },
261 | {
262 | encodings: [
263 | {
264 | labels: ['big5', 'big5-hkscs', 'cn-big5', 'csbig5', 'x-x-big5'],
265 | name: 'Big5',
266 | },
267 | ],
268 | heading: 'Legacy multi-byte Chinese (traditional) encodings',
269 | },
270 | {
271 | encodings: [
272 | {
273 | labels: ['cseucpkdfmtjapanese', 'euc-jp', 'x-euc-jp'],
274 | name: 'EUC-JP',
275 | },
276 | {
277 | labels: ['csiso2022jp', 'iso-2022-jp'],
278 | name: 'ISO-2022-JP',
279 | },
280 | {
281 | labels: ['csshiftjis', 'ms932', 'ms_kanji', 'shift-jis', 'shift_jis', 'sjis', 'windows-31j', 'x-sjis'],
282 | name: 'Shift_JIS',
283 | },
284 | ],
285 | heading: 'Legacy multi-byte Japanese encodings',
286 | },
287 | {
288 | encodings: [
289 | {
290 | labels: [
291 | 'cseuckr',
292 | 'csksc56011987',
293 | 'euc-kr',
294 | 'iso-ir-149',
295 | 'korean',
296 | 'ks_c_5601-1987',
297 | 'ks_c_5601-1989',
298 | 'ksc5601',
299 | 'ksc_5601',
300 | 'windows-949',
301 | ],
302 | name: 'EUC-KR',
303 | },
304 | ],
305 | heading: 'Legacy multi-byte Korean encodings',
306 | },
307 | {
308 | encodings: [
309 | {
310 | labels: ['csiso2022kr', 'hz-gb-2312', 'iso-2022-cn', 'iso-2022-cn-ext', 'iso-2022-kr', 'replacement'],
311 | name: 'replacement',
312 | },
313 | {
314 | labels: ['unicodefffe', 'utf-16be'],
315 | name: 'UTF-16BE',
316 | },
317 | {
318 | labels: ['csunicode', 'iso-10646-ucs-2', 'ucs-2', 'unicode', 'unicodefeff', 'utf-16', 'utf-16le'],
319 | name: 'UTF-16LE',
320 | },
321 | ],
322 | heading: 'Legacy miscellaneous encodings',
323 | },
324 | ] as const;
325 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/helpers/openFileDialog.ts:
--------------------------------------------------------------------------------
1 | export function openFileDialog(
2 | accept: string,
3 | multiple: boolean,
4 | callback: (arg: Event) => void,
5 | initializeWithCustomAttributes?: (arg: HTMLInputElement) => void
6 | ): void {
7 | // this function must be called from a user
8 | // activation event (ie an onclick event)
9 |
10 | // Create an input element
11 | const inputElement = document.createElement('input');
12 | // Hide element and append to body (required to run on iOS safari)
13 | inputElement.style.display = 'none';
14 | document.body.appendChild(inputElement);
15 | // Set its type to file
16 | inputElement.type = 'file';
17 | // Set accept to the file types you want the user to select.
18 | // Include both the file extension and the mime type
19 | // if accept is "*" then dont set the accept attribute
20 | if (accept !== '*') inputElement.accept = accept;
21 | // Accept multiple files
22 | inputElement.multiple = multiple;
23 | // set onchange event to call callback when user has selected file
24 | //inputElement.addEventListener('change', callback);
25 | inputElement.addEventListener('change', arg => {
26 | callback(arg);
27 | // remove element
28 | document.body.removeChild(inputElement);
29 | });
30 |
31 | inputElement.addEventListener('cancel', () => {
32 | // remove element
33 | document.body.removeChild(inputElement);
34 | });
35 |
36 | if (initializeWithCustomAttributes) {
37 | initializeWithCustomAttributes(inputElement);
38 | }
39 | // dispatch a click event to open the file dialog
40 | inputElement.dispatchEvent(new MouseEvent('click'));
41 | }
42 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useFilePicker } from './useFilePicker.js';
2 | export { default as useImperativeFilePicker } from './useImperativeFilePicker.js';
3 | export type { UseFilePickerConfig, FileWithPath } from './interfaces.js';
4 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | import { type FileWithPath as FileWithPathFromSelector } from 'file-selector';
2 | import { Validator } from './validators/validatorBase.js';
3 | import { type XOR } from 'ts-xor';
4 | import type { ENCODINGS } from './helpers/encodings.js';
5 |
6 | export type FileWithPath = FileWithPathFromSelector;
7 |
8 | // ========== ERRORS ==========
9 |
10 | type BaseErrors = FileSizeError | FileReaderError | FileAmountLimitError | ImageDimensionError | FileTypeError;
11 |
12 | export type UseFilePickerError = CustomErrors extends object
13 | ? BaseErrors | CustomErrors
14 | : BaseErrors;
15 |
16 | export interface FileReaderError {
17 | name: 'FileReaderError';
18 | causedByFile: FileWithPath;
19 | readerError?: DOMException | null;
20 | }
21 |
22 | export interface FileAmountLimitError {
23 | name: 'FileAmountLimitError';
24 | reason: 'MIN_AMOUNT_OF_FILES_NOT_REACHED' | 'MAX_AMOUNT_OF_FILES_EXCEEDED';
25 | }
26 |
27 | export interface FileSizeError {
28 | name: 'FileSizeError';
29 | causedByFile: FileWithPath;
30 | reason: 'FILE_SIZE_TOO_LARGE' | 'FILE_SIZE_TOO_SMALL';
31 | }
32 |
33 | export interface ImageDimensionError {
34 | name: 'ImageDimensionError';
35 | causedByFile: FileWithPath;
36 | reasons: (
37 | | 'IMAGE_WIDTH_TOO_BIG'
38 | | 'IMAGE_WIDTH_TOO_SMALL'
39 | | 'IMAGE_HEIGHT_TOO_BIG'
40 | | 'IMAGE_HEIGHT_TOO_SMALL'
41 | | 'IMAGE_NOT_LOADED'
42 | )[];
43 | }
44 |
45 | export interface FileTypeError {
46 | name: 'FileTypeError';
47 | causedByFile: FileWithPath;
48 | reason: 'FILE_TYPE_NOT_ACCEPTED';
49 | }
50 |
51 | export type FileErrors = {
52 | errors: UseFilePickerError[];
53 | };
54 |
55 | // ========== VALIDATOR CONFIGS ==========
56 |
57 | export interface ImageDimensionRestrictionsConfig {
58 | minWidth?: number;
59 | maxWidth?: number;
60 | minHeight?: number;
61 | maxHeight?: number;
62 | }
63 |
64 | export interface FileSizeRestrictions {
65 | /**Minimum file size in bytes*/
66 | minFileSize?: number;
67 | /**Maximum file size in bytes*/
68 | maxFileSize?: number;
69 | }
70 |
71 | export interface FileAmountLimitConfig {
72 | min?: number;
73 | max?: number;
74 | }
75 |
76 | // ========== MAIN TYPES ==========
77 |
78 | export type ReadType = 'Text' | 'BinaryString' | 'ArrayBuffer' | 'DataURL';
79 |
80 | export type ReaderMethod = keyof FileReader;
81 |
82 | export type SelectedFiles = {
83 | plainFiles: File[];
84 | filesContent: FileContent[];
85 | };
86 |
87 | export type SelectedFilesOrErrors = XOR<
88 | SelectedFiles,
89 | FileErrors
90 | >;
91 |
92 | type KnownEncoding = (typeof ENCODINGS)[number]['encodings'][number]['labels'][number];
93 |
94 | /**
95 | * Type that represents text encodings supported by the system.
96 | *
97 | * The encoding standards are organized into the following categories:
98 | *
99 | * - **The Default Encoding by W3C File API specification**: UTF-8
100 | * - **Legacy single-byte encodings**: IBM866, ISO-8859-2 through ISO-8859-16, KOI8-R, KOI8-U, macintosh, windows-874 through windows-1258, x-mac-cyrillic
101 | * - **Legacy multi-byte Chinese (simplified) encodings**: GBK, gb18030
102 | * - **Legacy multi-byte Chinese (traditional) encodings**: Big5
103 | * - **Legacy multi-byte Japanese encodings**: EUC-JP, ISO-2022-JP, Shift_JIS
104 | * - **Legacy multi-byte Korean encodings**: EUC-KR
105 | * - **Legacy miscellaneous encodings**: replacement, UTF-16BE, UTF-16LE
106 | */
107 | export type Encoding = KnownEncoding | (string & {}); // this is a TS hack to allow any string to be used as an encoding, apart from the known encodings
108 |
109 | type UseFilePickerConfigCommon = {
110 | multiple?: boolean;
111 | accept?: string | string[];
112 | validators?: Validator[];
113 | onFilesRejected?: (data: FileErrors) => void;
114 | onClear?: () => void;
115 | initializeWithCustomParameters?: (inputElement: HTMLInputElement) => void;
116 | };
117 | type ReadFileContentConfig =
118 | | ({
119 | readFilesContent?: true | undefined | never;
120 | } & (
121 | | {
122 | readAs?: 'ArrayBuffer';
123 | onFilesSelected?: (data: SelectedFilesOrErrors) => void;
124 | onFilesSuccessfullySelected?: (data: SelectedFiles) => void;
125 | }
126 | | {
127 | readAs?: 'Text';
128 | encoding?: Encoding;
129 | onFilesSelected?: (data: SelectedFilesOrErrors) => void;
130 | onFilesSuccessfullySelected?: (data: SelectedFiles) => void;
131 | }
132 | | {
133 | readAs?: Exclude;
134 | onFilesSelected?: (data: SelectedFilesOrErrors) => void;
135 | onFilesSuccessfullySelected?: (data: SelectedFiles) => void;
136 | }
137 | ))
138 | | {
139 | readFilesContent: false;
140 | readAs?: never;
141 | onFilesSelected?: (data: SelectedFilesOrErrors) => void;
142 | onFilesSuccessfullySelected?: (data: SelectedFiles) => void;
143 | };
144 |
145 | export type ExtractContentTypeFromConfig = Config extends { readAs: 'ArrayBuffer' } ? ArrayBuffer : string;
146 |
147 | export type UseFilePickerConfig = UseFilePickerConfigCommon &
148 | ReadFileContentConfig;
149 |
150 | export type useImperativeFilePickerConfig = UseFilePickerConfig & {
151 | onFileRemoved?: (file: FileWithPath, removedIndex: number) => void | Promise;
152 | };
153 |
154 | export interface FileContent extends Blob {
155 | lastModified: number;
156 | name: string;
157 | content: ContentType;
158 | path: string;
159 | size: number;
160 | type: string;
161 | }
162 |
163 | export type FilePickerReturnTypes = {
164 | openFilePicker: () => void;
165 | filesContent: FileContent[];
166 | errors: UseFilePickerError[];
167 | loading: boolean;
168 | plainFiles: File[];
169 | clear: () => void;
170 | };
171 |
172 | export type ImperativeFilePickerReturnTypes = FilePickerReturnTypes<
173 | ContentType,
174 | CustomErrors
175 | > & {
176 | removeFileByIndex: (index: number) => void;
177 | removeFileByReference: (file: File) => void;
178 | };
179 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/types.ts:
--------------------------------------------------------------------------------
1 | export * from './interfaces.js';
2 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/useFilePicker.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { fromEvent, type FileWithPath } from 'file-selector';
3 | import type {
4 | UseFilePickerConfig,
5 | FileContent,
6 | FilePickerReturnTypes,
7 | UseFilePickerError,
8 | ReaderMethod,
9 | ExtractContentTypeFromConfig,
10 | } from './interfaces.js';
11 | import { openFileDialog } from './helpers/openFileDialog.js';
12 | import { useValidators } from './validators/useValidators.js';
13 | import { Validator } from './validators.js';
14 |
15 | // empty array reference in order to avoid re-renders when no validators are passed as props
16 | const EMPTY_ARRAY: Validator[] = [];
17 |
18 | function useFilePicker<
19 | CustomErrors = unknown,
20 | ConfigType extends UseFilePickerConfig = UseFilePickerConfig,
21 | >(props: ConfigType = {} as ConfigType): FilePickerReturnTypes, CustomErrors> {
22 | const {
23 | accept = '*',
24 | multiple = true,
25 | readAs = 'Text',
26 | readFilesContent = true,
27 | validators = EMPTY_ARRAY,
28 | initializeWithCustomParameters,
29 | } = props;
30 |
31 | const [plainFiles, setPlainFiles] = useState([]);
32 | const [filesContent, setFilesContent] = useState>[]>([]);
33 | const [fileErrors, setFileErrors] = useState[]>([]);
34 | const [loading, setLoading] = useState(false);
35 | const { onFilesSelected, onFilesSuccessfullySelected, onFilesRejected, onClear } = useValidators<
36 | ConfigType,
37 | CustomErrors
38 | >(props);
39 |
40 | const clear: () => void = useCallback(() => {
41 | setPlainFiles([]);
42 | setFilesContent([]);
43 | setFileErrors([]);
44 | }, []);
45 |
46 | const clearWithEventListener: () => void = useCallback(() => {
47 | clear();
48 | onClear?.();
49 | }, [clear, onClear]);
50 |
51 | const parseFile = useCallback(
52 | (file: FileWithPath) =>
53 | new Promise>>(
54 | (
55 | resolve: (fileContent: FileContent>) => void,
56 | reject: (reason: UseFilePickerError) => void
57 | ) => {
58 | const reader = new FileReader();
59 |
60 | //availible reader methods: readAsText, readAsBinaryString, readAsArrayBuffer, readAsDataURL
61 | const readStrategy = reader[`readAs${readAs}` as ReaderMethod] as typeof reader.readAsText;
62 | readStrategy.call(reader, file, props.readAs === 'Text' ? props.encoding : undefined);
63 |
64 | const addError = ({ ...others }: UseFilePickerError) => {
65 | reject({ ...others });
66 | };
67 |
68 | reader.onload = async () =>
69 | Promise.all(
70 | validators.map(validator =>
71 | validator
72 | .validateAfterParsing(props, file, reader)
73 | .catch((err: UseFilePickerError) => Promise.reject(addError(err)))
74 | )
75 | )
76 | .then(() =>
77 | resolve({
78 | content: reader.result as string,
79 | name: file.name,
80 | lastModified: file.lastModified,
81 | path: file.path,
82 | size: file.size,
83 | type: file.type,
84 | } as FileContent>)
85 | )
86 | .catch(() => {});
87 |
88 | reader.onerror = () => {
89 | addError({ name: 'FileReaderError', readerError: reader.error, causedByFile: file });
90 | };
91 | }
92 | ),
93 | [props, readAs, validators]
94 | );
95 |
96 | const openFilePicker = useCallback(() => {
97 | const fileExtensions = accept instanceof Array ? accept.join(',') : accept;
98 | openFileDialog(
99 | fileExtensions,
100 | multiple,
101 | async evt => {
102 | clear();
103 | setLoading(true);
104 | const plainFileObjects = (await fromEvent(evt)) as FileWithPath[];
105 |
106 | const validationsBeforeParsing = (
107 | await Promise.all(
108 | validators.map(validator =>
109 | validator
110 | .validateBeforeParsing(props, plainFileObjects)
111 | .catch((err: UseFilePickerError | UseFilePickerError[]) => (Array.isArray(err) ? err : [err]))
112 | )
113 | )
114 | )
115 | .flat(1)
116 | .filter(Boolean) as UseFilePickerError[];
117 |
118 | setPlainFiles(plainFileObjects);
119 | setFileErrors(validationsBeforeParsing);
120 | if (validationsBeforeParsing.length) {
121 | setPlainFiles([]);
122 | onFilesRejected?.({ errors: validationsBeforeParsing });
123 | onFilesSelected?.({ errors: validationsBeforeParsing });
124 | setLoading(false);
125 | return;
126 | }
127 |
128 | if (!readFilesContent) {
129 | onFilesSelected?.({ plainFiles: plainFileObjects, filesContent: [] });
130 | setLoading(false);
131 | return;
132 | }
133 |
134 | const validationsAfterParsing: UseFilePickerError[] = [];
135 | const filesContent = (await Promise.all(
136 | plainFileObjects.map(file =>
137 | parseFile(file).catch(
138 | (fileError: UseFilePickerError | UseFilePickerError[]) => {
139 | validationsAfterParsing.push(...(Array.isArray(fileError) ? fileError : [fileError]));
140 | }
141 | )
142 | )
143 | )) as FileContent>[];
144 | setLoading(false);
145 |
146 | if (validationsAfterParsing.length) {
147 | setPlainFiles([]);
148 | setFilesContent([]);
149 | setFileErrors(errors => [...errors, ...validationsAfterParsing]);
150 | onFilesRejected?.({ errors: validationsAfterParsing });
151 | onFilesSelected?.({
152 | errors: validationsBeforeParsing.concat(validationsAfterParsing),
153 | });
154 | return;
155 | }
156 |
157 | setFilesContent(filesContent);
158 | setPlainFiles(plainFileObjects);
159 | setFileErrors([]);
160 | onFilesSuccessfullySelected?.({ filesContent: filesContent, plainFiles: plainFileObjects });
161 | onFilesSelected?.({
162 | plainFiles: plainFileObjects,
163 | filesContent: filesContent,
164 | });
165 | },
166 | initializeWithCustomParameters
167 | );
168 | }, [
169 | props,
170 | accept,
171 | clear,
172 | initializeWithCustomParameters,
173 | multiple,
174 | onFilesRejected,
175 | onFilesSelected,
176 | onFilesSuccessfullySelected,
177 | parseFile,
178 | readFilesContent,
179 | validators,
180 | ]);
181 |
182 | return {
183 | openFilePicker,
184 | filesContent,
185 | errors: fileErrors,
186 | loading,
187 | plainFiles,
188 | clear: clearWithEventListener,
189 | };
190 | }
191 |
192 | export default useFilePicker;
193 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/useImperativeFilePicker.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { useCallback, useState } from 'react';
3 | import type {
4 | ExtractContentTypeFromConfig,
5 | FileContent,
6 | ImperativeFilePickerReturnTypes,
7 | SelectedFiles,
8 | SelectedFilesOrErrors,
9 | useImperativeFilePickerConfig,
10 | } from './interfaces.js';
11 | import useFilePicker from './useFilePicker.js';
12 |
13 | /**
14 | * A version of useFilePicker hook that keeps selected files between selections. On top of that it allows to remove files from the selection.
15 | */
16 | function useImperativeFilePicker<
17 | CustomErrors = unknown,
18 | ConfigType extends useImperativeFilePickerConfig = useImperativeFilePickerConfig,
19 | >(props: ConfigType): ImperativeFilePickerReturnTypes, CustomErrors> {
20 | const { onFilesSelected, onFilesSuccessfullySelected, validators, onFileRemoved } = props;
21 |
22 | const [allPlainFiles, setAllPlainFiles] = useState([]);
23 | const [allFilesContent, setAllFilesContent] = useState>[]>([]);
24 |
25 | const { openFilePicker, loading, errors, clear } = useFilePicker({
26 | ...props,
27 | onFilesSelected: (data: SelectedFilesOrErrors, CustomErrors>) => {
28 | if (!onFilesSelected) return;
29 | if (data.errors?.length) {
30 | return onFilesSelected(data);
31 | }
32 | // override the files property to return all files that were selected previously and in the current batch
33 | onFilesSelected({
34 | errors: undefined,
35 | plainFiles: [...allPlainFiles, ...(data.plainFiles || [])],
36 | filesContent: [...allFilesContent, ...(data.filesContent || [])] as any,
37 | });
38 | },
39 | onFilesSuccessfullySelected: (data: SelectedFiles) => {
40 | setAllPlainFiles(previousPlainFiles => previousPlainFiles.concat(data.plainFiles));
41 | setAllFilesContent(previousFilesContent => previousFilesContent.concat(data.filesContent));
42 |
43 | if (!onFilesSuccessfullySelected) return;
44 | // override the files property to return all files that were selected previously and in the current batch
45 | onFilesSuccessfullySelected({
46 | plainFiles: [...allPlainFiles, ...(data.plainFiles || [])],
47 | filesContent: [...allFilesContent, ...(data.filesContent || [])],
48 | });
49 | },
50 | });
51 |
52 | const clearAll = useCallback(() => {
53 | clear();
54 | // clear previous files
55 | setAllPlainFiles([]);
56 | setAllFilesContent([]);
57 | }, [clear]);
58 |
59 | const removeFileByIndex = useCallback(
60 | (index: number) => {
61 | setAllFilesContent(previousFilesContent => [
62 | ...previousFilesContent.slice(0, index),
63 | ...previousFilesContent.slice(index + 1),
64 | ]);
65 | setAllPlainFiles(previousPlainFiles => {
66 | const removedFile = previousPlainFiles[index];
67 | if (!removedFile) {
68 | return previousPlainFiles;
69 | }
70 | validators?.forEach(validator => validator.onFileRemoved?.(removedFile, index));
71 | onFileRemoved?.(removedFile, index);
72 | return [...previousPlainFiles.slice(0, index), ...previousPlainFiles.slice(index + 1)];
73 | });
74 | },
75 | [validators, onFileRemoved]
76 | );
77 |
78 | const removeFileByReference = useCallback(
79 | (file: File) => {
80 | const index = allPlainFiles.findIndex(f => f === file);
81 | if (index === -1) return;
82 | removeFileByIndex(index);
83 | },
84 | [removeFileByIndex, allPlainFiles]
85 | );
86 |
87 | return {
88 | openFilePicker,
89 | plainFiles: allPlainFiles,
90 | filesContent: allFilesContent,
91 | loading,
92 | errors,
93 | clear: clearAll,
94 | removeFileByIndex,
95 | removeFileByReference,
96 | };
97 | }
98 |
99 | export default useImperativeFilePicker;
100 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators.ts:
--------------------------------------------------------------------------------
1 | export * from './validators/index.js';
2 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/FileTypeValidator/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unused-vars */
2 | import type { FileWithPath, UseFilePickerConfig } from '../../interfaces.js';
3 | import { Validator } from '../validatorBase.js';
4 |
5 | export default class FileTypeValidator extends Validator {
6 | constructor(private readonly acceptedFileExtensions: string[]) {
7 | super();
8 | }
9 |
10 | validateBeforeParsing(_config: UseFilePickerConfig, plainFiles: File[]): Promise {
11 | const fileExtensionErrors = plainFiles.reduce<{ name: string; reason: string; causedByFile: File }[]>(
12 | (errors, currentFile) => {
13 | const fileExtension = currentFile.name.split('.').pop();
14 | if (!fileExtension) {
15 | return [
16 | ...errors,
17 | {
18 | name: 'FileTypeError',
19 | reason: 'FILE_EXTENSION_NOT_FOUND',
20 | causedByFile: currentFile,
21 | },
22 | ];
23 | }
24 | if (!this.acceptedFileExtensions.includes(fileExtension)) {
25 | return [
26 | ...errors,
27 | {
28 | name: 'FileTypeError',
29 | reason: 'FILE_TYPE_NOT_ACCEPTED',
30 | causedByFile: currentFile,
31 | },
32 | ];
33 | }
34 |
35 | return errors;
36 | },
37 | []
38 | );
39 |
40 | return fileExtensionErrors.length > 0 ? Promise.reject(fileExtensionErrors) : Promise.resolve();
41 | }
42 |
43 | validateAfterParsing(_config: UseFilePickerConfig, _file: FileWithPath, _reader: FileReader): Promise {
44 | return Promise.resolve();
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/fileAmountLimitValidator/index.ts:
--------------------------------------------------------------------------------
1 | import type { FileAmountLimitError, FileAmountLimitConfig, UseFilePickerConfig } from '../../interfaces.js';
2 | import { Validator } from '../validatorBase.js';
3 |
4 | export default class FileAmountLimitValidator extends Validator {
5 | constructor(private limitAmountOfFilesConfig: FileAmountLimitConfig) {
6 | super();
7 | }
8 |
9 | validateBeforeParsing(_config: UseFilePickerConfig, plainFiles: File[]): Promise {
10 | const { min, max } = this.limitAmountOfFilesConfig;
11 | if (max && plainFiles.length > max) {
12 | return Promise.reject({
13 | name: 'FileAmountLimitError',
14 | reason: 'MAX_AMOUNT_OF_FILES_EXCEEDED',
15 | } as FileAmountLimitError);
16 | }
17 |
18 | if (min && plainFiles.length < min) {
19 | return Promise.reject({
20 | name: 'FileAmountLimitError',
21 | reason: 'MIN_AMOUNT_OF_FILES_NOT_REACHED',
22 | } as FileAmountLimitError);
23 | }
24 | return Promise.resolve();
25 | }
26 | validateAfterParsing(): Promise {
27 | return Promise.resolve();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/fileSizeValidator/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unused-vars */
2 | import { type FileWithPath } from 'file-selector';
3 | import type { FileSizeError, FileSizeRestrictions, UseFilePickerConfig } from '../../interfaces.js';
4 | import { Validator } from '../validatorBase.js';
5 |
6 | export default class FileSizeValidator extends Validator {
7 | constructor(private fileSizeRestrictions: FileSizeRestrictions) {
8 | super();
9 | }
10 |
11 | async validateBeforeParsing(_config: UseFilePickerConfig, plainFiles: File[]): Promise {
12 | const { minFileSize, maxFileSize } = this.fileSizeRestrictions;
13 |
14 | if (!minFileSize && !maxFileSize) {
15 | return Promise.resolve();
16 | }
17 |
18 | const errors = plainFiles
19 | .map(file => getFileSizeError({ minFileSize, maxFileSize, file }))
20 | .filter(error => !!error) as FileSizeError[];
21 |
22 | return errors.length > 0 ? Promise.reject(errors) : Promise.resolve();
23 | }
24 | async validateAfterParsing(_config: UseFilePickerConfig, _file: FileWithPath): Promise {
25 | return Promise.resolve();
26 | }
27 | }
28 |
29 | const getFileSizeError = ({
30 | file,
31 | maxFileSize,
32 | minFileSize,
33 | }: {
34 | minFileSize: number | undefined;
35 | maxFileSize: number | undefined;
36 | file: FileWithPath;
37 | }): FileSizeError | undefined => {
38 | if (minFileSize) {
39 | const minBytes = minFileSize;
40 | if (file.size < minBytes) {
41 | return { name: 'FileSizeError', reason: 'FILE_SIZE_TOO_SMALL', causedByFile: file };
42 | }
43 | }
44 | if (maxFileSize) {
45 | const maxBytes = maxFileSize;
46 | if (file.size > maxBytes) {
47 | return { name: 'FileSizeError', reason: 'FILE_SIZE_TOO_LARGE', causedByFile: file };
48 | }
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/imageDimensionsValidator/index.ts:
--------------------------------------------------------------------------------
1 | import { type FileWithPath } from 'file-selector';
2 | import type { ImageDimensionError, ImageDimensionRestrictionsConfig, UseFilePickerConfig } from '../../interfaces.js';
3 | import { Validator } from '../validatorBase.js';
4 |
5 | export default class ImageDimensionsValidator extends Validator {
6 | constructor(private imageSizeRestrictions: ImageDimensionRestrictionsConfig) {
7 | super();
8 | }
9 |
10 | validateBeforeParsing(): Promise {
11 | return Promise.resolve();
12 | }
13 | validateAfterParsing(config: UseFilePickerConfig, file: FileWithPath, reader: FileReader): Promise {
14 | const { readAs } = config;
15 | if (readAs === 'DataURL' && this.imageSizeRestrictions && isImage(file.type)) {
16 | return checkImageDimensions(file, reader.result as string, this.imageSizeRestrictions);
17 | }
18 | return Promise.resolve();
19 | }
20 | }
21 |
22 | const isImage = (fileType: string) => fileType.startsWith('image');
23 |
24 | const checkImageDimensions = (
25 | file: FileWithPath,
26 | imgDataURL: string,
27 | imageSizeRestrictions: ImageDimensionRestrictionsConfig
28 | ) =>
29 | new Promise((resolve, reject) => {
30 | const img = new Image();
31 | const error: ImageDimensionError = {
32 | name: 'ImageDimensionError',
33 | causedByFile: file,
34 | reasons: [],
35 | };
36 | img.onload = function () {
37 | const { maxHeight, maxWidth, minHeight, minWidth } = imageSizeRestrictions;
38 | const { width, height } = this as unknown as typeof img;
39 |
40 | if (maxHeight && maxHeight < height) error.reasons.push('IMAGE_HEIGHT_TOO_BIG');
41 | if (minHeight && minHeight > height) error.reasons.push('IMAGE_HEIGHT_TOO_SMALL');
42 | if (maxWidth && maxWidth < width) error.reasons.push('IMAGE_WIDTH_TOO_BIG');
43 | if (minWidth && minWidth > width) error.reasons.push('IMAGE_WIDTH_TOO_SMALL');
44 |
45 | if (error.reasons.length) {
46 | reject(error);
47 | } else {
48 | resolve();
49 | }
50 | };
51 | img.onerror = function () {
52 | error.reasons.push('IMAGE_NOT_LOADED');
53 | reject(error);
54 | };
55 | img.src = imgDataURL;
56 | });
57 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/index.ts:
--------------------------------------------------------------------------------
1 | export { Validator } from './validatorBase.js';
2 | export { default as FileAmountLimitValidator } from './fileAmountLimitValidator/index.js';
3 | export { default as FileSizeValidator } from './fileSizeValidator/index.js';
4 | export { default as ImageDimensionsValidator } from './imageDimensionsValidator/index.js';
5 | export { default as PersistentFileAmountLimitValidator } from './persistentFileAmountLimitValidator/index.js';
6 | export { default as FileTypeValidator } from './FileTypeValidator/index.js';
7 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/persistentFileAmountLimitValidator/index.ts:
--------------------------------------------------------------------------------
1 | import type { FileAmountLimitConfig, FileAmountLimitError, UseFilePickerConfig } from '../../interfaces.js';
2 | import { Validator } from '../validatorBase.js';
3 |
4 | class PersistentFileAmountLimitValidator extends Validator {
5 | private previousPlainFiles: File[] = [];
6 |
7 | constructor(private limitFilesConfig: FileAmountLimitConfig) {
8 | super();
9 | }
10 |
11 | override onClear(): void {
12 | this.previousPlainFiles = [];
13 | }
14 |
15 | override onFileRemoved(_removedFile: File, removedIndex: number): void {
16 | this.previousPlainFiles.splice(removedIndex, 1);
17 | }
18 |
19 | validateBeforeParsing(_config: UseFilePickerConfig, plainFiles: File[]): Promise {
20 | const fileAmount = this.previousPlainFiles.length + plainFiles.length;
21 | const { min, max } = this.limitFilesConfig;
22 | if (max && fileAmount > max) {
23 | return Promise.reject({
24 | name: 'FileAmountLimitError',
25 | reason: 'MAX_AMOUNT_OF_FILES_EXCEEDED',
26 | } as FileAmountLimitError);
27 | }
28 |
29 | if (min && fileAmount < min) {
30 | return Promise.reject({
31 | name: 'FileAmountLimitError',
32 | reason: 'MIN_AMOUNT_OF_FILES_NOT_REACHED',
33 | } as FileAmountLimitError);
34 | }
35 |
36 | this.previousPlainFiles = [...this.previousPlainFiles, ...plainFiles];
37 |
38 | return Promise.resolve();
39 | }
40 |
41 | validateAfterParsing(): Promise {
42 | return Promise.resolve();
43 | }
44 | }
45 |
46 | export default PersistentFileAmountLimitValidator;
47 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/useValidators.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { useCallback } from 'react';
3 | import type {
4 | SelectedFilesOrErrors,
5 | ExtractContentTypeFromConfig,
6 | UseFilePickerConfig,
7 | SelectedFiles,
8 | FileErrors,
9 | } from '../interfaces.js';
10 |
11 | export const useValidators = , CustomErrors>({
12 | onFilesSelected: onFilesSelectedProp,
13 | onFilesSuccessfullySelected: onFilesSuccessfullySelectedProp,
14 | onFilesRejected: onFilesRejectedProp,
15 | onClear: onClearProp,
16 | validators,
17 | }: ConfigType) => {
18 | // setup validators' event handlers
19 | const onFilesSelected = useCallback(
20 | (data: SelectedFilesOrErrors, CustomErrors>) => {
21 | onFilesSelectedProp?.(data as any);
22 | validators?.forEach(validator => {
23 | validator.onFilesSelected(data as any);
24 | });
25 | },
26 | [onFilesSelectedProp, validators]
27 | );
28 | const onFilesSuccessfullySelected = useCallback(
29 | (data: SelectedFiles>) => {
30 | onFilesSuccessfullySelectedProp?.(data as any);
31 | validators?.forEach(validator => {
32 | validator.onFilesSuccessfullySelected(data as any);
33 | });
34 | },
35 | [validators, onFilesSuccessfullySelectedProp]
36 | );
37 | const onFilesRejected = useCallback(
38 | (errors: FileErrors) => {
39 | onFilesRejectedProp?.(errors);
40 | validators?.forEach(validator => {
41 | validator.onFilesRejected(errors);
42 | });
43 | },
44 | [validators, onFilesRejectedProp]
45 | );
46 | const onClear = useCallback(() => {
47 | onClearProp?.();
48 | validators?.forEach(validator => {
49 | validator.onClear?.();
50 | });
51 | }, [validators, onClearProp]);
52 |
53 | return {
54 | onFilesSelected,
55 | onFilesSuccessfullySelected,
56 | onFilesRejected,
57 | onClear,
58 | };
59 | };
60 |
--------------------------------------------------------------------------------
/packages/use-file-picker/src/validators/validatorBase.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | /* eslint-disable @typescript-eslint/no-unused-vars */
3 | import { type FileWithPath } from 'file-selector';
4 | import type {
5 | ExtractContentTypeFromConfig,
6 | FileErrors,
7 | SelectedFiles,
8 | SelectedFilesOrErrors,
9 | UseFilePickerConfig,
10 | } from '../interfaces.js';
11 |
12 | export abstract class Validator<
13 | CustomErrors = unknown,
14 | ConfigType extends UseFilePickerConfig = UseFilePickerConfig,
15 | > {
16 | protected invokerHookId: string | undefined;
17 |
18 | /**
19 | * This method is called before parsing the selected files. It is called once per selection.
20 | * @param config passed to the useFilePicker hook
21 | * @param plainFiles files selected by the user
22 | */
23 | abstract validateBeforeParsing(config: ConfigType, plainFiles: File[]): Promise;
24 | /**
25 | * This method is called after parsing the selected files. It is called once per every parsed file.
26 | * @param config passed to the useFilePicker hook
27 | * @param file parsed file selected by the user
28 | * @param reader instance that was used to parse the file
29 | */
30 | abstract validateAfterParsing(config: ConfigType, file: FileWithPath, reader: FileReader): Promise;
31 |
32 | /**
33 | * lifecycle method called after user selection (regardless of validation result)
34 | */
35 | onFilesSelected(
36 | _data: SelectedFilesOrErrors, CustomErrors>
37 | ): Promise | void {}
38 | /**
39 | * lifecycle method called after successful validation
40 | */
41 | onFilesSuccessfullySelected(_data: SelectedFiles>): Promise | void {}
42 | /**
43 | * lifecycle method called after failed validation
44 | */
45 | onFilesRejected(_data: FileErrors): Promise | void {}
46 | /**
47 | * lifecycle method called after the selection is cleared
48 | */
49 | onClear(): Promise | void {}
50 |
51 | /**
52 | * This method is called when file is removed from the list of selected files.
53 | * Invoked only by the useImperativeFilePicker hook
54 | * @param _removedFile removed file
55 | * @param _removedIndex index of removed file
56 | */
57 | onFileRemoved(_removedFile: File, _removedIndex: number): Promise | void {}
58 | }
59 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/AmountOfFilesValidator.test.tsx:
--------------------------------------------------------------------------------
1 | import { userEvent } from '@testing-library/user-event';
2 | import { waitFor } from '@testing-library/react';
3 | import { invokeUseFilePicker } from './testUtils.js';
4 | import { FileAmountLimitValidator } from '../src/validators.js';
5 | import { describe, expect, it } from 'vitest';
6 |
7 | describe('AmountOfFilesRestrictions', () => {
8 | it('should not allow to put more files than maximum specified', async () => {
9 | const validators = [new FileAmountLimitValidator({ max: 3 })];
10 | const { input, result } = invokeUseFilePicker({ validators });
11 |
12 | const files = [new File([''], 'file1'), new File([''], 'file2'), new File([''], 'file3'), new File([''], 'file4')];
13 | await userEvent.upload(input.current!, files);
14 |
15 | await waitFor(() => result.current.loading === false);
16 | if (result.current.errors[0]?.name === 'FileAmountLimitError') {
17 | expect(result.current.errors[0]?.reason === 'MAX_AMOUNT_OF_FILES_EXCEEDED').toBe(true);
18 | } else {
19 | throw new Error('Expected FileAmountLimitError');
20 | }
21 | });
22 |
23 | it('should allow to put less files than maximum specified', async () => {
24 | const validators = [new FileAmountLimitValidator({ max: 3 })];
25 | const { input, result } = invokeUseFilePicker({ validators });
26 |
27 | const files = [new File([''], 'file1'), new File([''], 'file2')];
28 | await userEvent.upload(input.current!, files);
29 |
30 | await waitFor(() => result.current.loading === false);
31 | expect(result.current.errors.length).toBe(0);
32 | expect(result.current.plainFiles.length).toBe(2);
33 | });
34 |
35 | it('should allow to put more files than minimum specified', async () => {
36 | const validators = [new FileAmountLimitValidator({ min: 3 })];
37 | const { input, result } = invokeUseFilePicker({ validators });
38 |
39 | const files = [new File([''], 'file1'), new File([''], 'file2'), new File([''], 'file3'), new File([''], 'file4')];
40 | await userEvent.upload(input.current!, files);
41 |
42 | await waitFor(() => result.current.loading === false);
43 | expect(result.current.plainFiles.length).toBe(4);
44 | });
45 |
46 | it('should not allow to put less files than minimum specified', async () => {
47 | const validators = [new FileAmountLimitValidator({ min: 3 })];
48 | const { input, result } = invokeUseFilePicker({ validators });
49 |
50 | const files = [new File([''], 'file1'), new File([''], 'file2')];
51 | await userEvent.upload(input.current!, files);
52 |
53 | await waitFor(() => result.current.loading === false);
54 | if (result.current.errors[0]?.name === 'FileAmountLimitError') {
55 | expect(result.current.errors[0]?.reason === 'MIN_AMOUNT_OF_FILES_NOT_REACHED').toBe(true);
56 | } else {
57 | throw new Error('Expected FileAmountLimitError');
58 | }
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/FilePicker.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { userEvent } from '@testing-library/user-event';
3 | import { render, waitFor } from '@testing-library/react';
4 | import { invokeUseFilePicker } from './testUtils.js';
5 | import { describe, expect, it } from 'vitest';
6 | import { FilePickerComponent } from './FilePickerTestComponents.jsx';
7 |
8 | describe('DefaultPicker', () => {
9 | it('renders without crashing', async () => {
10 | const { findByRole } = render( );
11 | const button = await findByRole('button');
12 | expect(button).toBeInTheDocument();
13 | });
14 | });
15 |
16 | describe('DefaultPicker', () => {
17 | it('should upload files correctly', async () => {
18 | const user = userEvent.setup();
19 | const { input, result } = invokeUseFilePicker({});
20 |
21 | const files = [
22 | new File(['hello'], 'hello.png', { type: 'image/png' }),
23 | new File(['there'], 'there.png', { type: 'image/png' }),
24 | ];
25 | await user.upload(input.current!, files);
26 |
27 | await waitFor(() => result.current.loading === false);
28 |
29 | expect(result.current.plainFiles.length).toBe(2);
30 | expect(input.current!.files).toHaveLength(2);
31 | expect(input.current!.files?.[0]).toStrictEqual(files[0]);
32 | expect(input.current!.files?.[1]).toStrictEqual(files[1]);
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/FilePickerTestComponents.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useFilePicker, useImperativeFilePicker } from '../src/index.js';
3 | import { type FileContent, type ReadType, type UseFilePickerConfig } from '../src/types.js';
4 |
5 | const renderDependingOnReaderType = (file: FileContent, readAs: ReadType) => {
6 | switch (readAs) {
7 | case 'DataURL':
8 | return (
9 |
10 | success!
11 | File name: {file.name}
12 |
13 | Image:
14 |
15 |
16 |
17 | );
18 | default:
19 | return (
20 |
21 | success!
22 | File name: {file.name}
23 |
24 | content:
25 |
26 | {file.content}
27 |
28 | );
29 | }
30 | };
31 |
32 | export const FilePickerComponent = (props: UseFilePickerConfig) => {
33 | const { openFilePicker, filesContent, errors, plainFiles, loading } = useFilePicker({ ...props });
34 |
35 | return (
36 | <>
37 | {loading && Loading... }
38 | {errors.length ? (
39 |
40 | errors:
41 |
42 | {errors.map((error, index) => (
43 |
44 |
{index + 1}.
45 | {Object.entries(error).map(([key, value]) => (
46 |
47 | {key}: {typeof value === 'string' ? value : Array.isArray(value) ? value.join(', ') : null}
48 |
49 | ))}
50 |
51 | ))}
52 |
53 | ) : null}
54 |
55 | Open File Picker
56 | {filesContent?.map(file => (file ? renderDependingOnReaderType(file, props.readAs!) : null))}
57 | {plainFiles?.map(file => (
58 |
59 | File name: {file.name}
60 |
61 | File size: {file.size} bytes
62 |
63 | File type: {file.type}
64 |
65 | Last modified: {new Date(file.lastModified).toISOString()}
66 |
67 | ))}
68 | >
69 | );
70 | };
71 |
72 | export const ImperativeFilePickerComponent = (props: UseFilePickerConfig) => {
73 | const { openFilePicker, filesContent, errors, plainFiles, loading } = useImperativeFilePicker({ ...props });
74 |
75 | return (
76 | <>
77 | {loading && Loading... }
78 | {errors.length ? (
79 |
80 | errors:
81 |
82 | {errors.map((error, index) => (
83 |
84 |
{index + 1}.
85 | {Object.entries(error).map(([key, value]) => (
86 |
87 | {key}: {typeof value === 'string' ? value : Array.isArray(value) ? value.join(', ') : null}
88 |
89 | ))}
90 |
91 | ))}
92 |
93 | ) : null}
94 |
95 | Open File Picker
96 | {filesContent?.map(file => (file ? renderDependingOnReaderType(file, props.readAs!) : null))}
97 | {plainFiles?.map(file => (
98 |
99 | File name: {file.name}
100 |
101 | File size: {file.size} bytes
102 |
103 | File type: {file.type}
104 |
105 | Last modified: {new Date(file.lastModified).toISOString()}
106 |
107 | ))}
108 | >
109 | );
110 | };
111 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/FileSizeValidator.test.tsx:
--------------------------------------------------------------------------------
1 | import { userEvent } from '@testing-library/user-event';
2 | import { waitFor } from '@testing-library/react';
3 | import { createFileOfSize, invokeUseFilePicker } from './testUtils.js';
4 | import { FileSizeValidator } from '../src/validators.js';
5 | import { describe, expect, it } from 'vitest';
6 |
7 | describe('FileSizeRestrictions', () => {
8 | it('should check maximum file size', async () => {
9 | const validators = [new FileSizeValidator({ maxFileSize: 8_000_000 })]; // 8MB
10 | const { input, result } = invokeUseFilePicker({ validators });
11 |
12 | const bigFile = createFileOfSize(10240 * 1024);
13 | await userEvent.upload(input.current!, bigFile);
14 |
15 | await waitFor(() => result.current.loading === false);
16 |
17 | if (result.current.errors[0]?.name === 'FileSizeError') {
18 | expect(result.current.errors[0]?.reason === 'FILE_SIZE_TOO_LARGE').toBe(true);
19 | } else {
20 | throw new Error('Expected FileSizeError');
21 | }
22 | });
23 |
24 | it('should check minimum file size', async () => {
25 | const validators = [new FileSizeValidator({ minFileSize: 1_000_000 })]; // 1MB
26 | const { input, result } = invokeUseFilePicker({ validators });
27 |
28 | const bigFile = createFileOfSize(0);
29 | await userEvent.upload(input.current!, bigFile);
30 |
31 | await waitFor(() => result.current.loading === false);
32 | if (result.current.errors[0]?.name === 'FileSizeError') {
33 | expect(result.current.errors[0]?.reason === 'FILE_SIZE_TOO_SMALL').toBe(true);
34 | } else {
35 | throw new Error('Expected FileSizeError');
36 | }
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/FileTypeValidator.test.tsx:
--------------------------------------------------------------------------------
1 | import { userEvent } from '@testing-library/user-event';
2 | import { waitFor } from '@testing-library/react';
3 | import { createFileOfSize, invokeUseFilePicker } from './testUtils.js';
4 | import { FileTypeValidator } from '../src/validators.js';
5 | import { describe, expect, it } from 'vitest';
6 |
7 | describe('FileTypeValidator', () => {
8 | it('should allow to select desired file extension', async () => {
9 | const validators = [new FileTypeValidator(['txt'])];
10 | const { input, result } = invokeUseFilePicker({ validators });
11 |
12 | const file = createFileOfSize(1024);
13 | await userEvent.upload(input.current!, file);
14 |
15 | await waitFor(() => result.current.loading === false);
16 |
17 | expect(result.current.plainFiles.length).toBe(1);
18 | });
19 |
20 | it('should reject a file with an extension that is not listed', async () => {
21 | const validators = [new FileTypeValidator(['.nonexistent'])];
22 | const { input, result } = invokeUseFilePicker({ validators });
23 |
24 | const file = createFileOfSize(1024);
25 | await userEvent.upload(input.current!, file);
26 |
27 | await waitFor(() => result.current.loading === false);
28 |
29 | expect(result.current.plainFiles.length).toBe(0);
30 | if (result.current.errors[0]?.name === 'FileTypeError') {
31 | expect(result.current.errors[0]?.reason === 'FILE_TYPE_NOT_ACCEPTED').toBe(true);
32 | } else {
33 | throw new Error('Expected FileTypeError');
34 | }
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/ImperativeFilePicker.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { userEvent } from '@testing-library/user-event';
3 | import { render, act, waitFor } from '@testing-library/react';
4 | import { invokeUseImperativeFilePicker, isInputElement } from './testUtils.js';
5 | import { describe, expect, it } from 'vitest';
6 | import { ImperativeFilePickerComponent } from './FilePickerTestComponents.jsx';
7 |
8 | describe('DefaultPicker', () => {
9 | it('renders without crashing', async () => {
10 | const { findByRole } = render( );
11 | const button = await findByRole('button');
12 | expect(button).toBeInTheDocument();
13 | });
14 | });
15 |
16 | describe('ImperativeFilePicker', () => {
17 | it('should keep selected files in state after new selection', async () => {
18 | const user = userEvent.setup();
19 | const { input, result } = invokeUseImperativeFilePicker({});
20 |
21 | const files = [
22 | new File(['hello'], 'hello.png', { type: 'image/png' }),
23 | new File(['there'], 'there.png', { type: 'image/png' }),
24 | ];
25 | await user.upload(input.current, files);
26 |
27 | await waitFor(() => result.current.loading === false);
28 | await waitFor(() => result.current.plainFiles.length > 0);
29 | expect(result.current.plainFiles.length).toBe(2);
30 |
31 | act(() => {
32 | result.current.openFilePicker();
33 | });
34 |
35 | if (!isInputElement(input.current!)) throw new Error('Input not found');
36 |
37 | const newFile = [new File(['new'], 'new.png', { type: 'image/png' })];
38 | await user.upload(input.current, newFile);
39 |
40 | await waitFor(() => result.current.loading === false);
41 | await waitFor(() => result.current.plainFiles.length > 2);
42 | expect(result.current.plainFiles.length).toBe(3);
43 | });
44 |
45 | it('should allow to remove files by index', async () => {
46 | const user = userEvent.setup();
47 | const { input, result } = invokeUseImperativeFilePicker({});
48 |
49 | const files = [
50 | new File(['hello'], 'hello.png', { type: 'image/png' }),
51 | new File(['there'], 'there.png', { type: 'image/png' }),
52 | new File(['new'], 'new.png', { type: 'image/png' }),
53 | ];
54 | await user.upload(input.current, files);
55 |
56 | await waitFor(() => result.current.loading === false);
57 | await waitFor(() => result.current.plainFiles.length > 0);
58 | expect(result.current.plainFiles.length).toBe(3);
59 |
60 | act(() => {
61 | result.current.removeFileByIndex(1); // remove the second file
62 | });
63 |
64 | await waitFor(() => result.current.loading === false);
65 | await waitFor(() => result.current.plainFiles.length < 3);
66 | expect(result.current.plainFiles.length).toBe(2);
67 | expect(result.current.plainFiles[0]?.name).toBe('hello.png');
68 | expect(result.current.plainFiles[1]?.name).toBe('new.png');
69 | });
70 |
71 | it('should allow to remove files by reference', async () => {
72 | const user = userEvent.setup();
73 | const { input, result } = invokeUseImperativeFilePicker({});
74 |
75 | const files = [
76 | new File(['hello'], 'hello.png', { type: 'image/png' }),
77 | new File(['there'], 'there.png', { type: 'image/png' }),
78 | new File(['new'], 'new.png', { type: 'image/png' }),
79 | ];
80 | await user.upload(input.current, files);
81 |
82 | await waitFor(() => result.current.loading === false);
83 | await waitFor(() => result.current.plainFiles.length > 0);
84 | expect(result.current.plainFiles.length).toBe(3);
85 |
86 | act(() => {
87 | const fileToBeRemoved = result.current.plainFiles[1]!; // remove the second file
88 | result.current.removeFileByReference(fileToBeRemoved); // remove the second file
89 | });
90 |
91 | await waitFor(() => result.current.loading === false);
92 | await waitFor(() => result.current.plainFiles.length < 3);
93 | expect(result.current.plainFiles.length).toBe(2);
94 | expect(result.current.plainFiles[0]?.name).toBe('hello.png');
95 | expect(result.current.plainFiles[1]?.name).toBe('new.png');
96 | });
97 |
98 | it('should allow to clear the selection', async () => {
99 | const user = userEvent.setup();
100 | const { input, result } = invokeUseImperativeFilePicker({});
101 |
102 | const files = [
103 | new File(['hello'], 'hello.png', { type: 'image/png' }),
104 | new File(['there'], 'there.png', { type: 'image/png' }),
105 | new File(['new'], 'new.png', { type: 'image/png' }),
106 | ];
107 | await user.upload(input.current, files);
108 |
109 | await waitFor(() => result.current.loading === false);
110 | await waitFor(() => result.current.plainFiles.length > 0);
111 | expect(result.current.plainFiles.length).toBe(3);
112 | expect(result.current.filesContent.length).toBe(3);
113 |
114 | act(() => {
115 | result.current.clear();
116 | });
117 |
118 | await waitFor(() => result.current.loading === false);
119 | expect(result.current.plainFiles.length).toBe(0);
120 | expect(result.current.filesContent.length).toBe(0);
121 | });
122 | });
123 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/setup.ts:
--------------------------------------------------------------------------------
1 | import { afterEach } from 'vitest';
2 | import { cleanup } from '@testing-library/react';
3 | import '@testing-library/jest-dom/vitest';
4 |
5 | afterEach(() => {
6 | cleanup();
7 | });
8 |
--------------------------------------------------------------------------------
/packages/use-file-picker/test/testUtils.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react';
2 | import { useFilePicker, useImperativeFilePicker } from '../src/index.js';
3 | import {
4 | type ExtractContentTypeFromConfig,
5 | type ImperativeFilePickerReturnTypes,
6 | type UseFilePickerConfig,
7 | } from '../src/types.js';
8 |
9 | export const isInputElement = (el: HTMLElement): el is HTMLInputElement => el instanceof HTMLInputElement;
10 |
11 | export function createFileOfSize(sizeInBytes: number) {
12 | const content = new ArrayBuffer(sizeInBytes);
13 | const file = new File([content], 'bigFile.txt', { type: 'text/plain' });
14 | return file;
15 | }
16 |
17 | type UseFilePickerHook = typeof useFilePicker | typeof useImperativeFilePicker;
18 |
19 | const invokeFilePicker = (props: UseFilePickerConfig, useFilePicker: UseFilePickerHook) => {
20 | const input: { current: HTMLInputElement | null } = { current: null };
21 |
22 | const { result } = renderHook(() =>
23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
24 | //@ts-ignore
25 | useFilePicker({
26 | ...props,
27 | initializeWithCustomParameters(inputElement: HTMLInputElement) {
28 | input.current = inputElement;
29 | },
30 | })
31 | ) as { result: { current: ImperativeFilePickerReturnTypes> } };
32 |
33 | act(() => {
34 | result.current.openFilePicker();
35 | });
36 |
37 | if (!isInputElement(input.current!)) throw new Error('Input not found');
38 |
39 | return {
40 | result,
41 | input,
42 | };
43 | };
44 |
45 | export const invokeUseFilePicker = (props: UseFilePickerConfig) => invokeFilePicker(props, useFilePicker);
46 |
47 | export const invokeUseImperativeFilePicker = (props: T) =>
48 | invokeFilePicker(props, useImperativeFilePicker) as {
49 | result: {
50 | current: ImperativeFilePickerReturnTypes>;
51 | };
52 | input: { current: HTMLInputElement };
53 | };
54 |
--------------------------------------------------------------------------------
/packages/use-file-picker/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/use-file-picker/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 | import react from '@vitejs/plugin-react';
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | test: {
7 | environment: 'happy-dom',
8 | setupFiles: ['./test/setup.ts'],
9 | include: ['test/**/*.test.tsx'],
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/*
3 | onlyBuiltDependencies:
4 | - '@swc/core'
5 | - esbuild
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "ES2015",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 | "verbatimModuleSyntax": true,
12 | "jsx": "preserve",
13 |
14 | /* Strictness */
15 | "strict": true,
16 | "noUncheckedIndexedAccess": true,
17 | "noImplicitOverride": true,
18 |
19 | /* If transpiling with TypeScript: */
20 | "module": "NodeNext",
21 | "outDir": "dist",
22 | "sourceMap": true,
23 |
24 | /* AND if you're building for a library: */
25 | "declaration": true,
26 |
27 | /* AND if you're building for a library in a monorepo: */
28 | "composite": true,
29 | "declarationMap": true,
30 |
31 | /* If your code runs in the DOM: */
32 | "lib": ["es2019", "dom", "dom.iterable"]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.com/schema.json",
3 | "tasks": {
4 | "build": {
5 | "outputs": ["dist/**"]
6 | },
7 | "check-types": {
8 | "dependsOn": ["^check-types"]
9 | },
10 | "test": {
11 | "dependsOn": ["^test"]
12 | },
13 | "dev": {
14 | "dependsOn": ["build"],
15 | "persistent": true,
16 | "cache": false
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------