Skip to content
This repository was archived by the owner on Aug 27, 2026. It is now read-only.
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