UNPKG

@utahdts/utah-design-system

Version:
187 lines (178 loc) 6.63 kB
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> ); }