|
- import * as React from 'react';
- import { tailwind } from '@tesseract-design/web-base'
- import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils';
-
- const { tw } = tailwind;
-
- const digitalUnits = [
- 'byte',
- 'kilobyte',
- 'megabyte',
- 'gigabyte',
- 'terabyte',
- 'petabyte',
- 'exabyte',
- 'zettabyte',
- 'yottabyte',
- 'brontobyte',
- ] as const;
-
- const getCompactDigitalUnitValue = (byteCount: number) => {
- // kibibytes, mebibytes...
- // return byteCount / (2 ** (10 * getCompactDigitalUnitTier(byteCount)));
- return byteCount / (10 ** (3 * getCompactDigitalUnitTier(byteCount)));
- }
-
- const getCompactDigitalUnitTier = (byteCount: string | number | bigint) => {
- const byteCountBigInt = BigInt(byteCount);
- for (let i = 0; i < digitalUnits.length - 1; i += 1) {
- // kibibytes, mebibytes...
- // if (byteCountBigInt < BigInt(2) ** BigInt(10 * i)) {
- // return i - 1;
- // }
- if (byteCountBigInt < BigInt(10) ** BigInt(3 * i)) {
- return i - 1;
- }
- }
-
- return digitalUnits.length - 1;
- };
-
- const FileSelectBoxDefaultPreviewComponentDerivedElementComponent = 'div' 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;
- }
-
- export type FileSelectBoxDefaultPreviewComponentDerivedElement = HTMLElementTagNameMap[
- typeof FileSelectBoxDefaultPreviewComponentDerivedElementComponent
- ];
-
- /**
- * 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) => (
- <FileSelectBoxDefaultPreviewComponentDerivedElementComponent
- data-enhanced={enhanced}
- className={tw({
- '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 && (
- <>
- <div className="w-full whitespace-nowrap text-sm overflow-hidden text-ellipsis">
- {file?.type || 'application/octet-stream'}
- </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 text-sm overflow-hidden text-ellipsis tabular-nums"
- >
- {new Intl.NumberFormat(undefined, {
- style: 'unit',
- unit: digitalUnits[getCompactDigitalUnitTier(file.size)],
- unitDisplay: getCompactDigitalUnitTier(file.size) === 0 ? 'long' : 'short',
- }).format(getCompactDigitalUnitValue(file.size))}
- </div>
- )}
- {typeof file?.lastModified === 'number' && (
- <div className="w-full whitespace-nowrap text-sm overflow-hidden text-ellipsis">
- <time dateTime={new Date(file.lastModified).toISOString()}>
- {new Date(file.lastModified).toDateString()}
- </time>
- </div>
- )}
- </>
- )}
- </FileSelectBoxDefaultPreviewComponentDerivedElementComponent>
- ));
-
- const FileSelectBoxRootElementComponent = 'div' as const;
-
- type FileSelectBoxRootElement = HTMLElementTagNameMap[typeof FileSelectBoxRootElementComponent];
-
- const FileSelectBoxActionElementComponent = 'button' as const;
-
- type FileSelectBoxActionElement = HTMLElementTagNameMap[typeof FileSelectBoxActionElementComponent];
-
- const FileSelectBoxDerivedElementComponent = 'input' as const;
-
- /**
- * Derived HTML element of the {@link FileSelectBox} component.
- */
- export type FileSelectBoxDerivedElement = HTMLElementTagNameMap[typeof FileSelectBoxDerivedElementComponent];
-
- /**
- * 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,
- /**
- * Should the component resize vertically?
- */
- resize?: boolean,
- }
-
- const isButtonElement = (el: HTMLElement): el is FileSelectBoxActionElement => el.tagName === 'BUTTON';
-
- const isInputElement = (el: HTMLElement): el is FileSelectBoxDerivedElement => 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 = '' as const,
- hint = '' as const,
- border = false as const,
- block = false as const,
- enhanced: enhancedProp = false as const,
- hiddenLabel = false as const,
- multiple = false as const,
- disabled = false as const,
- className,
- onChange,
- id: idProp,
- placeholder,
- previewComponent: FilePreviewComponent = FileSelectBoxDefaultPreviewComponent,
- style,
- onBlur,
- reselectLabel = 'Reselect' as const,
- clearLabel = 'Clear' as const,
- onPaste,
- resize = false as const,
- ...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<FileSelectBoxActionElement>
- | React.DragEvent<FileSelectBoxRootElement>
- | 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<FileSelectBoxRootElement> = React.useCallback((e) => {
- e.stopPropagation();
- e.preventDefault();
- }, []);
-
- const handleDrop: React.DragEventHandler<FileSelectBoxRootElement> = 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);
- onChange?.(e);
- return;
- }
- if (clearFileListRef.current) {
- clearFileListRef.current = false;
- setFileList(undefined);
- setLastUpdated(Date.now());
- onChange?.(e);
- }
- // prevent triggering onChange when the user cancels the file picker.
- }, [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<
- FileSelectBoxActionElement
- > = React.useCallback(() => {
- setTimeout(() => {
- setAboutToSelect(true);
- setTimeout(() => {
- const fileInput = defaultRef.current as FileSelectBoxDerivedElement;
- fileInput.focus();
- });
- });
- }, [defaultRef]);
-
- const handleReselectMouseUp: React.MouseEventHandler<
- FileSelectBoxActionElement
- > = React.useCallback(() => {
- const fileInput = defaultRef.current as FileSelectBoxDerivedElement;
- if (typeof fileInput.showPicker !== 'function') {
- fileInput.click();
- return;
- }
- fileInput.showPicker();
- }, [defaultRef]);
-
- const handleDeleteMouseDown: React.MouseEventHandler<
- FileSelectBoxActionElement
- > = React.useCallback(() => {
- setTimeout(() => {
- 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 (
- <FileSelectBoxRootElementComponent
- className={tw(
- 'relative rounded ring-secondary/50 group file-select-box',
- 'focus-within:ring-4',
- block && 'flex w-full',
- !block && 'inline-flex align-top w-64 justify-center items-center',
- className,
- )}
- onDragEnter={clientSide ? cancelEvent : undefined}
- onDragOver={clientSide ? cancelEvent : undefined}
- onDrop={clientSide ? handleDrop : undefined}
- data-testid="root"
- style={style}
- >
- <div
- className={tw(
- 'w-full overflow-hidden min-h-16 relative pt-4 min-h-16 rounded-inherit',
- {
- 'h-4': clientSide,
- 'h-16': !clientSide,
- 'resize-y': resize,
- 'pb-4': clientSide && filesCount > 0,
- },
- )}
- >
- {label && (
- <label
- data-testid="label"
- id={labelId}
- htmlFor={id}
- className={tw(
- '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 && filesCount <= 0 && (
- <FileSelectBoxActionElementComponent
- type="button"
- disabled={disabled}
- tabIndex={-1}
- className={tw(
- '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}
- </FileSelectBoxActionElementComponent>
- )}
- <FileSelectBoxDerivedElementComponent
- {...etcProps}
- id={id}
- disabled={disabled}
- ref={defaultRef}
- type="file"
- onBlur={handleBlur}
- onKeyDown={clientSide ? handleKeyDown : undefined}
- onKeyUp={clientSide ? doSetFileList : undefined}
- className={tw(
- 'peer text-sm box-border focus:outline-0 file:cursor-pointer disabled:file:cursor-pointer block 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 px-4': !clientSide,
- },
- )}
- onChange={clientSide ? handleFileChange : onChange}
- onPaste={clientSide ? doSetFileList : onPaste}
- multiple={multiple}
- data-testid="input"
- aria-labelledby={label ? `${labelId}` : undefined}
- />
- {filesCount <= 0
- && clientSide
- && hint
- && (
- <div
- data-testid="hint"
- className="w-full h-full pointer-events-none box-border overflow-hidden"
- >
- <div className="flex items-center justify-center w-full h-full px-4">
- {hint}
- </div>
- </div>
- )}
- {filesCount > 0
- && clientSide
- && (
- <div
- key={lastUpdated}
- className="flex flex-col h-full gap-4"
- >
- <div
- className="flex-auto pointer-events-none overflow-hidden"
- 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="w-full text-center h-8 flex"
- >
- <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
- <FileSelectBoxActionElementComponent
- type="button"
- data-testid="reselect"
- disabled={disabled}
- onMouseDown={handleReselectMouseDown}
- onMouseUp={handleReselectMouseUp}
- tabIndex={-1}
- className={tw(
- '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>
- </FileSelectBoxActionElementComponent>
- </div>
- <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
- <FileSelectBoxActionElementComponent
- disabled={disabled}
- data-testid="clear"
- type="button"
- name="action"
- value="clear"
- onMouseDown={handleDeleteMouseDown}
- onMouseUp={doSetFileList}
- tabIndex={-1}
- className={tw(
- '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>
- </FileSelectBoxActionElementComponent>
- </div>
- </div>
- </div>
- )}
- {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>
- </FileSelectBoxRootElementComponent>
- );
- });
-
- 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,
- resize: false as const,
- };
-
- FileSelectBoxDefaultPreviewComponent.defaultProps = {
- file: undefined,
- mini: false as const,
- disabled: FileSelectBox.defaultProps.disabled,
- enhanced: FileSelectBox.defaultProps.enhanced,
- };
|