├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE.md ├── README.md ├── config ├── webpack.build.config.js └── webpack.dev.config.js ├── demo.gif ├── dist └── index.js ├── examples ├── Gallery.js ├── ListWithUpload.js ├── RenderProps.js ├── assets │ └── style.css ├── index.html ├── index.js └── server.js ├── package-lock.json ├── package.json └── src ├── index.js └── utils ├── fileExtension.js ├── fileSizeReadable.js └── fileTypeAcceptable.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "modules": false }], 4 | "@babel/preset-react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "mother", 3 | "rules": { 4 | "import/no-extraneous-dependencies": [ 5 | "error", { 6 | "devDependencies": true, 7 | "optionalDependencies": false, 8 | "peerDependencies": true 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | examples/.uploads/* 5 | .tmp/* 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Mother Co 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 | React Files 2 | ======================= 3 | 4 | A minimal, zero dependency, file input (dropzone) component for React. 5 | 6 | If upgrading from version 1 or 2, see the [Upgrading to Version 3](#upgrading-to-version-3) section below. 7 | 8 | ![Alt text](/demo.gif?raw=true "Demo") 9 | 10 | ## Installation 11 | 12 | Install using npm or yarn. Requires React 16.8+. 13 | 14 | ```bash 15 | npm install react-files --save 16 | ``` 17 | 18 | ## Basic Usage 19 | 20 | ```js 21 | import React from 'react' 22 | import Files from 'react-files' 23 | 24 | const FileDropzone = () => { 25 | const handleChange = (files) => { 26 | console.log(files) 27 | } 28 | 29 | const handleError = (error, file) => { 30 | console.log('error code ' + error.code + ': ' + error.message) 31 | } 32 | 33 | return ( 34 |
35 | 44 | Drop files here or click to upload 45 | 46 |
47 | ) 48 | } 49 | ``` 50 | 51 | ## Upgrading to Version 3 52 | 53 | Most of the changes made to version 3 are internal, but there are some notable and breaking changes: 54 | 1. The most significant change is that `react-files` no longer manages state internally to track files that have been uploaded to a file list. This can be achieved quite simply however - please refer to the [`ListWithUpload` example](https://github.com/mother/react-files/blob/master/examples/ListWithUpload.js). 55 | 2. `dropActiveClassName` prop has been renamed to `dragActiveClassName`. 56 | 2. Removed unnecessary parent/wrapper `div` element. No more default values for `className` or `dragActiveClassName` props. 57 | 3. Ability to pass in a render prop with a prop that indicates whether a drag is in progress. See the [`RenderProps` example](https://github.com/mother/react-files/blob/master/examples/RenderProps.js). 58 | 4. Ability to pass in attributes to underlying input 59 | 60 | For a full list of changes, please checkout the [v3.0.0 release changelog](https://github.com/mother/react-files/releases/tag/v3.0.0) or the [corresponding pull request](https://github.com/mother/react-files/pull/32). 61 | 62 | ## Props 63 | 64 | `onChange(files)` - *Function* 65 | 66 | Perform work on files added when submit is clicked. 67 | 68 | --- 69 | 70 | `onError(error, file)` - *Function* 71 | - `error.code` - Number 72 | - `error.message` - String 73 | 74 | Perform work or notify the user when an error occurs. 75 | 76 | Error codes are: 77 | 1. Invalid file type 78 | 2. File too large 79 | 3. File too small 80 | 4. Maximum file count reached 81 | 82 | --- 83 | 84 | `accepts` - *Array* of *String* 85 | 86 | Control what types of generic/specific MIME types or file extensions can be dropped/added. 87 | 88 | > See full list of MIME types here: http://www.iana.org/assignments/media-types/media-types.xhtml 89 | 90 | Example: 91 | ```js 92 | accepts={['image/*', 'video/mp4', 'audio/*', '.pdf']} 93 | ``` 94 | 95 | --- 96 | 97 | `multiple` - *Boolean* 98 | 99 | Default: `true` 100 | 101 | Allow multiple files 102 | 103 | --- 104 | 105 | `clickable` - *Boolean* 106 | 107 | Default: `true` 108 | 109 | Dropzone is clickable to open file browser. Disable for dropping only. 110 | 111 | --- 112 | 113 | `maxFiles` - *Number* 114 | 115 | Default: `Infinity` 116 | 117 | Maximum number of files allowed 118 | 119 | --- 120 | 121 | `maxFileSize` - *Number* 122 | 123 | Default: `Infinity` 124 | 125 | Maximum file size allowed (in bytes) 126 | 127 | --- 128 | 129 | `minFileSize` - *Number* 130 | 131 | Default: `0` 132 | 133 | Minimum file size allowed (in bytes) 134 | 135 | --- 136 | 137 | `dragActiveClassName` - *String* 138 | 139 | Class added to the Files component when user is actively hovering over the dropzone with files selected. 140 | 141 | --- 142 | 143 | `inputProps` - *Object* 144 | 145 | Default: `{}` 146 | 147 | Inject properties directly into the underlying HTML `file` input. Useful for setting `required` or overriding the `style` attributes. 148 | 149 | --- 150 | 151 | ## Examples 152 | 153 | To run the examples locally, clone and install peer dependencies (React 16.8+) 154 | 155 | ``` 156 | git clone https://github.com/mother/react-files 157 | npm install 158 | npm i react react-dom 159 | ``` 160 | 161 | Then run the examples server: 162 | ``` 163 | npm run examples 164 | ``` 165 | 166 | Then visit http://localhost:8080/ 167 | 168 | ## License 169 | 170 | MIT. Copyright (c) Mother Co. 2023 171 | -------------------------------------------------------------------------------- /config/webpack.build.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: { 6 | main: './src/index.js' 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, '..', 'dist'), 10 | filename: 'index.js', 11 | libraryTarget: 'umd', 12 | // Workaround until https://github.com/webpack/webpack/issues/6525 is adddressed 13 | globalObject: 'this' 14 | }, 15 | externals: { 16 | react: { 17 | commonjs: 'react', 18 | commonjs2: 'react', 19 | amd: 'React', 20 | root: 'React' 21 | }, 22 | 'react-dom': { 23 | commonjs: 'react-dom', 24 | commonjs2: 'react-dom', 25 | amd: 'ReactDOM', 26 | root: 'ReactDOM' 27 | } 28 | }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.js$/, 33 | exclude: /node_modules/, 34 | use: ['babel-loader'] 35 | } 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /config/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: [ 7 | 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000', 8 | './examples/index.js' 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/' 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | use: ['babel-loader'] 21 | } 22 | ] 23 | }, 24 | plugins: [ 25 | new webpack.HotModuleReplacementPlugin() 26 | ], 27 | resolve: { 28 | extensions: ['.js'], 29 | enforceExtension: false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mother/react-files/3ce5aa47194e361acd10c7ff9ac3c2c2546a8c49/demo.gif -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | !function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t(require("react"));else if("function"==typeof define&&define.amd)define(["React"],t);else{var r="object"==typeof exports?t(require("react")):t(e.React);for(var n in r)("object"==typeof exports?exports:e)[n]=r[n]}}(this,(e=>(()=>{var t={694:(e,t,r)=>{"use strict";var n=r(925);function o(){}function a(){}a.resetWarningCache=o,e.exports=function(){function e(e,t,r,o,a,i){if(i!==n){var l=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw l.name="Invariant Violation",l}}function t(){return e}e.isRequired=e;var r={array:e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:a,resetWarningCache:o};return r.PropTypes=r,r}},556:(e,t,r)=>{e.exports=r(694)()},925:e=>{"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},156:t=>{"use strict";t.exports=e}},r={};function n(e){var o=r[e];if(void 0!==o)return o.exports;var a=r[e]={exports:{}};return t[e](a,a.exports,n),a.exports}n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var o={};return(()=>{"use strict";n.r(o),n.d(o,{default:()=>d});var e=n(156),t=n.n(e),r=n(556),a=n.n(r);const i=function(e){var t=e.name.split(".");return t.length>1?t[t.length-1]:"none"};function l(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var r=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=r){var n,o,a,i,l=[],c=!0,u=!1;try{if(a=(r=r.call(e)).next,0===t){if(Object(r)!==r)return;c=!1}else for(;!(c=(n=a.call(r)).done)&&(l.push(n.value),l.length!==t);c=!0);}catch(e){u=!0,o=e}finally{try{if(!c&&null!=r.return&&(i=r.return(),Object(i)!==i))return}finally{if(u)throw o}}return l}}(e,t)||function(e,t){if(e){if("string"==typeof e)return c(e,t);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?c(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function c(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);re.length)&&(t=e.length);for(var r=0,n=Array(t);r1&&(r=[r[0]]);for(var n=[],o=0;o=1e9?Math.ceil(t/1e9)+"GB":t>=1e6?Math.ceil(t/1e6)+"MB":t>=1e3?Math.ceil(t/1e3)+"KB":Math.ceil(t)+"B",a.type&&"image"===a.type.split("/")[0]?a.preview={type:"image",url:window.URL.createObjectURL(a)}:a.preview={type:"file"},n.length>=x){B({code:4,message:"maximum file count reached"},a);break}if(a.size>S){B({code:2,message:"".concat(a.name," is too large")},a);break}if(a.size { 5 | const [files, setFiles] = useState([]) 6 | const handleChange = (newFiles) => { 7 | setFiles(prevFiles => [...prevFiles, ...newFiles]) 8 | } 9 | 10 | return ( 11 |
12 |

Example 2 - Gallery

13 | 20 | {files.length === 0 && ( 21 |
Drop images here
22 | )} 23 | {files.length > 0 && ( 24 |
25 | {files.map(file => ( 26 | 31 | ))} 32 |
33 | )} 34 |
35 |
36 | ) 37 | } 38 | 39 | export default Gallery 40 | -------------------------------------------------------------------------------- /examples/ListWithUpload.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import axios from 'axios' 3 | import Blob from 'blob' 4 | import FormData from 'form-data' 5 | import Files from '../src' 6 | 7 | const ListWithUpload = () => { 8 | const [files, setFiles] = useState([]) 9 | const handleChange = (newFiles) => { 10 | setFiles(prevFiles => [...prevFiles, ...newFiles]) 11 | } 12 | 13 | const handleFileRemove = (fileId) => { 14 | setFiles(prevFiles => prevFiles.filter(prevFile => prevFile.id !== fileId)) 15 | } 16 | 17 | const handleClearFiles = () => { 18 | setFiles([]) 19 | } 20 | 21 | const handleUploadFiles = () => { 22 | const formData = new FormData() 23 | files.forEach((file) => { 24 | formData.append(file.id, new Blob([file], { type: file.type }), file.name || 'file') 25 | }) 26 | 27 | axios.post('/files', formData).then(() => { 28 | window.alert(`${files.length} files uploaded succesfully!`) 29 | setFiles([]) 30 | }).catch((err) => { 31 | window.alert(`Error uploading files: ${err.message}`) 32 | }) 33 | } 34 | 35 | return ( 36 |
37 |

Example 1 - List

38 | 48 | Drop files here or click to upload 49 | 50 | 51 | 52 | 53 | {files.length > 0 && ( 54 |
55 |
    56 | {files.map(file => ( 57 |
  • 58 |
    59 | {file.preview.type === 'image' 60 | ? 61 | :
    {file.extension}
    } 62 |
    63 |
    64 |
    {file.name}
    65 |
    {file.sizeReadable}
    66 |
    67 |
    handleFileRemove(file.id)} 70 | /> 71 |
  • 72 | ))} 73 |
74 |
75 | )} 76 |
77 | ) 78 | } 79 | 80 | export default ListWithUpload 81 | -------------------------------------------------------------------------------- /examples/RenderProps.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Files from '../src' 3 | 4 | const RenderProps = () => ( 5 |
6 |

Example 3 - Render Props

7 | 11 | {isDragging => ( 12 |
19 | {isDragging && 'Drop Here!'} 20 | {!isDragging && 'Ready'} 21 |
22 | )} 23 |
24 |
25 | ) 26 | 27 | export default RenderProps 28 | -------------------------------------------------------------------------------- /examples/assets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Roboto', sans-serif; 3 | } 4 | 5 | 6 | /* LIST */ 7 | /* ================== */ 8 | 9 | .files-dropzone-list { 10 | display: table-cell; 11 | vertical-align: middle; 12 | text-align: center; 13 | padding: 10px; 14 | border: 1px dashed #D3D3D3; 15 | cursor: pointer; 16 | } 17 | 18 | .files-buttons { 19 | border-top: 1px solid #D3D3D3; 20 | cursor: pointer; 21 | } 22 | .files-button-submit { 23 | display: inline-block; 24 | text-align: center; 25 | height: 40px; 26 | line-height: 40px; 27 | width: 50%; 28 | box-sizing: border-box; 29 | border-right: 1px solid #D3D3D3; 30 | } 31 | .files-button-submit:before { 32 | content: "Submit" 33 | } 34 | .files-button-clear { 35 | display: inline-block; 36 | text-align: center; 37 | height: 40px; 38 | line-height: 40px; 39 | width: 50%; 40 | box-sizing: border-box; 41 | } 42 | .files-button-clear:before { 43 | content: "Clear" 44 | } 45 | .files-list { 46 | width: 300px; 47 | } 48 | .files-list ul { 49 | list-style: none; 50 | margin: 0; 51 | padding: 0; 52 | } 53 | .files-list li:last-child { 54 | border: none; 55 | } 56 | .files-list-item { 57 | height: 60px; 58 | padding: 10px 0px 10px 10px; 59 | /*border-bottom: 1px solid #D3D3D3;*/ 60 | } 61 | .files-list-item-content { 62 | float: left; 63 | padding-top: 5px; 64 | padding-left: 10px; 65 | width: calc(100% - 130px); 66 | } 67 | .files-list-item-content-item { 68 | overflow: hidden; 69 | white-space: nowrap; 70 | text-overflow: ellipsis; 71 | } 72 | .files-list-item-content-item-1 { 73 | font-size: 20px; 74 | line-height: 30px; 75 | } 76 | .files-list-item-content-item-2 { 77 | font-size: 16px; 78 | line-height: 20px; 79 | } 80 | .files-list-item-preview { 81 | height: 60px; 82 | width: 60px; 83 | float: left; 84 | } 85 | .files-list-item-preview-image { 86 | height: 100%; 87 | width: 100%; 88 | } 89 | .files-list-item-preview-extension { 90 | text-align: center; 91 | line-height: 60px; 92 | color: #FFF; 93 | background-color: #D3D3D3; 94 | font-size: 16px; 95 | overflow: hidden; 96 | white-space: nowrap; 97 | text-overflow: ellipsis; 98 | padding-left: 5px; 99 | padding-right: 5px; 100 | box-sizing: border-box; 101 | } 102 | .files-list-item-remove { 103 | height: 60px; 104 | width: 60px; 105 | float: right; 106 | cursor: pointer; 107 | background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4IiB3aWR0aD0iNDgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTM4IDEyLjgzbC0yLjgzLTIuODMtMTEuMTcgMTEuMTctMTEuMTctMTEuMTctMi44MyAyLjgzIDExLjE3IDExLjE3LTExLjE3IDExLjE3IDIuODMgMi44MyAxMS4xNy0xMS4xNyAxMS4xNyAxMS4xNyAyLjgzLTIuODMtMTEuMTctMTEuMTd6Ii8+PHBhdGggZD0iTTAgMGg0OHY0OGgtNDh6IiBmaWxsPSJub25lIi8+PC9zdmc+) no-repeat center center; 108 | background-size: 30px 30px; 109 | } 110 | .files-list-item-remove-image { 111 | height: 30px; 112 | width: 30px; 113 | margin-top: 15px; 114 | margin-right: 10px; 115 | float: right; 116 | } 117 | 118 | 119 | 120 | /* GALLERY */ 121 | /* ================== */ 122 | 123 | .files-dropzone-gallery { 124 | padding: 10px; 125 | border: 1px dashed #D3D3D3; 126 | min-height: 300px; 127 | width: 500px; 128 | } 129 | .files-gallery-item { 130 | height: 80px; 131 | margin: 5px; 132 | } 133 | 134 | 135 | .files-dropzone-active { 136 | border: 1px solid lightgreen; 137 | color: lightgreen; 138 | } 139 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-files 5 | 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import ListWithUploadExample from './ListWithUpload' 4 | import GalleryExample from './Gallery' 5 | import RenderPropsExample from './RenderProps' 6 | 7 | const container = document.getElementById('container') 8 | const root = createRoot(container) 9 | root.render( 10 |
11 | 12 | 13 | 14 |
15 | ) 16 | -------------------------------------------------------------------------------- /examples/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const fs = require('fs') 3 | const multer = require('multer') 4 | const path = require('path') 5 | const webpack = require('webpack') 6 | const webpackDevMiddleware = require('webpack-dev-middleware') 7 | const webpackHotMiddleware = require('webpack-hot-middleware') 8 | const webpackConfig = require('../config/webpack.dev.config') 9 | 10 | const compiler = webpack(webpackConfig) 11 | const app = express() 12 | const upload = multer({ dest: path.join(__dirname, '.uploads') }) 13 | 14 | app.use(webpackDevMiddleware(compiler, { 15 | publicPath: webpackConfig.output.publicPath 16 | })) 17 | 18 | app.use(webpackHotMiddleware(compiler, { 19 | log: console.log, // eslint-disable-line no-console 20 | path: '/__webpack_hmr', 21 | heartbeat: 10 * 1000 22 | })) 23 | 24 | app.use('/assets', express.static(path.join(__dirname, 'assets'))) 25 | 26 | app.get('/', (req, res, next) => { 27 | res.sendFile(path.join(`${__dirname}/index.html`)) 28 | }) 29 | 30 | app.post('/files', upload.any(), (req, res, next) => { 31 | if (!req.files) { 32 | return next(new Error('No files uploaded')) 33 | } 34 | 35 | req.files.forEach((file) => { 36 | console.log(file) // eslint-disable-line no-console 37 | fs.unlinkSync(file.path) 38 | }) 39 | 40 | res.status(200).end() 41 | }) 42 | 43 | const server = app.listen(process.env.PORT || 8080, () => { 44 | // eslint-disable-next-line no-console 45 | console.log(`Starting react-files examples on port ${server.address().port}`) 46 | }) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-files", 3 | "version": "3.0.3", 4 | "main": "dist/index.js", 5 | "description": "A file input (dropzone) management component for React", 6 | "scripts": { 7 | "build": "rm -rf dist && webpack --config config/webpack.build.config.js", 8 | "examples": "node examples/server.js", 9 | "lint": "git diff HEAD --name-only --diff-filter=ACM | grep '.js$' | xargs node ./node_modules/eslint/bin/eslint.js --quiet", 10 | "lint-full": "node ./node_modules/eslint/bin/eslint.js .", 11 | "webpack-analyze": "mkdir -p .tmp; NODE_ENV=production webpack --config config/webpack.build.config.js --profile --json > .tmp/stats.json; webpack-bundle-analyzer .tmp/stats.json" 12 | }, 13 | "husky": { 14 | "hooks": { 15 | "pre-commit": "npm run lint && npm audit", 16 | "post-merge": "npm install", 17 | "post-receive": "npm install", 18 | "post-rewrite": "npm install" 19 | } 20 | }, 21 | "files": [ 22 | "dist/index.js" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/mother/react-files.git" 27 | }, 28 | "keywords": [ 29 | "react", 30 | "reactjs", 31 | "component", 32 | "file", 33 | "files", 34 | "input", 35 | "dropzone" 36 | ], 37 | "author": "Mother Co", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/mother/react-files/issues" 41 | }, 42 | "homepage": "https://github.com/mother/react-files", 43 | "peerDependencies": { 44 | "react": ">=16.8", 45 | "react-dom": ">=16.8" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.24.7", 49 | "@babel/polyfill": "^7.12.1", 50 | "@babel/preset-env": "^7.20.2", 51 | "@babel/preset-react": "^7.18.6", 52 | "axios": "^1.7.2", 53 | "babel-eslint": "^10.1.0", 54 | "babel-loader": "^9.1.2", 55 | "blob": "^0.1.0", 56 | "eslint-config-mother": "^2.0.15", 57 | "express": "^4.19.2", 58 | "form-data": "^4.0.0", 59 | "husky": "^4.2.5", 60 | "multer": "^1.4.5-lts.1", 61 | "prop-types": "^15.8.1", 62 | "react": "^18.2.0", 63 | "react-dom": "^18.2.0", 64 | "webpack": "^5.92.1", 65 | "webpack-bundle-analyzer": "^4.7.0", 66 | "webpack-cli": "^5.0.1", 67 | "webpack-dev-middleware": "^6.1.3", 68 | "webpack-hot-middleware": "^2.26.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import fileExtension from './utils/fileExtension' 4 | import fileSizeReadable from './utils/fileSizeReadable' 5 | import fileTypeAcceptable from './utils/fileTypeAcceptable' 6 | 7 | const Files = ({ 8 | accepts = null, 9 | children, 10 | className, 11 | clickable = true, 12 | dragActiveClassName, 13 | inputProps = {}, 14 | multiple = true, 15 | maxFiles = Infinity, 16 | maxFileSize = Infinity, 17 | minFileSize = 0, 18 | name = 'file', 19 | onChange = files => console.log(files), // eslint-disable-line no-console 20 | onDragEnter, 21 | onDragLeave, 22 | onError = err => console.log(`error code ${err.code}: ${err.message}`), // eslint-disable-line no-console 23 | style 24 | }) => { 25 | const idCounter = useRef(1) 26 | const dropzoneElement = useRef() 27 | const inputElement = useRef() 28 | const [isDragging, setDragging] = useState(false) 29 | 30 | const handleError = (error, file) => { 31 | onError(error, file) 32 | } 33 | 34 | const handleDragOver = useCallback((event) => { 35 | event.preventDefault() 36 | event.stopPropagation() 37 | }, []) 38 | 39 | const handleDragEnter = (event) => { 40 | const el = dropzoneElement.current 41 | if (dragActiveClassName && !el.className.includes(dragActiveClassName)) { 42 | el.className = `${el.className} ${dragActiveClassName}` 43 | } 44 | 45 | if (typeof children === 'function') { 46 | setDragging(true) 47 | } 48 | 49 | if (onDragEnter) { 50 | onDragEnter(event) 51 | } 52 | } 53 | 54 | const handleDragLeave = (event) => { 55 | const el = dropzoneElement.current 56 | if (dragActiveClassName) { 57 | el.className = el.className.replace(` ${dragActiveClassName}`, '') 58 | } 59 | 60 | if (typeof children === 'function') { 61 | setDragging(false) 62 | } 63 | 64 | if (onDragLeave) { 65 | onDragLeave(event) 66 | } 67 | } 68 | 69 | const openFileChooser = () => { 70 | inputElement.current.value = null 71 | inputElement.current.click() 72 | } 73 | 74 | const handleDrop = (event) => { 75 | event.preventDefault() 76 | handleDragLeave(event) 77 | 78 | // Collect added files, perform checking, cast pseudo-array to Array, 79 | // then return to method 80 | let filesAdded = event.dataTransfer 81 | ? event.dataTransfer.files 82 | : event.target.files 83 | 84 | // Multiple files dropped when not allowed 85 | if (multiple === false && filesAdded.length > 1) { 86 | filesAdded = [filesAdded[0]] 87 | } 88 | 89 | const fileResults = [] 90 | for (let i = 0; i < filesAdded.length; i += 1) { 91 | const file = filesAdded[i] 92 | 93 | // Assign file an id 94 | file.id = `files-${idCounter.current}` 95 | idCounter.current += 1 96 | 97 | // Tell file it's own extension 98 | file.extension = fileExtension(file) 99 | 100 | // Tell file it's own readable size 101 | file.sizeReadable = fileSizeReadable(file.size) 102 | 103 | // Add preview, either image or file extension 104 | if (file.type && file.type.split('/')[0] === 'image') { 105 | file.preview = { 106 | type: 'image', 107 | url: window.URL.createObjectURL(file) 108 | } 109 | } else { 110 | file.preview = { 111 | type: 'file' 112 | } 113 | } 114 | 115 | // Check max file count 116 | if (fileResults.length >= maxFiles) { 117 | handleError({ 118 | code: 4, 119 | message: 'maximum file count reached' 120 | }, file) 121 | 122 | break 123 | } 124 | 125 | // Check if file is too big 126 | if (file.size > maxFileSize) { 127 | handleError({ 128 | code: 2, 129 | message: `${file.name} is too large` 130 | }, file) 131 | 132 | break 133 | } 134 | 135 | // Check if file is too small 136 | if (file.size < minFileSize) { 137 | handleError({ 138 | code: 3, 139 | message: `${file.name} is too small` 140 | }, file) 141 | 142 | break 143 | } 144 | 145 | // Ensure acceptable file type 146 | if (!fileTypeAcceptable(accepts, file)) { 147 | handleError({ 148 | code: 1, 149 | message: `${file.name} is not a valid file type` 150 | }, file) 151 | 152 | break 153 | } 154 | 155 | fileResults.push(file) 156 | } 157 | 158 | onChange(fileResults) 159 | } 160 | 161 | return ( 162 | <> 163 | 173 | {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} 174 |
183 | {typeof children === 'function' ? children(isDragging) : children} 184 |
185 | 186 | ) 187 | } 188 | 189 | Files.propTypes = { 190 | accepts: PropTypes.array, 191 | children: PropTypes.oneOfType([ 192 | PropTypes.func, 193 | PropTypes.arrayOf(PropTypes.node), 194 | PropTypes.node 195 | ]), 196 | className: PropTypes.string, 197 | clickable: PropTypes.bool, 198 | dragActiveClassName: PropTypes.string, 199 | inputProps: PropTypes.object, 200 | multiple: PropTypes.bool, 201 | maxFiles: PropTypes.number, 202 | maxFileSize: PropTypes.number, 203 | minFileSize: PropTypes.number, 204 | name: PropTypes.string, 205 | onChange: PropTypes.func, 206 | onDragEnter: PropTypes.func, 207 | onDragLeave: PropTypes.func, 208 | onError: PropTypes.func, 209 | style: PropTypes.object 210 | } 211 | 212 | export default Files 213 | -------------------------------------------------------------------------------- /src/utils/fileExtension.js: -------------------------------------------------------------------------------- 1 | const fileExtension = (file) => { 2 | const extensionSplit = file.name.split('.') 3 | if (extensionSplit.length > 1) { 4 | return extensionSplit[extensionSplit.length - 1] 5 | } 6 | 7 | return 'none' 8 | } 9 | 10 | export default fileExtension 11 | -------------------------------------------------------------------------------- /src/utils/fileSizeReadable.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-template */ 2 | const fileSizeReadable = (size) => { 3 | if (size >= 1000000000) { 4 | return Math.ceil(size / 1000000000) + 'GB' 5 | } 6 | 7 | if (size >= 1000000) { 8 | return Math.ceil(size / 1000000) + 'MB' 9 | } 10 | 11 | if (size >= 1000) { 12 | return Math.ceil(size / 1000) + 'KB' 13 | } 14 | 15 | return Math.ceil(size) + 'B' 16 | } 17 | /* eslint-enable prefer-template */ 18 | 19 | export default fileSizeReadable 20 | -------------------------------------------------------------------------------- /src/utils/fileTypeAcceptable.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | const mimeTypeRegexp = /^(application|audio|example|image|message|model|multipart|text|video|\*)\/[a-z0-9\.\+\*-]+$/ 3 | const extRegexp = /\.[a-zA-Z0-9]*$/ 4 | 5 | const fileTypeAcceptable = (accepts, file) => { 6 | if (!accepts) { 7 | return true 8 | } 9 | 10 | return accepts.some((accept) => { 11 | if (file.type && accept.match(mimeTypeRegexp)) { 12 | const [typeLeft, typeRight] = file.type.split('/') 13 | const [acceptLeft, acceptRight] = accept.split('/') 14 | 15 | if (acceptLeft && acceptRight) { 16 | if (acceptLeft === '*' && acceptRight === '*') { 17 | return true 18 | } 19 | 20 | if (acceptLeft === typeLeft && acceptRight === '*') { 21 | return true 22 | } 23 | 24 | if (acceptLeft === typeLeft && acceptRight === typeRight) { 25 | return true 26 | } 27 | } 28 | } else if (file.extension && accept.match(extRegexp)) { 29 | const ext = accept.substr(1) 30 | return file.extension.toLowerCase() === ext.toLowerCase() 31 | } 32 | 33 | return false 34 | }) 35 | } 36 | 37 | export default fileTypeAcceptable 38 | --------------------------------------------------------------------------------