import * as React from 'react'; import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils'; import clsx from 'clsx'; const DEFAULT_ENHANCED_HEIGHT_PX = 64 as const; const DEFAULT_NON_ENHANCED_SIDE_HEIGHT_PX = 256 as const; /** * Common props for the {@link FileSelectBoxProps.previewComponent|previewComponent prop} of the * {@link FileSelectBox} component. */ export interface CommonPreviewComponentProps = Partial> { /** * The file to preview. */ file?: F; /** * Is the component disabled? */ disabled?: boolean; /** * Should the component be enhanced? */ enhanced?: boolean; /** * Should the component use minimal space? */ mini?: boolean; } /** * Derived HTML element of the {@link FileSelectBox} component. */ export type FileSelectBoxDerivedElement = HTMLInputElement; /** * Props of the {@link FileSelectBox} component. */ export interface FileSelectBoxProps< F extends Partial = Partial, P extends CommonPreviewComponentProps = CommonPreviewComponentProps > extends Omit, 'size' | 'type' | 'label' | 'list'> { /** * Should the component display a border? */ border?: boolean, /** * Should the component occupy the whole width of its parent? */ block?: boolean, /** * Short textual description indicating the nature of the component's value. */ label?: React.ReactNode, /** * Short textual description as guidelines for valid input values. */ hint?: React.ReactNode, /** * Should the component be enhanced? */ enhanced?: boolean, /** * Is the label hidden? */ hiddenLabel?: boolean, /** * Preview component for the selected file(s). */ previewComponent?: React.ElementType

, /** * Reselect label. */ reselectLabel?: string, /** * Clear label. */ clearLabel?: string, } export type FileSelectBoxDefaultPreviewComponentDerivedElement = HTMLDivElement; /** * Default component for the {@link FileSelectBoxProps.previewComponent|previewComponent prop} * of the {@link FileSelectBox} component. */ export const FileSelectBoxDefaultPreviewComponent = React.forwardRef< FileSelectBoxDefaultPreviewComponentDerivedElement, CommonPreviewComponentProps >(({ file, mini, enhanced, disabled, }, forwardedRef) => (

{file?.name ?? ( File )}
{!mini && ( <> {typeof file?.type === 'string' && (
{file?.type}
)} {typeof file?.size === 'number' && (
{new Intl.NumberFormat(undefined, { style: 'unit', unit: 'kilobyte', unitDisplay: 'long', }).format(file.size)}
)} {typeof file?.lastModified === 'number' && (
)} )}
)); const isButtonElement = (el: HTMLElement): el is HTMLButtonElement => el.tagName === 'BUTTON'; const isInputElement = (el: HTMLElement): el is HTMLInputElement => el.tagName === 'INPUT'; const isKeyUpEvent = (e: React.SyntheticEvent): e is React.KeyboardEvent => e.type === 'keyup'; const isMouseUpEvent = (e: React.SyntheticEvent): e is React.MouseEvent => e.type === 'mouseup'; const isDropEvent = (e: React.SyntheticEvent): e is React.DragEvent => e.type === 'drop'; const isPasteEvent = (e: React.SyntheticEvent): e is React.ClipboardEvent => e.type === 'paste'; export const DELETE_KEYS = ['Backspace', 'Delete'] as const; type DeleteKey = typeof DELETE_KEYS[number]; export const SELECT_KEYS = ['Enter', 'Space', 'Return'] as const; type SelectKey = typeof SELECT_KEYS[number]; /** * Component for selecting files. */ export const FileSelectBox = React.forwardRef(( { label = '', hint = '', border = false, block = false, enhanced: enhancedProp = false, hiddenLabel = false, multiple = false, disabled = false, className, onChange, id: idProp, placeholder, previewComponent: FilePreviewComponent = FileSelectBoxDefaultPreviewComponent, style, onBlur, reselectLabel = 'Reselect', clearLabel = 'Clear', onPaste, ...etcProps }, forwardedRef, ) => { const { clientSide } = useClientSide({ clientSide: enhancedProp }); const [fileList, setFileList] = React.useState(); const [lastUpdated, setLastUpdated] = React.useState(); const clearFileListRef = React.useRef(false); const [aboutToClear, setAboutToClear] = React.useState(false); const [aboutToSelect, setAboutToSelect] = React.useState(false); const clearFiles = ( fileInput: FileSelectBoxDerivedElement, clearRef: React.MutableRefObject, ) => { const clearRefMut = clearRef as unknown as Record; clearRefMut.current = true; const fileInputMut = fileInput as unknown as Record; fileInputMut.value = ''; setFileList(undefined); setLastUpdated(Date.now()); }; const { defaultRef, handleChange: doSetFileList } = useProxyInput< React.ChangeEvent | React.MouseEvent | React.DragEvent | React.KeyboardEvent | React.ClipboardEvent, FileSelectBoxDerivedElement >({ forwardedRef, valueSetterFn: (e) => { const fileInput = defaultRef.current as FileSelectBoxDerivedElement; if (isMouseUpEvent(e) && isButtonElement(e.currentTarget)) { clearFiles(fileInput, clearFileListRef); return; } if (isKeyUpEvent(e) && isInputElement(e.currentTarget) && e.currentTarget === fileInput) { // delete via keyboard const { code } = e; if (DELETE_KEYS.includes(code as DeleteKey)) { clearFiles(fileInput, clearFileListRef); setAboutToClear(false); } return; } if (isDropEvent(e)) { // drop file const { dataTransfer } = e; const { files } = dataTransfer; if (files && files.length > 0) { try { fileInput.files = files; } catch { // noop, the assignment throws for test environments } setFileList(files); setLastUpdated(Date.now()); } return; } if (isPasteEvent(e)) { const { clipboardData } = e; const { files } = clipboardData; if (files && files.length > 0) { try { fileInput.files = files; } catch { // noop, the assignment throws for test environments } setFileList(files); setLastUpdated(Date.now()); } return; } // Unhandled event }, }); const labelId = React.useId(); const id = useFallbackId(idProp); const cancelEvent: React.DragEventHandler = React.useCallback((e) => { e.stopPropagation(); e.preventDefault(); }, []); const handleDrop: React.DragEventHandler = React.useCallback((e) => { cancelEvent(e); doSetFileList(e); }, [cancelEvent, doSetFileList]); const handleFileChange: React.ChangeEventHandler< FileSelectBoxDerivedElement > = React.useCallback((e) => { const { currentTarget } = e; if (currentTarget.files && currentTarget.files.length > 0) { setFileList(currentTarget.files); setLastUpdated(Date.now()); setAboutToSelect(false); } else if (clearFileListRef.current) { clearFileListRef.current = false; setFileList(undefined); setLastUpdated(Date.now()); } onChange?.(e); }, [clearFileListRef, onChange]); const handleKeyDown: React.KeyboardEventHandler< FileSelectBoxDerivedElement > = React.useCallback((e) => { const { code } = e; if (DELETE_KEYS.includes(code as DeleteKey)) { setAboutToClear(true); return; } if (SELECT_KEYS.includes(code as SelectKey)) { setAboutToSelect(true); } // ignore other keys }, []); const handleReselectMouseDown: React.MouseEventHandler< HTMLButtonElement > = React.useCallback(() => { setAboutToSelect(true); setTimeout(() => { const fileInput = defaultRef.current as FileSelectBoxDerivedElement; fileInput.focus(); }); }, [defaultRef]); const handleReselectMouseUp: React.MouseEventHandler< HTMLButtonElement > = React.useCallback(() => { const fileInput = defaultRef.current as FileSelectBoxDerivedElement; if (typeof fileInput.showPicker !== 'function') { fileInput.click(); return; } fileInput.showPicker(); }, [defaultRef]); const handleDeleteMouseDown: React.MouseEventHandler< HTMLButtonElement > = React.useCallback(() => { setAboutToClear(true); setTimeout(() => { const fileInput = defaultRef.current as FileSelectBoxDerivedElement; fileInput.focus(); }); }, [defaultRef]); const handleBlur: React.FocusEventHandler< FileSelectBoxDerivedElement > = React.useCallback((e) => { setAboutToSelect(false); setAboutToClear(false); onBlur?.(e); }, [onBlur]); React.useEffect(() => { const handleLabelMouseUp = () => { setAboutToSelect(false); setAboutToClear(false); }; window.addEventListener('mouseup', handleLabelMouseUp, { capture: true }); return () => { window.removeEventListener('mouseup', handleLabelMouseUp, { capture: true }); }; }, [defaultRef, aboutToSelect]); const filesCount = fileList?.length ?? 0; const fileListArray = Array.from(fileList ?? []); return (
{label && ( )} {clientSide && ( )} {filesCount < 1 && clientSide && hint && (
{hint}
)} {filesCount > 0 && clientSide && (
{multiple && (
{fileListArray.map((file, i) => (
))}
)} {!multiple && fileListArray.map((file, i) => (
))}
)} {border && ( )}
); }); FileSelectBox.displayName = 'FileSelectBox'; FileSelectBox.defaultProps = { border: false as const, block: false as const, enhanced: false as const, hiddenLabel: false as const, label: undefined, hint: undefined, previewComponent: FileSelectBoxDefaultPreviewComponent, reselectLabel: 'Reselect' as const, clearLabel: 'Clear' as const, }; FileSelectBoxDefaultPreviewComponent.defaultProps = { file: undefined, mini: false as const, disabled: FileSelectBox.defaultProps.disabled, enhanced: FileSelectBox.defaultProps.enhanced, };