diff --git a/packages/react-components-lab/src/components/InputFile/InputFile.stories.tsx b/packages/react-components-lab/src/components/InputFile/InputFile.stories.tsx new file mode 100644 index 00000000..6446635e --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFile.stories.tsx @@ -0,0 +1,67 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { InputFileProps } from './types'; +import InputFile from './InputFile'; + +export default { + title: 'Components/InputFile', + component: InputFile, + argTypes: { + variant: { control: 'select' }, + accept: { control: 'string' }, + quantity: { control: 'select' }, + showFiles: { control: 'boolean' }, + }, +} as Meta; + +const Template: Story = (args) => ; + +export const Dropzone = Template.bind({}); +Dropzone.args = { + variant: 'dropzone', + accept: ['.txt', '.png'], + quantity: 'multiple', + showFiles: true, +}; + +export const DropzoneNoReplace = Template.bind({}); +DropzoneNoReplace.args = { + variant: 'dropzone', + accept: ['.txt', '.png'], + quantity: 'multiple', + showFiles: true, + replaceOnUpload: false, +}; + +export const DropzoneSingle = Template.bind({}); +DropzoneSingle.args = { + variant: 'dropzone', + accept: ['.txt', '.png'], + quantity: 'single', + showFiles: true, +}; + +export const DropzoneHiddenFiles = Template.bind({}); +DropzoneHiddenFiles.args = { + variant: 'dropzone', + accept: ['.txt', '.png'], + quantity: 'multiple', + showFiles: false, +}; + +export const Button = Template.bind({}); +Button.args = { + variant: 'button', + accept: ['.txt', '.png'], + quantity: 'multiple', + showFiles: true, +}; + +export const Icon = Template.bind({}); +Icon.args = { + variant: 'iconbutton', + accept: ['.txt', '.png'], + quantity: 'multiple', + showFiles: true, +}; diff --git a/packages/react-components-lab/src/components/InputFile/InputFile.tsx b/packages/react-components-lab/src/components/InputFile/InputFile.tsx new file mode 100644 index 00000000..d7cb730f --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFile.tsx @@ -0,0 +1,77 @@ +import React, { FC, useState } from 'react'; +import InputFileButton from './InputFileButton'; +import InputFileDropzone from './InputFileDropzone'; +import InputFileIconButton from './InputFileIconButton'; +import { InputFileProps } from './types'; +import { validateFiles } from './util'; + +const InputFile: FC = (props) => { + const [files, setFiles] = useState([]); + + const { + variant, + accept, + quantity, + replaceOnUpload, + getFiles, + getRejectedFiles, + } = props; + + const uploadFiles = (transferFiles: File[] | FileList) => { + if (!transferFiles || !transferFiles.length) { + return; + } + + if (quantity === 'single' && transferFiles.length > 1) { + getRejectedFiles?.(Array.from(transferFiles)); + return; + } + + const [theFiles, rejectedFiles] = validateFiles( + Array.from(transferFiles), + accept + ); + + if (rejectedFiles.length && getRejectedFiles) { + getRejectedFiles(rejectedFiles); + } + + if (!theFiles.length) { + return; + } + + if (replaceOnUpload) { + setFiles(theFiles); + getFiles?.(theFiles); + return; + } + + setFiles((prevFiles) => { + const newFiles = [...prevFiles, ...theFiles]; + getFiles?.(newFiles); + return newFiles; + }); + }; + + const finalProps = { + uploadFiles, + files, + setFiles, + ...props, + }; + + return ( + <> + {variant === 'dropzone' && } + {variant === 'button' && } + {variant === 'iconbutton' && } + + ); +}; + +InputFile.defaultProps = { + replaceOnUpload: true, + showFiles: true, +}; + +export default InputFile; diff --git a/packages/react-components-lab/src/components/InputFile/InputFileBase.tsx b/packages/react-components-lab/src/components/InputFile/InputFileBase.tsx new file mode 100644 index 00000000..1387ca1b --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFileBase.tsx @@ -0,0 +1,35 @@ +import { Box } from '@mui/material'; +import React, { ChangeEvent, FC } from 'react'; +import { InputFileBaseProps, InputFileProps } from './types'; + +const InputFileBase: FC & InputFileBaseProps> = ({ + accept, + quantity, + handleFileChange, +}) => { + let theAccept = ''; + + if (accept) { + if (Array.isArray(accept)) { + theAccept = accept.join(','); + } else { + theAccept = accept; + } + } + + return ( + ) => { + handleFileChange(e); + e.target.files = null; + }} + /> + ); +}; + +export default InputFileBase; diff --git a/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx b/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx new file mode 100644 index 00000000..6c31dd8d --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx @@ -0,0 +1,41 @@ +import { Upload } from '@mui/icons-material'; +import { Button, Stack, Typography } from '@mui/material'; +import React, { FC } from 'react'; +import InputFileBase from './InputFileBase'; +import { InputFileProps, InputVariantProps } from './types'; + +const InputFileButton: FC & InputVariantProps> = ({ + accept, + quantity, + files, + uploadFiles, + showFiles, + text, + icon, + buttonProps, +}) => ( + + + + {showFiles && + files.length > 0 && + `File${files.length > 1 ? 's' : ''}: ${files + .map((file) => file.name) + .join(', ')}`} + + +); + +export default InputFileButton; diff --git a/packages/react-components-lab/src/components/InputFile/InputFileDropzone.tsx b/packages/react-components-lab/src/components/InputFile/InputFileDropzone.tsx new file mode 100644 index 00000000..5f36ad20 --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFileDropzone.tsx @@ -0,0 +1,119 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable react/no-array-index-key */ +import { Upload } from '@mui/icons-material'; +import { Box, Chip, Stack, Typography } from '@mui/material'; +import React, { DragEvent, FC, MouseEvent, useState } from 'react'; +import InputFileBase from './InputFileBase'; +import styles from './styles'; +import { InputFileProps, InputVariantProps } from './types'; + +const InputFileDropzone: FC & InputVariantProps> = ({ + accept, + disabled, + quantity, + showFiles, + uploadFiles, + text, + icon, + files, + setFiles, +}) => { + const [dragging, setDragging] = useState(false); + + const prevent = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragOut = (e: DragEvent) => { + prevent(e); + + if (!disabled) { + setDragging(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + prevent(e); + + if (!disabled) { + setDragging(true); + } else { + // set cursor to no-drop + e.dataTransfer.dropEffect = 'none'; + } + }; + + const handleDrop = (e: DragEvent) => { + prevent(e); + + if (disabled) { + return; + } + + setDragging(false); + uploadFiles(e.dataTransfer.files); + e.dataTransfer.clearData(); + }; + + const handleRemoveFile = (i: number) => (e: MouseEvent) => { + e.preventDefault(); + setFiles((prevFiles) => prevFiles.filter((_, index) => index !== i)); + }; + + return ( + + + {icon || } + + + {dragging + ? 'Release to upload' + : text || 'Drop a file here to upload'} + + + uploadFiles(e.target.files)} + quantity={quantity} + /> + + {showFiles && ( + + {files.map((file, i) => ( + + ))} + + )} + + + + ); +}; + +export default InputFileDropzone; diff --git a/packages/react-components-lab/src/components/InputFile/InputFileIconButton.tsx b/packages/react-components-lab/src/components/InputFile/InputFileIconButton.tsx new file mode 100644 index 00000000..8448564d --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFileIconButton.tsx @@ -0,0 +1,43 @@ +import { Upload } from '@mui/icons-material'; +import { IconButton, Stack, Typography } from '@mui/material'; +import React, { FC } from 'react'; +import InputFileBase from './InputFileBase'; +import { InputFileProps, InputVariantProps } from './types'; + +const InputFileIconButton: FC & InputVariantProps> = ({ + icon, + accept, + quantity, + showFiles, + files, + uploadFiles, +}) => ( + + + {icon || } + uploadFiles(e.target.files)} + quantity={quantity} + /> + + + {showFiles && + files.length > 0 && + `File${files.length > 1 ? 's' : ''}: ${files + .map((file) => file.name) + .join(', ')}`} + + +); + +export default InputFileIconButton; diff --git a/packages/react-components-lab/src/components/InputFile/styles.ts b/packages/react-components-lab/src/components/InputFile/styles.ts new file mode 100644 index 00000000..3984bdad --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/styles.ts @@ -0,0 +1,29 @@ +const styles = { + dropzone: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: `2px dashed`, + position: 'relative', + width: '100%', + height: '100%', + cursor: 'pointer', + }, + dropzoneActive: { + backgroundColor: 'grey.100', + borderColor: 'success.light', + border: `4px dashed`, + }, + dropzoneInactive: { + backgroundColor: 'grey.50', + borderColor: 'grey.700', + }, + disabled: { + cursor: 'no-drop', + noDrop: 'no-drop', + backgroundColor: 'grey.100', + opacity: 0.7, + }, +}; + +export default styles; diff --git a/packages/react-components-lab/src/components/InputFile/types.ts b/packages/react-components-lab/src/components/InputFile/types.ts new file mode 100644 index 00000000..4093e4e1 --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/types.ts @@ -0,0 +1,32 @@ +import { Button } from '@mui/material'; +import { + ChangeEvent, + ComponentProps, + Dispatch, + ReactNode, + SetStateAction, +} from 'react'; + +export interface InputFileProps { + variant: 'dropzone' | 'button' | 'iconbutton'; + quantity?: 'single' | 'multiple'; + accept?: string | string[]; + disabled?: boolean; + showFiles?: boolean; + replaceOnUpload?: boolean; + text?: string; + icon?: ReactNode; + getRejectedFiles?: (file: File | File[]) => void; + getFiles?: (file: File | File[]) => void; + buttonProps?: ComponentProps; +} + +export interface InputVariantProps { + files: File[]; + uploadFiles: (files: File[] | FileList) => void; + setFiles: Dispatch>; +} + +export interface InputFileBaseProps { + handleFileChange: (e: ChangeEvent) => void; +} diff --git a/packages/react-components-lab/src/components/InputFile/util.ts b/packages/react-components-lab/src/components/InputFile/util.ts new file mode 100644 index 00000000..9a3b9965 --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/util.ts @@ -0,0 +1,49 @@ +const accepts = (file: File, acceptedFiles: string | string[]) => { + if (file && acceptedFiles) { + const acceptedFilesArray = Array.isArray(acceptedFiles) + ? acceptedFiles + : acceptedFiles.split(','); + const fileName = file.name || ''; + const mimeType = (file.type || '').toLowerCase(); + const baseMimeType = mimeType.replace(/\/.*$/, ''); + + return acceptedFilesArray.some((type) => { + const validType = type.trim().toLowerCase(); + if (validType.charAt(0) === '.') { + return fileName.toLowerCase().endsWith(validType); + } + if (validType.endsWith('/*')) { + // This is something like a image/* mime type + return baseMimeType === validType.replace(/\/.*$/, ''); + } + return mimeType === validType; + }); + } + return true; +}; + +const fileAccepted = (file: File, accept: string | string[]) => + file.type === 'application/x-moz-file' || accepts(file, accept); + +const validateFiles = ( + transferFiles: File[], + accept: string | string[] | undefined +) => { + if (!accept) { + return [transferFiles, []]; + } + + const rejectedFiles = [] as File[]; + let theFiles = transferFiles; + + theFiles.forEach((file) => { + if (!fileAccepted(file, accept)) { + theFiles = theFiles.filter((f) => file.name !== f.name); + rejectedFiles.push(file); + } + }); + + return [theFiles, rejectedFiles]; +}; + +export { accepts, fileAccepted, validateFiles }; diff --git a/packages/react-components-lab/src/index.ts b/packages/react-components-lab/src/index.ts index ce4b19a4..7f63e740 100644 --- a/packages/react-components-lab/src/index.ts +++ b/packages/react-components-lab/src/index.ts @@ -38,6 +38,10 @@ export { default as GapBox } from './components/GapBox/GapBox'; export { default as GapBoxStyled } from './components/GapBox/GapBox.styled'; export * from './components/GapBox/types'; +// InputFile +export { default as InputFile } from './components/InputFile/InputFile'; +export { InputFileProps } from './components/InputFile/types'; + // LegendBase export { default as LegendBase } from './components/LegendBase/LegendBase'; export * from './components/LegendBase/types';