import { ActionsUpload, BatchstatusOpen, NavigationClose, NotificationError, StatusCheck, } from '@repay/cactus-icons' import { border, color, colorStyle, radius, textStyle } from '@repay/cactus-theme' import PropTypes, { Validator } from 'prop-types' import React, { useRef } from 'react' import styled, { css } from 'styled-components' import { margin, MarginProps, maxWidth, MaxWidthProps, width, WidthProps } from 'styled-system' import Avatar, { AvatarStatus } from '../Avatar/Avatar' import Flex from '../Flex/Flex' import accepts from '../helpers/accept' import { CactusChangeEvent, CactusEventTarget, CactusFocusEvent, isFocusOut, } from '../helpers/events' import { omitProps, split } from '../helpers/omit' import { useRenderTrigger } from '../helpers/react' import { getStatusStyles, Status, StatusPropType } from '../helpers/status' import useId from '../helpers/useId' import { IconButton } from '../IconButton/IconButton' import Spinner from '../Spinner/Spinner' import StatusMessage from '../StatusMessage/StatusMessage' import { focusStyle, TextButton } from '../TextButton/TextButton' const FILE_TYPE_ERR = 'FileTypeError' const NOT_FOUND_ERR = 'NotFoundError' const SECURITY_ERR = 'SecurityError' const ABORT_ERR = 'AbortError' const NOT_READABLE_ERR = 'NotReadableError' const ENCODING_ERR = 'EncodingError' type FileStatus = 'unloaded' | 'loading' | 'loaded' | 'error' type Target = CactusEventTarget type WrapperProps = Omit< React.HTMLAttributes, 'onChange' | 'onDrop' | 'onFocus' | 'onBlur' > type InputProps = Pick< React.InputHTMLAttributes, 'disabled' | 'form' | 'required' | 'multiple' | 'capture' > interface CommonProps extends MarginProps, MaxWidthProps, WidthProps, InputProps, WrapperProps { name: string status?: Status | null accept?: string[] labels?: FileInfoLabels buttonText?: React.ReactNode prompt?: React.ReactNode children?: never } export interface FileInputProps extends CommonProps { value?: '' | FileObject[] onChange?: React.ChangeEventHandler onFocus?: React.FocusEventHandler onBlur?: React.FocusEventHandler } interface FileInputState { inputKey: number files: FileObject[] } interface InnerInputProps extends CommonProps, FileInputState { deleteFile: (index: number, event: React.SyntheticEvent) => void onDrop: (event: React.DragEvent) => void onChange: (event: React.ChangeEvent) => void onFocus: (event: React.FocusEvent) => void onBlur: (event: React.FocusEvent) => void } type FileInfoLabels = { [K in FileStatus | 'delete']?: string } interface FileInfoProps { disabled?: boolean labels: FileInfoLabels index: number deleteFile: (i: number, e: React.SyntheticEvent) => void fileObj: FileObject } type LoadFunc = (file: File) => Promise export interface FileObject { file: File status: FileStatus contents?: unknown error?: Error & { original?: any } errorMsg?: React.ReactNode load: (l?: LoadFunc) => Promise } interface LoadingFile extends FileObject { $promise?: Promise } const triggerMap = new WeakMap void>() const updateStatus = (fileObj: LoadingFile, newStatus: FileStatus) => { fileObj.status = newStatus delete fileObj.$promise triggerMap.get(fileObj)?.() } const loadBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { let dataURL = reader.result as string if (file.size > 250000000 && dataURL === '') { reject(getError(NOT_READABLE_ERR)) return } const prefixIndex = dataURL.indexOf('base64') if (prefixIndex >= 0) { dataURL = dataURL.slice(prefixIndex + 7) } resolve(dataURL) } reader.onerror = () => { reader.abort() reject(reader.error) } reader.readAsDataURL(file) }) } function load(this: LoadingFile, loader: LoadFunc = loadBase64): Promise { if (!(this.file instanceof File)) { throw new Error('Invalid file instance') } else if (this.status === 'error') { return Promise.reject(this.error) } else if (this.status === 'loaded') { return Promise.resolve(this.contents) } else if (this.status === 'loading' && this.$promise) { return this.$promise } updateStatus(this, 'loading') const promise = loader(this.file) Object.defineProperty(this, '$promise', { configurable: true, value: promise }) promise.then( (fileContent: unknown) => { this.contents = fileContent updateStatus(this, 'loaded') }, (error: any) => { if (error?.name && error?.message) { this.error = error } else { this.error = getError() this.error.original = error } updateStatus(this, 'error') } ) return promise } // I hate you gatsby. const fileType: any = typeof File !== 'undefined' ? File : () => undefined const baseValuePropType = PropTypes.arrayOf( PropTypes.shape({ file: PropTypes.instanceOf(fileType).isRequired, status: PropTypes.oneOf(['unloaded', 'loading', 'loaded', 'error']).isRequired, error: PropTypes.shape({ name: PropTypes.string.isRequired, message: PropTypes.string.isRequired, }) as Validator, errorMsg: PropTypes.node, }).isRequired as Validator ) const valuePropType: Validator = (props, ...args) => props.value === '' ? null : baseValuePropType(props, ...args) // Not 100% fool proof, but should be good enough. const isSameFile = (f1: File, f2: File) => f1.size === f2.size && f1.name === f2.name const DEFAULT_ERROR_MSG = 'An error occurred when reading the file.' const DEFAULT_BUTTON_TEXT = 'Select Files...' const DEFAULT_PROMPT = 'Drag files here or' const DEFAULT_LABELS = { delete: 'Delete File', error: 'Error Loading File', loading: 'Loading', loaded: 'Successful', unloaded: 'Not Loaded', } const filesAreDifferent = (left: FileObject[], right: FileObject[]) => left.length !== right.length || left.some((f, ix) => f.file !== right[ix].file) /** Handles all external-facing event & state logic */ class FileInput extends React.Component { static displayName = 'FileInput' static propTypes = { name: PropTypes.string.isRequired, status: StatusPropType, accept: PropTypes.arrayOf(PropTypes.string), disabled: PropTypes.bool, labels: PropTypes.shape({ delete: PropTypes.string, error: PropTypes.string, loading: PropTypes.string, loaded: PropTypes.string, unloaded: PropTypes.string, }), buttonText: PropTypes.node, prompt: PropTypes.node, onChange: PropTypes.func, onFocus: PropTypes.func, onBlur: PropTypes.func, multiple: PropTypes.bool, value: valuePropType, } private hasFocus = false private eventTarget = new CactusEventTarget({}) public state: FileInputState = { inputKey: 0, files: [] } static getDerivedStateFromProps( props: Readonly, state: FileInputState ): Partial | null { if (props.value !== undefined && props.value !== state.files) { const files = !Array.isArray(props.value) ? [] : props.value if (files.length || state.files.length) { const newState: Partial = { files } // Can't set the value/FileList of a file input, so the only option is to remount it. if (filesAreDifferent(files, state.files)) { newState.inputKey = state.inputKey + 1 } return newState } } return null } private deleteFile = (index: number, event: React.SyntheticEvent) => { const files = [...this.state.files] files.splice(index, 1) this.setState({ inputKey: Math.random(), files }) this.raiseChange(files, event) } private handleDrop = (event: React.DragEvent) => { trapEvent(event) if (this.props.disabled) return let files = this.state.files const isUnique = (f: File) => !files.some(({ file }) => isSameFile(file, f)) const newFiles = Array.from(event.dataTransfer.files).filter(isUnique).map(this.toFileObj) if (newFiles.length) { files = this.props.multiple ? files.concat(newFiles) : newFiles.slice(0, 1) this.setState({ files }) this.raiseChange(files, event) } } private handleChange = (event: React.ChangeEvent) => { event.stopPropagation() const inFiles = event.target.files const files = inFiles ? Array.from(inFiles).map(this.toFileObj) : [] this.setState({ files }) this.raiseChange(files, event) } private syncTarget(files: FileObject[] = this.state.files) { this.eventTarget.id = this.props.id this.eventTarget.name = this.props.name this.eventTarget.value = files return this.eventTarget } private raiseChange(files: FileObject[], event: React.SyntheticEvent) { const changeHandler = this.props.onChange if (typeof changeHandler === 'function') { const cactusEvent = new CactusChangeEvent(this.syncTarget(files), event) changeHandler(cactusEvent) } } private handleFocus = (event: React.FocusEvent) => { event.stopPropagation() if (!this.hasFocus) { this.hasFocus = true const focusHandler = this.props.onFocus if (typeof focusHandler === 'function') { const cactusEvent = new CactusFocusEvent('focus', this.syncTarget(), event) focusHandler(cactusEvent as React.FocusEvent) } } } private handleBlur = (event: React.FocusEvent) => { event.stopPropagation() if (isFocusOut(event)) { this.hasFocus = false const blurHandler = this.props.onBlur if (typeof blurHandler === 'function') { const cactusEvent = new CactusFocusEvent('blur', this.syncTarget(), event) blurHandler(cactusEvent as React.FocusEvent) } } } render(): React.ReactElement { const { value, onChange, onFocus, onBlur, ...props } = this.props return ( ) } private toFileObj = (file: File) => FileInput.toFileObj(file, this.props.accept) static toFileObj(file: File, accept?: string[]): FileObject { const fileObj: FileObject = { file, load, status: 'unloaded' } if (accept?.length && !accepts(file, accept)) { fileObj.error = getError(FILE_TYPE_ERR, accept) fileObj.status = 'error' } return fileObj } static toString(): string { return InnerFileInput.toString() } } const EmptyPrompts = styled.div` width: 100%; height: 100px; margin: 0 15%; display: flex; align-items: center; justify-content: center; color: ${color('mediumContrast')}; ` const fileBoxColors = { unloaded: css` border-color: ${color('mediumContrast')}; `, loading: css` background-color: ${color('lightContrast')}; `, loaded: css` border-color: ${color('success')}; background-color: ${color('successLight')}; `, error: css` border-color: ${color('error')}; background-color: ${color('errorLight')}; `, } const FileBox = styled.div<{ $status: FileStatus }>` box-sizing: border-box; width: 100%; margin-top: 8px; padding: 4px 8px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; color: ${color('darkestContrast')}; border: ${border('lightContrast')}; ${(p) => fileBoxColors[p.$status]}; &[aria-disabled] { color: ${color('mediumGray')}; border: ${border('mediumGray')}; background-color: ${color('lightGray')}; } span { flex-grow: 1; margin-left: 8px; margin-right: 8px; max-width: calc(100% - 52px); /* margins + avatar + button */ overflow-wrap: break-word; word-wrap: break-word; } ${Avatar} { height: 24px; width: 24px; flex-shrink: 0; ${NotificationError} { height: 16px; width: 16px; } ${StatusCheck} { height: 18px; width: 18px; } } ${IconButton}, ${Spinner} { padding: 0; flex-shrink: 0; } ${NavigationClose}, ${Spinner} { height: 12px; width: 12px; } ` const getLabel = (fileObj: FileObject, labels: FileInfoLabels): [string, AvatarStatus] => { let label = fileObj.file.name let avatarStatus: AvatarStatus = 'info' if (fileObj.status === 'unloaded') { label += `, ${labels.unloaded || DEFAULT_LABELS.unloaded}` } else if (fileObj.status === 'loaded') { label += `, ${labels.loaded || DEFAULT_LABELS.loaded}` avatarStatus = 'success' } else if (fileObj.status === 'error') { label += `, ${labels.error || DEFAULT_LABELS.error}` avatarStatus = 'error' } else if (fileObj.status === 'loading') { label += `, ${labels.loading || DEFAULT_LABELS.loading}` } return [label, avatarStatus] } const FileInfo: React.FC = (props) => { const { disabled, labels = {}, index, deleteFile, fileObj } = props // Attempt to track status changes with file loading. This will only work if the caller // (a) uses the provided `load()` method; AND (b) either does not copy the `fileObj`, // OR copies it but passes the new object in via the FileInput's `value` prop. const trigger = useRenderTrigger() React.useEffect(() => { triggerMap.set(fileObj, trigger) return () => { // Not sure if React allows this, but double-check in case another effect // in the same render cycle overwrote this (reordered files). if (triggerMap.get(fileObj) === trigger) { triggerMap.delete(fileObj) } } }, [fileObj, trigger]) const statusId = useId(undefined, 'file-status') const errorMsg = !disabled && (fileObj.errorMsg || fileObj.error?.message) const status = fileObj.status const [label, avatarStatus] = getLabel(fileObj, labels) const onDelete = React.useCallback( (event: React.MouseEvent) => deleteFile(index, event), [deleteFile, index] ) return ( <> {avatarStatus && } {fileObj.file.name} {status === 'loading' ? ( ) : ( )} {errorMsg && ( {errorMsg} )} ) } const getError = (errName = 'Error', accept?: string[]) => { let errorMsg = DEFAULT_ERROR_MSG switch (errName) { case FILE_TYPE_ERR: const acceptable = accept?.join(', ') errorMsg = `The file provided does not match any of the accepted file types: ${acceptable}.` break case NOT_FOUND_ERR: errorMsg = 'The file provided could not be found. Please try again.' break case SECURITY_ERR: errorMsg = 'The file could not be read due to security restrictions.' break case ABORT_ERR: errorMsg = 'The file read operation was aborted. Please try again.' break case NOT_READABLE_ERR: errorMsg = 'The file read operation failed: the file may be too large. Please try again or select a different file.' break case ENCODING_ERR: errorMsg = 'The encoding or decoding operation failed. Please try again.' break } const error = new Error(errorMsg) error.name = errName return error } function trapEvent(e: React.DragEvent) { e.preventDefault() e.stopPropagation() } const FileInputBase = (props: InnerInputProps): React.ReactElement => { const { labels = DEFAULT_LABELS, buttonText, prompt, deleteFile, files, accept, disabled, inputKey, ...rest } = props // aria-hidden & aria-disabled are inherited, so we leave them on the wrapper. const inputProps = split( rest, 'aria-describedby', 'aria-details', 'aria-errormessage', 'aria-invalid', 'aria-labelledby', 'aria-label', 'aria-placeholder', 'aria-required', 'capture', 'form', 'id', 'multiple', 'name', 'onChange', 'required' ) const fileSelector = useRef(null) const handleOpenFileSelect = React.useCallback( (event) => { event.preventDefault() if (fileSelector.current) { fileSelector.current.click() } }, [fileSelector] ) const openFileButton = ( {buttonText || DEFAULT_BUTTON_TEXT} ) return (
{files.length === 0 ? ( {prompt || DEFAULT_PROMPT} {openFileButton} ) : ( <> {files.map( (fileObj, index): React.ReactElement => ( ) )} {openFileButton} )}
) } const InnerFileInput = styled(FileInputBase).withConfig( omitProps(margin, width, maxWidth, 'status') )` box-sizing: border-box; border-radius: ${radius(8)}; border: 2px dotted ${color('darkestContrast')}; min-width: 300px; max-width: 100%; min-height: 100px; position: relative; display: inline-flex; justify-content: center; align-items: center; ${textStyle('body')}; ${(p) => !!p.files.length && ` flex-direction: column; border-color: ${color(p, 'callToAction')}; padding: 0 8px; ${TextButton} { position: relative; margin: 16px 0 16px 0; } `} ${getStatusStyles} ${colorStyle('standard')}; &[aria-disabled] { border-style: solid; border-color: ${color('lightGray')}; background-color: ${color('lightGray')}; * { color: ${color('mediumGray')}; } } ${StatusMessage} { margin-top: 4px; align-self: flex-start; } input { position: absolute; width: 0; height: 0; opacity: 0; z-index: -100; } /* When the input is focused, show visual focus on the button */ input:focus ~ ${TextButton}, input:focus ~ * ${TextButton} { ${focusStyle} } ${margin} ${width} ${maxWidth} ` export default FileInput