@utahdts/utah-design-system
Version:
Utah Design System React Library
187 lines (178 loc) • 6.63 kB
JSX
import { useImmer } from 'use-immer';
import {
useCallback, useEffect, useRef
} from 'react';
import { isEqual, isFunction } from 'lodash-es';
import { RequiredStar } from './RequiredStar';
import { joinClassNames } from '../../util/joinClassNames';
import { Tag } from '../buttons/Tag';
import { useAriaMessaging } from '../../contexts/UtahDesignSystemContext/hooks/useAriaMessaging';
import { ErrorMessage } from './ErrorMessage';
/**
* @param {object} props
* @param {string} [props.acceptedFileTypes]
* @param {(file: File, removeFile: (file: File, event?: import('react').MouseEvent<HTMLButtonElement, MouseEvent> | null) => void) => React.ReactNode} [props.children]
* @param {string} [props.className]
* @param {string} [props.errorMessage]
* @param {string} [props.hint]
* @param {string} props.id
* @param {import('react').Ref<HTMLDivElement>} [props.innerRef]
* @param {boolean} [props.isDisabled]
* @param {boolean} [props.isRequired]
* @param {string} props.label
* @param {boolean} [props.multiple]
* @param {string} [props.name]
* @param {(files: FileList | null, event: import('react').ChangeEvent<HTMLInputElement> | import('react').MouseEvent<HTMLButtonElement, MouseEvent> | null | undefined) => void} [props.onChange]
* @param {FileList} [props.value]
* @returns {import('react').JSX.Element}
*/
export function FileInput({
acceptedFileTypes,
children,
className,
errorMessage,
hint,
id,
innerRef,
isDisabled,
isRequired,
label,
multiple,
name,
onChange,
value,
}) {
const { addPoliteMessage } = useAriaMessaging();
const lf = new Intl.ListFormat('en');
const [isDragged, setDragged] = useImmer(false);
const [files, setFiles] = useImmer(value || null);
const inputRef = useRef(/** @type {HTMLInputElement | null} */(null));
const checkFiles = useCallback((/** @type {FileList | null | undefined} */ filesList) => {
let allowed = true;
if (acceptedFileTypes && files) {
// Get the list of file extensions allowed
// e.g. image/png OR .png
const types = acceptedFileTypes.split(/[,/]/).map((type) => type.trim().split('.').join(''));
[...filesList || []].forEach((file) => {
// Get file(s) extension
const fileType = /\.[0-9a-z]+$/i.exec(file.name)?.[0].split('.').join('');
if (fileType && !types.includes(fileType)) {
allowed = false;
addPoliteMessage('File type not accepted.');
}
});
}
return allowed;
}, [acceptedFileTypes]);
const currentOnChange = useCallback((/** @type {import('react').ChangeEvent<HTMLInputElement> | import('react').MouseEvent<HTMLButtonElement, MouseEvent> | null | undefined} */ event) => {
if (checkFiles(inputRef.current?.files)) {
onChange?.(inputRef.current?.files || null, event);
setFiles(inputRef.current?.files || null);
}
}, [acceptedFileTypes]);
const removeFile = useCallback((/** @type {File} */ file, /** @type {import('react').MouseEvent<HTMLButtonElement, MouseEvent> | null | undefined} */ event) => {
const currentFiles = [...inputRef.current?.files || []];
const fileIndex = currentFiles.findIndex((item) => isEqual(file, item));
if (fileIndex !== -1) {
currentFiles.splice(fileIndex, 1);
// Create new FileList
const dataTransfer = new DataTransfer();
currentFiles.forEach((item) => dataTransfer.items.add(item));
if (inputRef.current) inputRef.current.files = dataTransfer.files;
currentOnChange(event);
}
}, [currentOnChange]);
useEffect(() => {
if (files?.length) {
addPoliteMessage(`You have selected the file${files.length > 1 ? 's' : ''}: ${lf.format([...files].map((item) => item.name))}.`);
} else {
addPoliteMessage('No file selected.');
}
}, [files]);
let ariaDescribedBy = '';
if (errorMessage) {
ariaDescribedBy = `${id}-error`;
} else if (hint) {
ariaDescribedBy = `${id}-hint`;
}
return (
<div className={joinClassNames('input-wrapper', className)} ref={innerRef}>
<label htmlFor={id}>
{label}
{isRequired ? <RequiredStar /> : null}
</label>
{
hint
? (
<div className="info-box file-input__info-box my-spacing-xs">
<div className="info-box__content" id={`hint__${id}`}>
{hint}
</div>
</div>
)
: null
}
<div className={joinClassNames(
'file-input__box',
isDragged ? 'file-input__box--dragged' : '',
isDisabled ? 'file-input__box--disabled' : ''
)}
>
<div className="file-input__safari" />
{
!files?.length
? (
<div className="file-input__instructions">
<span>Drag {multiple ? 'files' : 'a file'} here or click to upload</span>
</div>
)
: ''
}
<input
accept={acceptedFileTypes}
aria-describedby={ariaDescribedBy}
disabled={isDisabled}
id={id}
multiple={multiple}
name={name || id}
onChange={currentOnChange}
onDragEnter={() => setDragged(true)}
onDragLeave={() => setDragged(false)}
onDrop={() => setDragged(false)}
ref={inputRef}
type="file"
/>
{
files?.length
? (
<div className="file-input__file-selected">
<div className="flex justify-between items-center">
<span className="font-bold mr-spacing">{files.length} file{files.length > 1 ? 's' : ''} selected</span>
<span>Change file{files.length > 1 ? 's' : ''}</span>
</div>
<hr />
<div className="file-input__file-list flex-wrap">
{[...files].map((/** @type {File} */ file) => (
isFunction(children)
? <div key={file.name}>{children(file, removeFile)}</div>
: (
<Tag
clearMessage={`Remove file: ${file.name}.`}
isDisabled={isDisabled}
key={file.name}
onClear={(event) => removeFile(file, event)}
>
{file.name}
</Tag>
)
))}
</div>
</div>
)
: ''
}
</div>
<ErrorMessage errorMessage={errorMessage} id={id} />
</div>
);
}