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 (
+
+ );
+}
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 (
+
+
+
+
+
+ {getIcon()}
+
+ {getText()}
+
+
+ }
+ onClick={handleCancel}
+ />
+
+
+
+
+
+ {bottomHelpText}
+
+
+
+ {error}
+
+
+
+
+ );
+});
+
+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';