diff --git a/src/components/FileSelector/helpers/containsFiles.js b/src/components/FileSelector/helpers/containsFiles.js new file mode 100644 index 000000000..9ae4385bf --- /dev/null +++ b/src/components/FileSelector/helpers/containsFiles.js @@ -0,0 +1,6 @@ +export default function containsFiles(event) { + if (event.dataTransfer.types) { + return !event.dataTransfer.types.some(type => type !== 'Files'); + } + return true; +} diff --git a/src/components/FileSelector/icons/cancel.js b/src/components/FileSelector/icons/cancel.js new file mode 100644 index 000000000..d1f9738bc --- /dev/null +++ b/src/components/FileSelector/icons/cancel.js @@ -0,0 +1,33 @@ +import React from 'react'; + +export default function CancelIcon() { + return ( + + cancel + + + + + + + + + + + ); +} diff --git a/src/components/FileSelector/icons/error.js b/src/components/FileSelector/icons/error.js new file mode 100644 index 000000000..952c12fc0 --- /dev/null +++ b/src/components/FileSelector/icons/error.js @@ -0,0 +1,37 @@ +import React from 'react'; + +export default function ErrorIcon() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/src/components/FileSelector/icons/file.js b/src/components/FileSelector/icons/file.js new file mode 100644 index 000000000..dc6fcbac5 --- /dev/null +++ b/src/components/FileSelector/icons/file.js @@ -0,0 +1,71 @@ +import React from 'react'; + +export default function FileIcon() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/FileSelector/icons/files.js b/src/components/FileSelector/icons/files.js new file mode 100644 index 000000000..b8bc28998 --- /dev/null +++ b/src/components/FileSelector/icons/files.js @@ -0,0 +1,101 @@ +import React from 'react'; + +export default function FilesIcon() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/FileSelector/icons/index.js b/src/components/FileSelector/icons/index.js new file mode 100644 index 000000000..6559d9636 --- /dev/null +++ b/src/components/FileSelector/icons/index.js @@ -0,0 +1,7 @@ +import UploadIcon from './upload'; +import ErrorIcon from './error'; +import FileIcon from './file'; +import FilesIcon from './files'; +import CancelIcon from './cancel'; + +export { UploadIcon, ErrorIcon, FileIcon, FilesIcon, CancelIcon }; diff --git a/src/components/FileSelector/icons/upload.js b/src/components/FileSelector/icons/upload.js new file mode 100644 index 000000000..4dd7c7ac2 --- /dev/null +++ b/src/components/FileSelector/icons/upload.js @@ -0,0 +1,23 @@ +import React from 'react'; + +export default function UploadIcon() { + return ( + + + + + + + + ); +} diff --git a/src/components/FileSelector/index.d.ts b/src/components/FileSelector/index.d.ts new file mode 100644 index 000000000..91cc69f07 --- /dev/null +++ b/src/components/FileSelector/index.d.ts @@ -0,0 +1,23 @@ +import { BaseProps } from '../types'; +import { ReactNode } from 'react'; + +export interface FileSelectorProps extends BaseProps { + id: string; + name: string; + label: ReactNode; + error: ReactNode; + bottomHelpText: ReactNode; + placeholder: string; + tabIndex: string | number; + required: boolean; + multiple: boolean; + disabled: boolean; + variant: string; + hideLabel: boolean; + accept: string; + onChange: (value: Array) => void; + onFocus: (event: FocusEvent) => void; + onBlur: (event: FocusEvent) => void; +} + +export default function(props: FileSelectorProps): JSX.Element | null; diff --git a/src/components/FileSelector/index.js b/src/components/FileSelector/index.js new file mode 100644 index 000000000..237ff1dec --- /dev/null +++ b/src/components/FileSelector/index.js @@ -0,0 +1,320 @@ +import React, { useRef, useState, useEffect, useImperativeHandle } from 'react'; +import PropTypes from 'prop-types'; +import Label from '../Input/label'; +import useReduxForm from '../../libs/hooks/useReduxForm'; +import RenderIf from '../RenderIf'; +import containsFiles from './helpers/containsFiles'; +import HelpText from '../Input/styled/helpText'; +import ErrorText from '../Input/styled/errorText'; +import { + StyledContainer, + StyledDropzone, + StyledInput, + TruncatedText, + StyledBackdrop, + StyledIconContainer, + StyledButtonIcon, +} from './styled'; +import { UploadIcon, ErrorIcon, FileIcon, FilesIcon, CancelIcon } from './icons'; +import { useUniqueIdentifier, useErrorMessageId, useLabelId } from '../../libs/hooks'; + +const FileSelector = React.forwardRef((props, ref) => { + const { + className, + style, + id, + name, + label, + error, + bottomHelpText, + placeholder, + tabIndex, + required, + multiple, + disabled, + variant, + hideLabel, + accept, + onChange, + onFocus, + onBlur, + } = useReduxForm(props); + + const [isDragOver, setIsDragOver] = useState(false); + const [files, setFiles] = useState(); + const [hasFocus, setHasFocus] = useState(); + + const inputId = useUniqueIdentifier('input'); + const buttonId = useUniqueIdentifier('cancel-button'); + const dropzoneId = useUniqueIdentifier('dropzone'); + const labelId = useLabelId(label); + const errorMessageId = useErrorMessageId(error); + + const inputRef = useRef(); + useImperativeHandle(ref, () => ({ + focus: () => { + inputRef.current.focus(); + }, + click: () => { + inputRef.current.click(); + }, + blur: () => { + inputRef.current.blur(); + }, + })); + + useEffect(() => { + inputRef.current.files = files; + }); + + const handleClick = () => { + if (disabled) { + return; + } + inputRef.current.focus(); + inputRef.current.click(); + }; + + const handleDragEnter = event => { + if (!containsFiles(event.nativeEvent) || disabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = event => { + if (!containsFiles(event.nativeEvent) || disabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.relatedTarget.id !== buttonId && event.relatedTarget.id !== dropzoneId) { + setIsDragOver(false); + } + }; + + const handleDragOver = event => { + if (!containsFiles(event.nativeEvent) || disabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }; + + const handleDrop = event => { + if (!containsFiles(event.nativeEvent) || disabled) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setIsDragOver(false); + + const eventFiles = event.nativeEvent.dataTransfer.files; + if (!multiple && eventFiles.length > 1) { + const list = new DataTransfer(); + list.items.add(eventFiles.item(0)); + setFiles(list.files); + if (onChange) { + onChange([...list.files]); + } + return; + } + + setFiles(eventFiles); + if (onChange) { + onChange([...eventFiles]); + } + }; + + const handleChange = event => { + const eventFiles = event.target.files; + setFiles(eventFiles); + if (onChange) { + onChange([...eventFiles]); + } + }; + + const handleCancel = event => { + event.preventDefault(); + event.stopPropagation(); + + const list = new DataTransfer(); + setFiles(list.files); + if (onChange) { + onChange([...list.files]); + } + }; + + const handleFocus = event => { + setHasFocus(true); + onFocus(event); + }; + + const handleBlur = event => { + setHasFocus(false); + onBlur(event); + }; + + const getIcon = () => { + if (error) { + return ; + } + if (files && files.length === 1) { + return ; + } + if (files && files.length > 1) { + return ; + } + return ; + }; + + const getText = () => { + if (!files) { + return placeholder; + } + if (files.length === 0) { + return placeholder; + } + if (files.length === 1) { + return files[0].name; + } + return `${files.length} files`; + }; + + const isFileSelected = files && files.length > 0; + const isSingleFile = files && files.length === 1; + const shouldRenderCancel = variant === 'inline' && isFileSelected; + + return ( + + + ); +}); + +FileSelector.propTypes = { + /** A CSS class for the outer element, in addition to the component's base classes. */ + className: PropTypes.string, + /** An object with custom style applied to the outer element. */ + style: PropTypes.object, + /** The id of the outer element. */ + id: PropTypes.string, + /** The name of the input. */ + name: PropTypes.string, + /** Text label for the input. */ + label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** Specifies that an input field must be filled out before submitting the form. */ + error: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** Shows the help message below the input. */ + bottomHelpText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** Text that is displayed when the field is empty, to prompt the user for a valid entry. */ + placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** Specifies the tab order of an element (when the tab button is used for navigating). */ + tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** Specifies that an input field must be filled out before submitting the form. + * This value defaults to false. */ + required: PropTypes.bool, + /** Specifies that multiple files can be picked. */ + multiple: PropTypes.bool, + /** Specifies that an input element should be disabled. This value defaults to false. */ + disabled: PropTypes.bool, + /** Specifies that the variant of the file selector. */ + variant: PropTypes.oneOf(['inline', 'normal']), + /** A boolean to hide the input label. */ + hideLabel: PropTypes.bool, + /** A string that defines the file types the file input should accept. */ + accept: PropTypes.string, + /** The action triggered when a value attribute changes. */ + onChange: PropTypes.func, + /** The action triggered when the element receives focus. */ + onFocus: PropTypes.func, + /** The action triggered when the element releases focus. */ + onBlur: PropTypes.func, +}; + +FileSelector.defaultProps = { + className: undefined, + style: undefined, + id: undefined, + name: undefined, + label: undefined, + error: undefined, + bottomHelpText: undefined, + placeholder: 'Drag & Drop or Click to Browse', + tabIndex: undefined, + required: false, + multiple: false, + disabled: false, + variant: 'inline', + hideLabel: false, + accept: undefined, + onChange: () => {}, + onFocus: () => {}, + onBlur: () => {}, +}; + +export default FileSelector; diff --git a/src/components/FileSelector/readme.md b/src/components/FileSelector/readme.md new file mode 100644 index 000000000..01a3de902 --- /dev/null +++ b/src/components/FileSelector/readme.md @@ -0,0 +1,125 @@ +##### FileSelector inline + +```js +import React, { useState } from 'react'; +import { FileSelector } from 'react-rainbow-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUpload } from '@fortawesome/free-solid-svg-icons'; + +const containerStyles = { + maxWidth: 300, +}; + +function FileSelectorExample(props) { + const [files, setFiles] = useState([]); + + const handleChange = files => { + setFiles(files); + } + + return ( +
+ + + + + + + +
+ ); +} + + +``` + +##### FileSelector normal + +```js +import React, { useState } from 'react'; +import { FileSelector } from 'react-rainbow-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUpload } from '@fortawesome/free-solid-svg-icons'; + +const containerStyles = { + maxWidth: 300, +}; + +function FileSelectorExample(props) { + const [files, setFiles] = useState([]); + + const handleChange = files => { + setFiles(files); + } + + return ( +
+ + + + + + + +
+ ); +} + + +``` \ No newline at end of file diff --git a/src/components/FileSelector/styled/index.js b/src/components/FileSelector/styled/index.js new file mode 100644 index 000000000..759d71653 --- /dev/null +++ b/src/components/FileSelector/styled/index.js @@ -0,0 +1,194 @@ +import styled from 'styled-components'; +import attachThemeAttrs from '../../../styles/helpers/attachThemeAttrs'; +import IconContainer from '../../Input/styled/iconContainer'; +import ButtonIcon from '../../ButtonIcon'; +import { MARGIN_X_SMALL } from '../../../styles/margins'; +import { BORDER_RADIUS_2 } from '../../../styles/borderRadius'; +import { FONT_SIZE_TEXT_MEDIUM } from '../../../styles/fontSizes'; +import { PADDING_MEDIUM } from '../../../styles/paddings'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledDropzone = attachThemeAttrs(styled.div)` + position: relative; + height: 2.5rem; + padding: 4px; + border: dashed 1px ${props => props.palette.text.disabled}; + border-radius: ${BORDER_RADIUS_2}; + background-color: ${props => props.palette.background.disabled}; + color: ${props => props.palette.text.main}; + + &:hover { + cursor: pointer; + border: dashed 1px ${props => props.palette.brand.main}; + } + + ${props => + props.hasFocus && + ` + outline: 0; + border: solid 1px ${props.palette.brand.main}; + box-shadow: ${props.shadows.brand}; + `} + + ${props => + props.variant === 'normal' && + ` + height: 12rem; + border-radius: 27px; + `} + + ${props => + props.disabled && + ` + color: ${props.palette.text.disabled}; + + &:hover { + cursor: not-allowed; + border-color: ${props.palette.text.disabled} + } + `} + + ${props => + props.error && + ` + color: ${props.palette.error.main}; + border: dashed 1px ${props.palette.error.main}; + background-color: ${props.palette.error.light}; + + &:hover { + border: dashed 1px ${props.palette.error.main}; + } + + ${props.hasFocus && + ` + outline: 0; + border: solid 1px ${props.palette.error.main}; + box-shadow: ${props.shadows.error}; + `} + `} + + ${props => + props.isDragOver && + ` + color: ${props.palette.brand.main}; + background-color: ${props.palette.brand.light}; + border-color: ${props.palette.brand.main}; + `} +`; + +const TruncatedText = styled.span` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + pointer-events: none; +`; + +const StyledInput = styled.input` + position: absolute; + width: 0; + height: 0; + opacity: 0; + overflow: hidden; + z-index: -1; +`; + +const StyledBackdrop = styled.div` + position: relative; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding-left: 2.35rem; + padding-right: ${PADDING_MEDIUM}; + border-radius: ${BORDER_RADIUS_2}; + pointer-events: none; + + ${props => + props.isFileSelected && + ` + justify-content: left; + background-color: rgba(0, 0, 0, 0.1); + padding-right: 2.35rem; + `} + + ${props => + props.variant === 'normal' && + ` + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + font-size: ${FONT_SIZE_TEXT_MEDIUM}; + padding: ${PADDING_MEDIUM}; + background: transparent; + `} +`; + +const StyledIconContainer = styled(IconContainer)` + svg { + width: 24px !important; + height: 24px !important; + font-size: 24px !important; + } + + ${props => + props.isSingleFile && + props.variant === 'inline' && + ` + svg { + width: 18px !important; + height: 18px !important; + font-size: 18px !important; + } + `} + + ${props => + props.iconPosition === 'left' && + ` + left: ${props.readOnly ? 0 : '0.8rem'}; + `} + ${props => + props.iconPosition === 'right' && + ` + right: ${props.readOnly ? 0 : '0.3rem'}; + `} + + ${props => + props.variant === 'normal' && + ` + position: static; + width: 64px; + height: auto; + margin-bottom: ${MARGIN_X_SMALL}; + svg { + width: 64px !important; + height: 64px !important; + font-size: 64px !important; + } + `} +`; + +const StyledButtonIcon = styled(ButtonIcon)` + width: unset; + height: unset; + pointer-events: auto; + + svg { + pointer-events: none; + } +`; + +export { + StyledContainer, + StyledDropzone, + StyledInput, + TruncatedText, + StyledBackdrop, + StyledIconContainer, + StyledButtonIcon, +}; diff --git a/src/components/index.d.ts b/src/components/index.d.ts index 1f8ea539f..73e2f535f 100644 --- a/src/components/index.d.ts +++ b/src/components/index.d.ts @@ -30,6 +30,7 @@ export { default as Dataset } from './Dataset'; export { default as DatePicker } from './DatePicker'; export { default as DateTimePicker } from './DateTimePicker'; export { default as Drawer } from './Drawer'; +export { default as FileSelector } from './FileSelector'; export { default as GMap } from './GMap'; export { default as GoogleAddressLookup } from './GoogleAddressLookup'; export { default as ImportRecordsFlow } from './ImportRecordsFlow'; diff --git a/src/components/index.js b/src/components/index.js index 218512e27..21817856f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -28,6 +28,7 @@ export { default as Dataset } from './Dataset'; export { default as DatePicker } from './DatePicker'; export { default as DateTimePicker } from './DateTimePicker'; export { default as Drawer } from './Drawer'; +export { default as FileSelector } from './FileSelector'; export { default as GMap } from './GMap'; export { default as GoogleAddressLookup } from './GoogleAddressLookup'; export { default as ImportRecordsFlow } from './ImportRecordsFlow';