Skip to content
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

add input file components #351

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<InputFileProps> = (args) => <InputFile {...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,
};
Original file line number Diff line number Diff line change
@@ -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<InputFileProps> = (props) => {
const [files, setFiles] = useState<File[]>([]);

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<File>(transferFiles));
return;
}

const [theFiles, rejectedFiles] = validateFiles(
Array.from<File>(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' && <InputFileDropzone {...finalProps} />}
{variant === 'button' && <InputFileButton {...finalProps} />}
{variant === 'iconbutton' && <InputFileIconButton {...finalProps} />}
</>
);
};

InputFile.defaultProps = {
replaceOnUpload: true,
showFiles: true,
};

export default InputFile;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Box } from '@mui/material';
import React, { ChangeEvent, FC } from 'react';
import { InputFileBaseProps, InputFileProps } from './types';

const InputFileBase: FC<Partial<InputFileProps> & InputFileBaseProps> = ({
accept,
quantity,
handleFileChange,
}) => {
let theAccept = '';

if (accept) {
if (Array.isArray(accept)) {
theAccept = accept.join(',');
} else {
theAccept = accept;
}
}

return (
<Box
accept={theAccept}
component="input"
multiple={quantity === 'multiple'}
sx={{ display: 'none' }}
type="file"
onChange={(e: ChangeEvent<HTMLInputElement>) => {
handleFileChange(e);
e.target.files = null;
}}
/>
);
};

export default InputFileBase;
Original file line number Diff line number Diff line change
@@ -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<Partial<InputFileProps> & InputVariantProps> = ({
accept,
quantity,
files,
uploadFiles,
showFiles,
text,
icon,
buttonProps,
}) => (
<Stack gap={0.5} height={1} width={1}>
<Button
component="label"
startIcon={icon || <Upload />}
variant="contained"
{...((buttonProps as any) || {})}
>
{text || 'Upload'}
<InputFileBase
accept={accept}
handleFileChange={(e) => uploadFiles(e.target.files)}
quantity={quantity}
/>
</Button>
<Typography variant="caption">
{showFiles &&
files.length > 0 &&
`File${files.length > 1 ? 's' : ''}: ${files
.map((file) => file.name)
.join(', ')}`}
</Typography>
</Stack>
);

export default InputFileButton;
Original file line number Diff line number Diff line change
@@ -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<Partial<InputFileProps> & InputVariantProps> = ({
accept,
disabled,
quantity,
showFiles,
uploadFiles,
text,
icon,
files,
setFiles,
}) => {
const [dragging, setDragging] = useState(false);

const prevent = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};

const handleDragOut = (e: DragEvent<HTMLDivElement>) => {
prevent(e);

if (!disabled) {
setDragging(false);
}
};

const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
prevent(e);

if (!disabled) {
setDragging(true);
} else {
// set cursor to no-drop
e.dataTransfer.dropEffect = 'none';
}
};

const handleDrop = (e: DragEvent<HTMLDivElement>) => {
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 (
<Box
component="label"
sx={{
...styles.dropzone,
...(dragging ? styles.dropzoneActive : styles.dropzoneInactive),
...(disabled && styles.disabled),
}}
onDragEnter={prevent}
onDragLeave={handleDragOut}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Stack alignItems="center" justifyContent="center">
{icon || <Upload fontSize="large" />}

<Typography variant="h3">
{dragging
? 'Release to upload'
: text || 'Drop a file here to upload'}
</Typography>
<Box mt={1}>
<InputFileBase
accept={accept}
handleFileChange={(e) => uploadFiles(e.target.files)}
quantity={quantity}
/>

{showFiles && (
<Stack
alignItems="center"
direction="row"
flexWrap="wrap"
gap={0.5}
justifyContent="center"
mx={1}
>
{files.map((file, i) => (
<Chip
color="secondary"
key={`file-${i}`}
label={file.name}
size="small"
variant="outlined"
onDelete={handleRemoveFile(i)}
/>
))}
</Stack>
)}
</Box>
</Stack>
</Box>
);
};

export default InputFileDropzone;
Original file line number Diff line number Diff line change
@@ -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<Partial<InputFileProps> & InputVariantProps> = ({
icon,
accept,
quantity,
showFiles,
files,
uploadFiles,
}) => (
<Stack alignItems="flex-start" gap={0.5} height={1} width={1}>
<IconButton
component="label"
sx={{
color: 'white',
backgroundColor: 'primary.main',
'&:hover': {
backgroundColor: 'primary.dark',
},
}}
>
{icon || <Upload />}
<InputFileBase
accept={accept}
handleFileChange={(e) => uploadFiles(e.target.files)}
quantity={quantity}
/>
</IconButton>
<Typography variant="caption">
{showFiles &&
files.length > 0 &&
`File${files.length > 1 ? 's' : ''}: ${files
.map((file) => file.name)
.join(', ')}`}
</Typography>
</Stack>
);

export default InputFileIconButton;
Loading