- 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<F extends Partial<File> = Partial<File>> {
- /**
- * 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<File> = Partial<File>,
- P extends CommonPreviewComponentProps<F> = CommonPreviewComponentProps<F>
- > extends Omit<React.HTMLProps<FileSelectBoxDerivedElement>, '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<P>,
- /**
- * 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) => (
- <div
- data-enhanced={enhanced}
- className={clsx({
- 'opacity-50': disabled,
- })}
- ref={forwardedRef}
- >
- <div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">
- {file?.name ?? (
- <span className="opacity-50">
- File
- </span>
- )}
- </div>
- {!mini && (
- <>
- {typeof file?.type === 'string' && (
- <div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">{file?.type}</div>
- )}
- {typeof file?.size === 'number' && (
- <div
- title={new Intl.NumberFormat(undefined, {
- style: 'unit',
- unit: 'byte',
- unitDisplay: 'long',
- }).format(file.size)}
- className="w-full whitespace-nowrap overflow-hidden text-ellipsis tabular-nums"
- >
- {new Intl.NumberFormat(undefined, {
- style: 'unit',
- unit: 'kilobyte',
- unitDisplay: 'long',
- }).format(file.size)}
- </div>
- )}
- {typeof file?.lastModified === 'number' && (
- <div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">
- <time dateTime={new Date(file.lastModified).toISOString()}>
- {new Date(file.lastModified).toDateString()}
- </time>
- </div>
- )}
- </>
- )}
- </div>
- ));
-
- 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<FileSelectBoxDerivedElement, FileSelectBoxProps>((
- {
- 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<FileList>();
- const [lastUpdated, setLastUpdated] = React.useState<number>();
- const clearFileListRef = React.useRef(false);
- const [aboutToClear, setAboutToClear] = React.useState(false);
- const [aboutToSelect, setAboutToSelect] = React.useState(false);
-
- const clearFiles = (
- fileInput: FileSelectBoxDerivedElement,
- clearRef: React.MutableRefObject<boolean>,
- ) => {
- const clearRefMut = clearRef as unknown as Record<string, boolean>;
- clearRefMut.current = true;
-
- const fileInputMut = fileInput as unknown as Record<string, string>;
- fileInputMut.value = '';
-
- setFileList(undefined);
- setLastUpdated(Date.now());
- };
-
- const { defaultRef, handleChange: doSetFileList } = useProxyInput<
- React.ChangeEvent<FileSelectBoxDerivedElement>
- | React.MouseEvent<HTMLButtonElement>
- | React.DragEvent<HTMLDivElement>
- | React.KeyboardEvent<FileSelectBoxDerivedElement>
- | React.ClipboardEvent<FileSelectBoxDerivedElement>,
- 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<HTMLDivElement> = React.useCallback((e) => {
- e.stopPropagation();
- e.preventDefault();
- }, []);
-
- const handleDrop: React.DragEventHandler<HTMLDivElement> = 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 (
- <div
- className={clsx(
- 'relative rounded ring-secondary/50 group file-select-box',
- 'focus-within:ring-4',
- block && 'flex w-full',
- !block && 'inline-flex w-64 min-h-16 justify-center items-center',
- className,
- )}
- onDragEnter={clientSide ? cancelEvent : undefined}
- onDragOver={clientSide ? cancelEvent : undefined}
- onDrop={clientSide ? handleDrop : undefined}
- data-testid="root"
- style={{
- height: clientSide ? DEFAULT_ENHANCED_HEIGHT_PX : undefined,
- ...(style ?? {}),
- }}
- >
- {label && (
- <label
- data-testid="label"
- id={labelId}
- htmlFor={id}
- className={clsx(
- 'absolute z-[1] w-full top-0.5 left-0 pointer-events-none select-none pl-1 text-xxs font-bold peer-disabled:opacity-50 group-focus-within:text-secondary text-primary leading-none bg-negative',
- {
- 'sr-only': hiddenLabel,
- },
- )}
- >
- <div className="w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
- {label}
- </div>
- </label>
- )}
- {clientSide && (
- <button
- type="button"
- disabled={disabled}
- tabIndex={-1}
- className={clsx(
- 'flex items-center focus:outline-0 justify-center absolute top-0 left-0 w-full h-full cursor-pointer select-none',
- (fileList?.length ?? 0) > 0 && 'opacity-0',
- )}
- data-testid="clickArea"
- onMouseDown={handleReselectMouseDown}
- onMouseUp={handleReselectMouseUp}
- >
- {placeholder}
- </button>
- )}
- <input
- {...etcProps}
- id={id}
- disabled={disabled}
- ref={defaultRef}
- type="file"
- onBlur={handleBlur}
- onKeyDown={clientSide ? handleKeyDown : undefined}
- onKeyUp={clientSide ? doSetFileList : undefined}
- className={clsx(
- 'peer box-border focus:outline-0 px-4 pt-2 block resize-y min-h-16 cursor-pointer disabled:cursor-not-allowed file:bg-transparent file:text-primary file:block file:font-bold file:font-semi-expanded file:uppercase file:p-0 file:border-0 group-focus-within:file:text-secondary',
- {
- 'sr-only': clientSide,
- 'h-full w-full': !clientSide,
- },
- )}
- onChange={clientSide ? handleFileChange : onChange}
- onPaste={clientSide ? doSetFileList : onPaste}
- multiple={multiple}
- data-testid="input"
- aria-labelledby={label ? `${labelId}` : undefined}
- style={{
- height: clientSide ? undefined : DEFAULT_NON_ENHANCED_SIDE_HEIGHT_PX,
- }}
- />
- {filesCount < 1
- && clientSide
- && hint
- && (
- <div
- data-testid="hint"
- className="absolute top-0 left-0 w-full h-full pointer-events-none box-border overflow-hidden pt-4"
- >
- <div className="flex items-center justify-center w-full h-full">
- {hint}
- </div>
- </div>
- )}
- {filesCount > 0
- && clientSide
- && (
- <React.Fragment
- key={lastUpdated}
- >
- <div
- className="sm:absolute top-0 left-0 w-full h-full pointer-events-none pb-12 box-border overflow-hidden pt-8"
- data-testid="preview"
- >
- {multiple
- && (
- <div className="pointer-events-auto w-full h-full overflow-auto px-4 box-border">
- <div className="w-full grid gap-2 grid-cols-3">
- {fileListArray.map((file, i) => (
- <div
- key={file.name ?? i}
- data-testid="selectedFileItem"
- className="w-full p-2 aspect-square rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10"
- >
- <FilePreviewComponent
- file={file}
- enhanced={clientSide}
- disabled={disabled}
- mini
- />
- </div>
- ))}
- </div>
- </div>
- )}
- {!multiple
- && fileListArray.map((file, i) => (
- <div
- key={file.name ?? i}
- className="pointer-events-auto w-full h-full px-4 box-border"
- >
- <div
- data-testid="selectedFileItem"
- className="h-full w-full p-4 box-border rounded overflow-hidden relative before:absolute before:content-[''] before:bg-current before:top-0 before:left-0 before:w-full before:h-full before:opacity-10"
- >
- <div
- className="w-full h-full relative"
- >
- <FilePreviewComponent
- file={file}
- enhanced={clientSide}
- disabled={disabled}
- />
- </div>
- </div>
- </div>
- ))}
- </div>
- <div
- data-testid="actions"
- className="absolute bottom-0 left-0 w-full text-center h-12 box-border flex"
- >
- <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
- <button
- type="button"
- data-testid="reselect"
- disabled={disabled}
- onMouseDown={handleReselectMouseDown}
- onMouseUp={handleReselectMouseUp}
- tabIndex={-1}
- className={clsx(
- 'flex w-full h-full focus:outline-0 bg-negative text-primary cursor-pointer items-center justify-center leading-none gap-4 select-none',
- {
- 'group-focus-within:text-secondary group-focus-within:active:text-tertiary': !aboutToSelect,
- 'text-tertiary': aboutToSelect,
- },
- )}
- >
- <span
- className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
- >
- {reselectLabel}
- </span>
- </button>
- </div>
- <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
- <button
- disabled={disabled}
- data-testid="clear"
- type="button"
- name="action"
- value="clear"
- onMouseDown={handleDeleteMouseDown}
- onMouseUp={doSetFileList}
- tabIndex={-1}
- className={clsx(
- 'flex w-full h-full bg-negative text-primary disabled:text-primary items-center justify-center leading-none gap-4 select-none focus:outline-0',
- {
- 'group-focus-within:text-secondary group-focus-within:active:text-tertiary': !aboutToClear,
- 'text-tertiary': aboutToClear,
- },
- )}
- >
- <span
- className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
- >
- {clearLabel}
- </span>
- </button>
- </div>
- </div>
- </React.Fragment>
- )}
- {border && (
- <span
- data-testid="border"
- className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
- />
- )}
- </div>
- );
- });
-
- 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,
- };
|