-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
File picker component #50
Changes from 49 commits
a620ed7
3c67b3c
41b7799
4d2c737
2d60700
8e8dd01
049606a
d29d361
eed85ae
e413e3a
06eae27
83b1dda
73bc6bf
10dc2cb
e695c00
787c38a
c4cbfe1
9e3e4dc
ac3b071
b03352f
aa8cd17
76641bd
34842bd
f6c4fa6
e747109
2336b2e
c5a3b32
2bd7480
8cece36
69ac678
fcabf67
0ff2cc4
1d307ac
6c7ef0f
556655f
4ee18c1
503fee1
4f002a3
dc99309
41e67a7
3d09374
ce995a6
c7a8d84
73073ce
f630d40
e39ac94
368d50a
89d8a32
d6a9b18
68ee789
e4404e6
07b8b2f
5e5501b
9bc41dd
eb7d938
2fc31d2
abe5880
87d3b49
9a80bd8
69af234
8786ab7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,7 +34,7 @@ | |
"homepage": "https://mavenlink.github.io/design-system", | ||
"jest": { | ||
"moduleNameMapper": { | ||
"\\.css$": "identity-obj-proxy" | ||
"\\.(css|svg)$": "identity-obj-proxy" | ||
}, | ||
"setupTestFrameworkScriptFile": "<rootDir>/setup-enzyme.js", | ||
"testPathIgnorePatterns": [ | ||
|
@@ -48,35 +48,37 @@ | |
"@babel/polyfill": "^7.4.4", | ||
"@babel/preset-env": "^7.2.3", | ||
"@babel/preset-react": "^7.0.0", | ||
"@testing-library/react": "^9.1.1", | ||
"babel-core": "^7.0.0-bridge.0", | ||
"babel-eslint": "^10.0.1", | ||
"babel-jest": "^23.6.0", | ||
"babel-loader": "^8.0.5", | ||
"css-loader": "^2.1.0", | ||
"cypress": "^3.1.4", | ||
"enzyme": "^3.8.0", | ||
"enzyme-adapter-react-16": "^1.7.1", | ||
"eslint": "^4.3.0", | ||
"enzyme": "^3.10.0", | ||
"enzyme-adapter-react-16": "^1.14.0", | ||
"eslint": "^6.1.0", | ||
"eslint-config-mavenlint-react": "^2.0.0", | ||
"eslint-plugin-cypress": "^2.2.0", | ||
"file-loader": "^3.0.1", | ||
"identity-obj-proxy": "^3.0.0", | ||
"jest": "^23.6.0", | ||
"postcss-loader": "^3.0.0", | ||
"postcss-preset-env": "^6.5.0", | ||
"react": "^16.7.0", | ||
"react-dom": "^16.7.0", | ||
"react": "^16.9.0", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Hooks support started in 16.8 and also we might as well keep up (bigmaven is already using hooks for example!) |
||
"react-dom": "^16.9.0", | ||
"react-ga": "^2.5.6", | ||
"react-styleguidist": "^8.0.6", | ||
"react-styleguidist": "9.0.4", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interestingly, we cannot upgrade more then this for the time being because we do not yet support the React.Fragment shorthand and they started using this in a couple of places 😛 |
||
"react-test-renderer": "^16.9.0", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Upgraded some of these libraries to be compatible with React Hooks: |
||
"react-transition-group": "^2.5.3", | ||
"style-loader": "^0.23.1", | ||
"svg-sprite-loader": "^3.8.0", | ||
"svgo": "^1.3,0", | ||
"svgo-loader": "^2.1.0", | ||
"stylelint": "^10.1.0", | ||
"stylelint-config-css-modules": "^1.4.0", | ||
"stylelint-config-standard": "^18.3.0", | ||
"stylelint-css-modules": "^0.9.0", | ||
"svg-sprite-loader": "^3.8.0", | ||
"svgo": "^1.3,0", | ||
"svgo-loader": "^2.1.0", | ||
"url-loader": "^1.1.2", | ||
"wait-on": "^3.2.0", | ||
"webpack": "^4.28.3" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
{ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can define the overrides in the top-level eslintrc with a files regex. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 68ee789 |
||
"rules": { | ||
// Override: role button for file-picker's label allows it to | ||
// behave as a file dialog open button in my VoiceOver testing | ||
"jsx-a11y/no-noninteractive-element-to-interactive-role": [ | ||
'error', | ||
{ | ||
label: [ | ||
'button', | ||
], | ||
}, | ||
] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
@import "../../styles/colors.css"; | ||
@import "../../styles/spacing.css"; | ||
@import "../../styles/typography.css"; | ||
|
||
.title, | ||
.label, | ||
.filename, | ||
.remove { | ||
font-family: var(--mavenlink-type-font-family); | ||
} | ||
|
||
.dropzone { | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
width: 100%; | ||
text-align: center; | ||
background: var(--palette-neutral-x-light); | ||
min-height: 100px; | ||
vertical-align: middle; | ||
border: 2px dashed var(--palette-grey-light); | ||
} | ||
|
||
.highlight-dropzone { | ||
border: 2px solid var(--palette-grey-base); | ||
opacity: 0.4; | ||
} | ||
|
||
.label-base { | ||
display: block; | ||
flex: 1; | ||
margin: 0 0 var(--spacing-small); | ||
color: var(--palette-grey-dark); | ||
cursor: pointer; | ||
} | ||
|
||
.label { | ||
composes: label-base; | ||
line-height: 100px; | ||
} | ||
|
||
.filename, | ||
.label { | ||
font-size: var(--mavenlink-type-base-size); | ||
} | ||
|
||
.title { | ||
composes: label-base; | ||
font-size: var(--mavenlink-type-small-size); | ||
} | ||
|
||
.file { | ||
position: absolute; | ||
width: 1px; | ||
height: 1px; | ||
margin: -1px; | ||
border: 0; | ||
padding: 0; | ||
clip: rect(0 0 0 0); | ||
overflow: hidden; | ||
} | ||
|
||
.label:focus { | ||
outline: 1px dotted var(--palette-grey-dark); | ||
outline: -webkit-focus-ring-color auto 5px; | ||
} | ||
|
||
.file-list { | ||
display: flex; | ||
flex-direction: column; | ||
} | ||
|
||
.file-list-button { | ||
display: flex; | ||
flex-direction: row; | ||
border-bottom: 1px solid var(--palette-grey-light); | ||
line-height: var(--spacing-x-large); | ||
} | ||
|
||
.icon { | ||
align-self: center; | ||
margin-right: var(--spacing-medium); | ||
} | ||
|
||
.upload { | ||
color: var(--palette-grey-dark); | ||
vertical-align: middle; | ||
} | ||
|
||
.upload-icon { | ||
margin-top: -1px; | ||
margin-right: var(--spacing-small); | ||
} | ||
|
||
.filename { | ||
flex-grow: 1; | ||
text-align: left; | ||
color: var(--palette-grey-x-dark); | ||
} | ||
|
||
.remove { | ||
flex: 0 0 50px; | ||
cursor: pointer; | ||
background: transparent; | ||
border: none; | ||
font-weight: bold; | ||
color: var(--palette-grey-base); | ||
text-align: center; | ||
font-size: 18px; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import PropTypes from 'prop-types'; | ||
import React, { useState, useRef } from 'react'; | ||
import Icon from '../icon/icon'; | ||
import useError from '../../hooks/useError'; | ||
import styles from './file-picker.css'; | ||
import iconUpload from '../../svgs/icon-cloud-upload-negative.svg'; | ||
import iconFileDefault from '../../svgs/icon-file-default.svg'; | ||
|
||
const FilePicker = (props) => { | ||
const { | ||
dropzoneClasses, | ||
labelClasses, | ||
fileClasses, | ||
fileListClasses, | ||
id, | ||
title, | ||
receiveFilesChanged, | ||
validator, | ||
...rest | ||
} = props; | ||
|
||
const [files, setFiles] = useState([]); | ||
const [getError, setError, validate] = useError(validator); | ||
const [highlight, setHighlight] = useState(false); | ||
const inputFile = useRef(null); | ||
|
||
const setFilesChanged = (selectedFiles) => { | ||
// Accounts for case where User's clicked 'Cancel' on native file dialog | ||
if (selectedFiles.length) { | ||
const currentFiles = []; | ||
Array.from(selectedFiles).map(file => currentFiles.push(file)); | ||
const errMsg = validate(currentFiles); | ||
if (errMsg.length) { | ||
setError(errMsg); | ||
} else { | ||
setError(''); | ||
setFiles(currentFiles); | ||
if (props.receiveFilesChanged) { | ||
props.receiveFilesChanged.call(this, currentFiles); | ||
} | ||
} | ||
} | ||
}; | ||
|
||
const onFilesChanged = (e) => { | ||
const selectedFiles = e.currentTarget.files; | ||
setFilesChanged(selectedFiles); | ||
}; | ||
|
||
const onRemoveFile = (e, file) => { | ||
e.preventDefault(); | ||
const currentFiles = files.filter(f => f.name !== file.name); | ||
setFiles(currentFiles); | ||
if (props.receiveFilesChanged) { | ||
props.receiveFilesChanged.call(this, currentFiles); | ||
} | ||
}; | ||
|
||
const onLabelKeypress = (e) => { | ||
e.preventDefault(); | ||
if (e.key === 'Enter') { | ||
inputFile.current.click(); | ||
} | ||
}; | ||
|
||
// This prevents the dropEffect going from "copy" to "none" | ||
// (in chrome, cursor turns from a green plus to an arrow) | ||
// https://github.com/react-dropzone/react-dropzone/blob/master/src/index.js#L533 | ||
const fixDropEffect = (e) => { | ||
e.stopPropagation(); | ||
if (e.dataTransfer) { | ||
try { | ||
e.dataTransfer.dropEffect = 'copy'; | ||
} catch {} /* eslint-disable-line no-empty */ | ||
} | ||
}; | ||
|
||
const onDragOver = (e) => { | ||
e.preventDefault(); | ||
fixDropEffect(e); | ||
}; | ||
|
||
const onDragLeave = () => { | ||
setHighlight(false); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are missing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in e4404e6 |
||
}; | ||
|
||
const onDrop = (e) => { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
const droppedFiles = e.dataTransfer.files; | ||
setFilesChanged(droppedFiles); | ||
setHighlight(false); | ||
}; | ||
|
||
const getFilesList = () => { | ||
if (files.length) { | ||
return files.map((file) => { | ||
return ( | ||
<section className={styles['file-list-button']} key={file.name}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's convert this list of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in: 07b8b2f |
||
<Icon className={styles.icon} name={iconFileDefault.id} size="medium" stroke="grey-base" fill="none" title="Upload file icon" /> | ||
<span className={styles.filename}>{file.name}</span> | ||
<button onClick={e => onRemoveFile(e, file)} className={styles.remove}>×</button> | ||
</section> | ||
); | ||
}); | ||
} | ||
return ''; | ||
}; | ||
|
||
const getDropzoneClasses = () => { | ||
const highlightClasses = highlight ? ` ${styles['highlight-dropzone']}` : ''; | ||
return `${props.dropzoneClasses}${highlightClasses}`; | ||
}; | ||
|
||
return ( | ||
<React.Fragment> | ||
<span className={styles.title}>{props.title}</span>{getError()} | ||
<section className={props.fileListClasses}> | ||
{getFilesList()} | ||
</section> | ||
<section | ||
className={getDropzoneClasses()} | ||
draggable | ||
onDragOver={onDragOver} | ||
onDragLeave={onDragLeave} | ||
onDrop={onDrop} | ||
> | ||
<label onKeyDown={onLabelKeypress} htmlFor={props.id} className={props.labelClasses} tabIndex="0" role="button" aria-controls={props.id}> | ||
<Icon className={styles['upload-icon']} name={iconUpload.id} size="medium" fill="grey-dark" stroke="none" title="Upload file icon" /> | ||
<span className={styles.upload}>Upload Files</span> | ||
<input onChange={e => onFilesChanged(e)} type="file" id={props.id} className={props.fileClasses} ref={inputFile} {...rest} /> | ||
</label> | ||
</section> | ||
</React.Fragment> | ||
); | ||
}; | ||
|
||
FilePicker.propTypes = { | ||
dropzoneClasses: PropTypes.string, | ||
fileClasses: PropTypes.string, | ||
fileListClasses: PropTypes.string, | ||
id: PropTypes.string.isRequired, | ||
labelClasses: PropTypes.string, | ||
title: PropTypes.string.isRequired, | ||
multiple: PropTypes.string, | ||
receiveFilesChanged: PropTypes.func, | ||
validator: PropTypes.func, | ||
}; | ||
|
||
FilePicker.defaultProps = { | ||
dropzoneClasses: styles.dropzone, | ||
fileClasses: styles.file, | ||
fileListClasses: styles['file-list'], | ||
id: undefined, | ||
labelClasses: styles.label, | ||
title: undefined, | ||
multiple: undefined, | ||
receiveFilesChanged: undefined, | ||
validator: undefined, | ||
}; | ||
|
||
export default FilePicker; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
Default usage | ||
|
||
You can either select files via the file dialog picker, or, drop | ||
the files onto the "dropzone". | ||
|
||
```jsx | ||
<FilePicker id="le-picker-single" title="Attach Files" /> | ||
``` | ||
|
||
Multiple | ||
|
||
```jsx | ||
<FilePicker id="le-picker-multi" title="Attach Multiple Files" multiple='multiple' /> | ||
``` | ||
|
||
The `receiveFilesChanged` prop allows you to receive notifications when | ||
the _FileList_ has changed. (see | ||
[File API](https://developer.mozilla.org/en-US/docs/Web/API/File_and_Directory_Entries_API)) | ||
You can then iteract with these files using the [File API](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL) | ||
per file. For example, you may wish to do something like | ||
`FileReader.readAsDataURL(someFile)` before uploading to a server endpoint. | ||
|
||
Interact with the following example component and view the console output. | ||
|
||
```jsx | ||
const callback = filelist => { filelist.forEach(file => console.log('file: ', file)) } | ||
<FilePicker receiveFilesChanged={callback} id="le-picker-notify" title="Attach" multiple='multiple' /> | ||
``` | ||
|
||
Errors | ||
|
||
Pass a `validator` function that verifies each of the files and returns an | ||
error message. The component will then take care of displaying these returned | ||
errors properly. To see this, pick a file greater then 10MB: | ||
|
||
```jsx | ||
const fileSizeError = 'The file size cannot exceed 10MB.'; | ||
const validateFileSize = (files) => { | ||
const max = 10240000; | ||
const errors = files.reduce((acc, file) => { | ||
if (file.size > max) { | ||
acc.push(`${file.name} too big. ${fileSizeError}`); | ||
} | ||
return acc; | ||
}, []); | ||
return errors.join(' '); | ||
} | ||
<FilePicker validator={validateFileSize} id="validator-example" title="Attach Files" /> | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kent Dodds popular testing framework:
https://testing-library.com/docs/react-testing-library/intro
We're using this in bigmaven already and it's got some things that make it easier to test uncontrolled components too.