From f3b04a4d6c2ddc39daad674632dc12a9c97fb95a Mon Sep 17 00:00:00 2001 From: DenizYil Date: Tue, 6 Jun 2023 19:14:15 +0200 Subject: [PATCH 1/3] add input file components --- .../src/components/InputFile/InputFile.tsx | 66 +++++++++++ .../components/InputFile/InputFileBase.tsx | 35 ++++++ .../components/InputFile/InputFileButton.tsx | 40 +++++++ .../InputFile/InputFileDropzone.tsx | 112 ++++++++++++++++++ .../InputFile/InputFileIconButton.tsx | 43 +++++++ .../src/components/InputFile/styles.ts | 29 +++++ .../src/components/InputFile/types.ts | 25 ++++ .../src/components/InputFile/util.ts | 49 ++++++++ packages/react-components-lab/src/index.ts | 3 + 9 files changed, 402 insertions(+) create mode 100644 packages/react-components-lab/src/components/InputFile/InputFile.tsx create mode 100644 packages/react-components-lab/src/components/InputFile/InputFileBase.tsx create mode 100644 packages/react-components-lab/src/components/InputFile/InputFileButton.tsx create mode 100644 packages/react-components-lab/src/components/InputFile/InputFileDropzone.tsx create mode 100644 packages/react-components-lab/src/components/InputFile/InputFileIconButton.tsx create mode 100644 packages/react-components-lab/src/components/InputFile/styles.ts create mode 100644 packages/react-components-lab/src/components/InputFile/types.ts create mode 100644 packages/react-components-lab/src/components/InputFile/util.ts 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..cdb0fbe9 --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFile.tsx @@ -0,0 +1,66 @@ +import React, { FC, useState } from 'react'; +import { InputFileProps } from './types'; +import { validateFiles } from './util'; +import InputFileDropzone from './InputFileDropzone'; +import InputFileButton from './InputFileButton'; +import InputFileIconButton from './InputFileIconButton'; + +const InputFile: FC = (props) => { + const [files, setFiles] = useState([]); + + const { variant, accept, onFilesRejected, onFilesUploaded, replaceOnUpload } = + props; + + const uploadFiles = (transferFiles: File[] | FileList) => { + if (!transferFiles || !transferFiles.length) { + return; + } + + const [theFiles, rejectedFiles] = validateFiles( + Array.from(transferFiles), + accept + ); + + if (rejectedFiles.length && onFilesRejected) { + onFilesRejected(rejectedFiles); + } + + if (!theFiles.length) { + return; + } + + if (replaceOnUpload) { + setFiles(theFiles); + onFilesUploaded?.(theFiles); + return; + } + + setFiles((prevFiles) => { + const newFiles = [...prevFiles, ...theFiles]; + onFilesUploaded?.(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..fa5cd78e --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx @@ -0,0 +1,40 @@ +import { Upload } from '@mui/icons-material'; +import { Button, Stack, Typography } from '@mui/material'; +import React, { FC } from 'react'; +import { InputFileProps, InputVariantProps } from './types'; +import InputFileBase from './InputFileBase'; + +const InputFileButton: FC & InputVariantProps> = ({ + accept, + quantity, + buttonVariant, + files, + uploadFiles, + showFiles, + text, + icon, +}) => ( + + + + {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..6cac5167 --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/InputFileDropzone.tsx @@ -0,0 +1,112 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable react/no-array-index-key */ +import { Clear, FileUpload } from '@mui/icons-material'; +import { Box, IconButton, Stack, Typography } from '@mui/material'; +import React, { DragEvent, FC, MouseEvent, useState } from 'react'; +import { InputFileProps, InputVariantProps } from './types'; +import styles from './styles'; +import InputFileBase from './InputFileBase'; + +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) => ( + + + + + {file.name} + + ))} + + + + ); +}; + +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..376413a1 --- /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 { InputFileProps, InputVariantProps } from './types'; +import InputFileBase from './InputFileBase'; + +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..6fcc5e8f --- /dev/null +++ b/packages/react-components-lab/src/components/InputFile/types.ts @@ -0,0 +1,25 @@ +import { ChangeEvent, Dispatch, ReactNode, SetStateAction } from 'react'; + +export interface InputFileProps { + variant: 'dropzone' | 'button' | 'iconbutton'; + buttonVariant?: 'contained' | 'outlined'; + quantity?: 'single' | 'multiple'; + accept?: string | string[]; + disabled?: boolean; + showFiles?: boolean; + replaceOnUpload?: boolean; + text?: string; + icon?: ReactNode; + onFilesRejected?: (file: File | File[]) => void; + onFilesUploaded?: (file: File | File[]) => void; +} + +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..55e9071d 100644 --- a/packages/react-components-lab/src/index.ts +++ b/packages/react-components-lab/src/index.ts @@ -38,6 +38,9 @@ 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'; + // LegendBase export { default as LegendBase } from './components/LegendBase/LegendBase'; export * from './components/LegendBase/types'; From 08b2a4bc10c5fb33cab57ccfbd9572a15510d1d9 Mon Sep 17 00:00:00 2001 From: DenizYil Date: Wed, 7 Jun 2023 13:32:38 +0200 Subject: [PATCH 2/3] add stories --- .../InputFile/InputFile.stories.tsx | 67 +++++++++++++++++++ .../src/components/InputFile/InputFile.tsx | 15 ++++- packages/react-components-lab/src/index.ts | 1 + 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 packages/react-components-lab/src/components/InputFile/InputFile.stories.tsx 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 index cdb0fbe9..609eeae1 100644 --- a/packages/react-components-lab/src/components/InputFile/InputFile.tsx +++ b/packages/react-components-lab/src/components/InputFile/InputFile.tsx @@ -8,14 +8,25 @@ import InputFileIconButton from './InputFileIconButton'; const InputFile: FC = (props) => { const [files, setFiles] = useState([]); - const { variant, accept, onFilesRejected, onFilesUploaded, replaceOnUpload } = - props; + const { + variant, + accept, + quantity, + replaceOnUpload, + onFilesRejected, + onFilesUploaded, + } = props; const uploadFiles = (transferFiles: File[] | FileList) => { if (!transferFiles || !transferFiles.length) { return; } + if (quantity === 'single' && transferFiles.length > 1) { + onFilesRejected?.(Array.from(transferFiles)); + return; + } + const [theFiles, rejectedFiles] = validateFiles( Array.from(transferFiles), accept diff --git a/packages/react-components-lab/src/index.ts b/packages/react-components-lab/src/index.ts index 55e9071d..7f63e740 100644 --- a/packages/react-components-lab/src/index.ts +++ b/packages/react-components-lab/src/index.ts @@ -40,6 +40,7 @@ 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'; From 45149e73d90221ebf1ff8a865102cf950a3a2241 Mon Sep 17 00:00:00 2001 From: DenizYil Date: Mon, 12 Jun 2023 11:09:07 +0200 Subject: [PATCH 3/3] rename methods, add buttonProps prop, use chips instead of custom components --- .../src/components/InputFile/InputFile.tsx | 20 ++++----- .../components/InputFile/InputFileButton.tsx | 7 +-- .../InputFile/InputFileDropzone.tsx | 43 +++++++++++-------- .../InputFile/InputFileIconButton.tsx | 2 +- .../src/components/InputFile/types.ts | 15 +++++-- 5 files changed, 51 insertions(+), 36 deletions(-) diff --git a/packages/react-components-lab/src/components/InputFile/InputFile.tsx b/packages/react-components-lab/src/components/InputFile/InputFile.tsx index 609eeae1..d7cb730f 100644 --- a/packages/react-components-lab/src/components/InputFile/InputFile.tsx +++ b/packages/react-components-lab/src/components/InputFile/InputFile.tsx @@ -1,9 +1,9 @@ import React, { FC, useState } from 'react'; -import { InputFileProps } from './types'; -import { validateFiles } from './util'; -import InputFileDropzone from './InputFileDropzone'; 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([]); @@ -13,8 +13,8 @@ const InputFile: FC = (props) => { accept, quantity, replaceOnUpload, - onFilesRejected, - onFilesUploaded, + getFiles, + getRejectedFiles, } = props; const uploadFiles = (transferFiles: File[] | FileList) => { @@ -23,7 +23,7 @@ const InputFile: FC = (props) => { } if (quantity === 'single' && transferFiles.length > 1) { - onFilesRejected?.(Array.from(transferFiles)); + getRejectedFiles?.(Array.from(transferFiles)); return; } @@ -32,8 +32,8 @@ const InputFile: FC = (props) => { accept ); - if (rejectedFiles.length && onFilesRejected) { - onFilesRejected(rejectedFiles); + if (rejectedFiles.length && getRejectedFiles) { + getRejectedFiles(rejectedFiles); } if (!theFiles.length) { @@ -42,13 +42,13 @@ const InputFile: FC = (props) => { if (replaceOnUpload) { setFiles(theFiles); - onFilesUploaded?.(theFiles); + getFiles?.(theFiles); return; } setFiles((prevFiles) => { const newFiles = [...prevFiles, ...theFiles]; - onFilesUploaded?.(newFiles); + getFiles?.(newFiles); return newFiles; }); }; diff --git a/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx b/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx index fa5cd78e..6c31dd8d 100644 --- a/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx +++ b/packages/react-components-lab/src/components/InputFile/InputFileButton.tsx @@ -1,24 +1,25 @@ import { Upload } from '@mui/icons-material'; import { Button, Stack, Typography } from '@mui/material'; import React, { FC } from 'react'; -import { InputFileProps, InputVariantProps } from './types'; import InputFileBase from './InputFileBase'; +import { InputFileProps, InputVariantProps } from './types'; const InputFileButton: FC & InputVariantProps> = ({ accept, quantity, - buttonVariant, files, uploadFiles, showFiles, text, icon, + buttonProps, }) => (