├── .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 | [![alt Version](https://img.shields.io/npm/v/use-file-picker?color=blue)](https://www.npmjs.com/package/use-file-picker) ![alt License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) [![alt Twitter: twitter.com/jaaneek/](https://img.shields.io/twitter/follow/jaaneek.svg?style=social)](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 | 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 | 108 |
109 | {filesContent.map((file, index) => ( 110 |
111 |

{file.name}

112 | {file.name} 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 | 156 |
157 | {filesContent.map((file, index) => ( 158 |
159 |

{file.name}

160 | {file.name} 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 | 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 | 206 | 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 | 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 | 316 | 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 | 331 | 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 | ![cooldoge Discord & Slack Emoji](https://emoji.gg/assets/emoji/cooldoge.gif) 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 | 76 | 77 | 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 | 45 | 46 | 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 | 77 | 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 | {file.name} 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 | 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 | 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 | --------------------------------------------------------------------------------