Design system.
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

596 строки
19 KiB

  1. import * as React from 'react';
  2. import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils';
  3. import clsx from 'clsx';
  4. const DEFAULT_ENHANCED_HEIGHT_PX = 64 as const;
  5. const DEFAULT_NON_ENHANCED_SIDE_HEIGHT_PX = 256 as const;
  6. /**
  7. * Common props for the {@link FileSelectBoxProps.previewComponent|previewComponent prop} of the
  8. * {@link FileSelectBox} component.
  9. */
  10. export interface CommonPreviewComponentProps<F extends Partial<File> = Partial<File>> {
  11. /**
  12. * The file to preview.
  13. */
  14. file?: F;
  15. /**
  16. * Is the component disabled?
  17. */
  18. disabled?: boolean;
  19. /**
  20. * Should the component be enhanced?
  21. */
  22. enhanced?: boolean;
  23. /**
  24. * Should the component use minimal space?
  25. */
  26. mini?: boolean;
  27. }
  28. /**
  29. * Derived HTML element of the {@link FileSelectBox} component.
  30. */
  31. export type FileSelectBoxDerivedElement = HTMLInputElement;
  32. /**
  33. * Props of the {@link FileSelectBox} component.
  34. */
  35. export interface FileSelectBoxProps<
  36. F extends Partial<File> = Partial<File>,
  37. P extends CommonPreviewComponentProps<F> = CommonPreviewComponentProps<F>
  38. > extends Omit<React.HTMLProps<FileSelectBoxDerivedElement>, 'size' | 'type' | 'label' | 'list'> {
  39. /**
  40. * Should the component display a border?
  41. */
  42. border?: boolean,
  43. /**
  44. * Should the component occupy the whole width of its parent?
  45. */
  46. block?: boolean,
  47. /**
  48. * Short textual description indicating the nature of the component's value.
  49. */
  50. label?: React.ReactNode,
  51. /**
  52. * Short textual description as guidelines for valid input values.
  53. */
  54. hint?: React.ReactNode,
  55. /**
  56. * Should the component be enhanced?
  57. */
  58. enhanced?: boolean,
  59. /**
  60. * Is the label hidden?
  61. */
  62. hiddenLabel?: boolean,
  63. /**
  64. * Preview component for the selected file(s).
  65. */
  66. previewComponent?: React.ElementType<P>,
  67. /**
  68. * Reselect label.
  69. */
  70. reselectLabel?: string,
  71. /**
  72. * Clear label.
  73. */
  74. clearLabel?: string,
  75. }
  76. export type FileSelectBoxDefaultPreviewComponentDerivedElement = HTMLDivElement;
  77. /**
  78. * Default component for the {@link FileSelectBoxProps.previewComponent|previewComponent prop}
  79. * of the {@link FileSelectBox} component.
  80. */
  81. export const FileSelectBoxDefaultPreviewComponent = React.forwardRef<
  82. FileSelectBoxDefaultPreviewComponentDerivedElement,
  83. CommonPreviewComponentProps
  84. >(({
  85. file,
  86. mini,
  87. enhanced,
  88. disabled,
  89. }, forwardedRef) => (
  90. <div
  91. data-enhanced={enhanced}
  92. className={clsx({
  93. 'opacity-50': disabled,
  94. })}
  95. ref={forwardedRef}
  96. >
  97. <div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">
  98. {file?.name ?? (
  99. <span className="opacity-50">
  100. File
  101. </span>
  102. )}
  103. </div>
  104. {!mini && (
  105. <>
  106. {typeof file?.type === 'string' && (
  107. <div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">{file?.type}</div>
  108. )}
  109. {typeof file?.size === 'number' && (
  110. <div
  111. title={new Intl.NumberFormat(undefined, {
  112. style: 'unit',
  113. unit: 'byte',
  114. unitDisplay: 'long',
  115. }).format(file.size)}
  116. className="w-full whitespace-nowrap overflow-hidden text-ellipsis tabular-nums"
  117. >
  118. {new Intl.NumberFormat(undefined, {
  119. style: 'unit',
  120. unit: 'kilobyte',
  121. unitDisplay: 'long',
  122. }).format(file.size)}
  123. </div>
  124. )}
  125. {typeof file?.lastModified === 'number' && (
  126. <div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">
  127. <time dateTime={new Date(file.lastModified).toISOString()}>
  128. {new Date(file.lastModified).toDateString()}
  129. </time>
  130. </div>
  131. )}
  132. </>
  133. )}
  134. </div>
  135. ));
  136. const isButtonElement = (el: HTMLElement): el is HTMLButtonElement => el.tagName === 'BUTTON';
  137. const isInputElement = (el: HTMLElement): el is HTMLInputElement => el.tagName === 'INPUT';
  138. const isKeyUpEvent = (e: React.SyntheticEvent): e is React.KeyboardEvent => e.type === 'keyup';
  139. const isMouseUpEvent = (e: React.SyntheticEvent): e is React.MouseEvent => e.type === 'mouseup';
  140. const isDropEvent = (e: React.SyntheticEvent): e is React.DragEvent => e.type === 'drop';
  141. const isPasteEvent = (e: React.SyntheticEvent): e is React.ClipboardEvent => e.type === 'paste';
  142. export const DELETE_KEYS = ['Backspace', 'Delete'] as const;
  143. type DeleteKey = typeof DELETE_KEYS[number];
  144. export const SELECT_KEYS = ['Enter', 'Space', 'Return'] as const;
  145. type SelectKey = typeof SELECT_KEYS[number];
  146. /**
  147. * Component for selecting files.
  148. */
  149. export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileSelectBoxProps>((
  150. {
  151. label = '',
  152. hint = '',
  153. border = false,
  154. block = false,
  155. enhanced: enhancedProp = false,
  156. hiddenLabel = false,
  157. multiple = false,
  158. disabled = false,
  159. className,
  160. onChange,
  161. id: idProp,
  162. placeholder,
  163. previewComponent: FilePreviewComponent = FileSelectBoxDefaultPreviewComponent,
  164. style,
  165. onBlur,
  166. reselectLabel = 'Reselect',
  167. clearLabel = 'Clear',
  168. onPaste,
  169. ...etcProps
  170. },
  171. forwardedRef,
  172. ) => {
  173. const { clientSide } = useClientSide({ clientSide: enhancedProp });
  174. const [fileList, setFileList] = React.useState<FileList>();
  175. const [lastUpdated, setLastUpdated] = React.useState<number>();
  176. const clearFileListRef = React.useRef(false);
  177. const [aboutToClear, setAboutToClear] = React.useState(false);
  178. const [aboutToSelect, setAboutToSelect] = React.useState(false);
  179. const clearFiles = (
  180. fileInput: FileSelectBoxDerivedElement,
  181. clearRef: React.MutableRefObject<boolean>,
  182. ) => {
  183. const clearRefMut = clearRef as unknown as Record<string, boolean>;
  184. clearRefMut.current = true;
  185. const fileInputMut = fileInput as unknown as Record<string, string>;
  186. fileInputMut.value = '';
  187. setFileList(undefined);
  188. setLastUpdated(Date.now());
  189. };
  190. const { defaultRef, handleChange: doSetFileList } = useProxyInput<
  191. React.ChangeEvent<FileSelectBoxDerivedElement>
  192. | React.MouseEvent<HTMLButtonElement>
  193. | React.DragEvent<HTMLDivElement>
  194. | React.KeyboardEvent<FileSelectBoxDerivedElement>
  195. | React.ClipboardEvent<FileSelectBoxDerivedElement>,
  196. FileSelectBoxDerivedElement
  197. >({
  198. forwardedRef,
  199. valueSetterFn: (e) => {
  200. const fileInput = defaultRef.current as FileSelectBoxDerivedElement;
  201. if (isMouseUpEvent(e) && isButtonElement(e.currentTarget)) {
  202. clearFiles(fileInput, clearFileListRef);
  203. return;
  204. }
  205. if (isKeyUpEvent(e) && isInputElement(e.currentTarget) && e.currentTarget === fileInput) {
  206. // delete via keyboard
  207. const { code } = e;
  208. if (DELETE_KEYS.includes(code as DeleteKey)) {
  209. clearFiles(fileInput, clearFileListRef);
  210. setAboutToClear(false);
  211. }
  212. return;
  213. }
  214. if (isDropEvent(e)) {
  215. // drop file
  216. const { dataTransfer } = e;
  217. const { files } = dataTransfer;
  218. if (files && files.length > 0) {
  219. try {
  220. fileInput.files = files;
  221. } catch {
  222. // noop, the assignment throws for test environments
  223. }
  224. setFileList(files);
  225. setLastUpdated(Date.now());
  226. }
  227. return;
  228. }
  229. if (isPasteEvent(e)) {
  230. const { clipboardData } = e;
  231. const { files } = clipboardData;
  232. if (files && files.length > 0) {
  233. try {
  234. fileInput.files = files;
  235. } catch {
  236. // noop, the assignment throws for test environments
  237. }
  238. setFileList(files);
  239. setLastUpdated(Date.now());
  240. }
  241. return;
  242. }
  243. // Unhandled event
  244. },
  245. });
  246. const labelId = React.useId();
  247. const id = useFallbackId(idProp);
  248. const cancelEvent: React.DragEventHandler<HTMLDivElement> = React.useCallback((e) => {
  249. e.stopPropagation();
  250. e.preventDefault();
  251. }, []);
  252. const handleDrop: React.DragEventHandler<HTMLDivElement> = React.useCallback((e) => {
  253. cancelEvent(e);
  254. doSetFileList(e);
  255. }, [cancelEvent, doSetFileList]);
  256. const handleFileChange: React.ChangeEventHandler<
  257. FileSelectBoxDerivedElement
  258. > = React.useCallback((e) => {
  259. const { currentTarget } = e;
  260. if (currentTarget.files && currentTarget.files.length > 0) {
  261. setFileList(currentTarget.files);
  262. setLastUpdated(Date.now());
  263. setAboutToSelect(false);
  264. } else if (clearFileListRef.current) {
  265. clearFileListRef.current = false;
  266. setFileList(undefined);
  267. setLastUpdated(Date.now());
  268. }
  269. onChange?.(e);
  270. }, [clearFileListRef, onChange]);
  271. const handleKeyDown: React.KeyboardEventHandler<
  272. FileSelectBoxDerivedElement
  273. > = React.useCallback((e) => {
  274. const { code } = e;
  275. if (DELETE_KEYS.includes(code as DeleteKey)) {
  276. setAboutToClear(true);
  277. return;
  278. }
  279. if (SELECT_KEYS.includes(code as SelectKey)) {
  280. setAboutToSelect(true);
  281. }
  282. // ignore other keys
  283. }, []);
  284. const handleReselectMouseDown: React.MouseEventHandler<
  285. HTMLButtonElement
  286. > = React.useCallback(() => {
  287. setAboutToSelect(true);
  288. setTimeout(() => {
  289. const fileInput = defaultRef.current as FileSelectBoxDerivedElement;
  290. fileInput.focus();
  291. });
  292. }, [defaultRef]);
  293. const handleReselectMouseUp: React.MouseEventHandler<
  294. HTMLButtonElement
  295. > = React.useCallback(() => {
  296. const fileInput = defaultRef.current as FileSelectBoxDerivedElement;
  297. if (typeof fileInput.showPicker !== 'function') {
  298. fileInput.click();
  299. return;
  300. }
  301. fileInput.showPicker();
  302. }, [defaultRef]);
  303. const handleDeleteMouseDown: React.MouseEventHandler<
  304. HTMLButtonElement
  305. > = React.useCallback(() => {
  306. setAboutToClear(true);
  307. setTimeout(() => {
  308. const fileInput = defaultRef.current as FileSelectBoxDerivedElement;
  309. fileInput.focus();
  310. });
  311. }, [defaultRef]);
  312. const handleBlur: React.FocusEventHandler<
  313. FileSelectBoxDerivedElement
  314. > = React.useCallback((e) => {
  315. setAboutToSelect(false);
  316. setAboutToClear(false);
  317. onBlur?.(e);
  318. }, [onBlur]);
  319. React.useEffect(() => {
  320. const handleLabelMouseUp = () => {
  321. setAboutToSelect(false);
  322. setAboutToClear(false);
  323. };
  324. window.addEventListener('mouseup', handleLabelMouseUp, { capture: true });
  325. return () => {
  326. window.removeEventListener('mouseup', handleLabelMouseUp, { capture: true });
  327. };
  328. }, [defaultRef, aboutToSelect]);
  329. const filesCount = fileList?.length ?? 0;
  330. const fileListArray = Array.from(fileList ?? []);
  331. return (
  332. <div
  333. className={clsx(
  334. 'relative rounded ring-secondary/50 group file-select-box',
  335. 'focus-within:ring-4',
  336. block && 'flex w-full',
  337. !block && 'inline-flex w-64 min-h-16 justify-center items-center',
  338. className,
  339. )}
  340. onDragEnter={clientSide ? cancelEvent : undefined}
  341. onDragOver={clientSide ? cancelEvent : undefined}
  342. onDrop={clientSide ? handleDrop : undefined}
  343. data-testid="root"
  344. style={{
  345. height: clientSide ? DEFAULT_ENHANCED_HEIGHT_PX : undefined,
  346. ...(style ?? {}),
  347. }}
  348. >
  349. {label && (
  350. <label
  351. data-testid="label"
  352. id={labelId}
  353. htmlFor={id}
  354. className={clsx(
  355. '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',
  356. {
  357. 'sr-only': hiddenLabel,
  358. },
  359. )}
  360. >
  361. <div className="w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  362. {label}
  363. </div>
  364. </label>
  365. )}
  366. {clientSide && (
  367. <button
  368. type="button"
  369. disabled={disabled}
  370. tabIndex={-1}
  371. className={clsx(
  372. 'flex items-center focus:outline-0 justify-center absolute top-0 left-0 w-full h-full cursor-pointer select-none',
  373. (fileList?.length ?? 0) > 0 && 'opacity-0',
  374. )}
  375. data-testid="clickArea"
  376. onMouseDown={handleReselectMouseDown}
  377. onMouseUp={handleReselectMouseUp}
  378. >
  379. {placeholder}
  380. </button>
  381. )}
  382. <input
  383. {...etcProps}
  384. id={id}
  385. disabled={disabled}
  386. ref={defaultRef}
  387. type="file"
  388. onBlur={handleBlur}
  389. onKeyDown={clientSide ? handleKeyDown : undefined}
  390. onKeyUp={clientSide ? doSetFileList : undefined}
  391. className={clsx(
  392. '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',
  393. {
  394. 'sr-only': clientSide,
  395. 'h-full w-full': !clientSide,
  396. },
  397. )}
  398. onChange={clientSide ? handleFileChange : onChange}
  399. onPaste={clientSide ? doSetFileList : onPaste}
  400. multiple={multiple}
  401. data-testid="input"
  402. aria-labelledby={label ? `${labelId}` : undefined}
  403. style={{
  404. height: clientSide ? undefined : DEFAULT_NON_ENHANCED_SIDE_HEIGHT_PX,
  405. }}
  406. />
  407. {filesCount < 1
  408. && clientSide
  409. && hint
  410. && (
  411. <div
  412. data-testid="hint"
  413. className="absolute top-0 left-0 w-full h-full pointer-events-none box-border overflow-hidden pt-4"
  414. >
  415. <div className="flex items-center justify-center w-full h-full">
  416. {hint}
  417. </div>
  418. </div>
  419. )}
  420. {filesCount > 0
  421. && clientSide
  422. && (
  423. <React.Fragment
  424. key={lastUpdated}
  425. >
  426. <div
  427. className="sm:absolute top-0 left-0 w-full h-full pointer-events-none pb-12 box-border overflow-hidden pt-8"
  428. data-testid="preview"
  429. >
  430. {multiple
  431. && (
  432. <div className="pointer-events-auto w-full h-full overflow-auto px-4 box-border">
  433. <div className="w-full grid gap-2 grid-cols-3">
  434. {fileListArray.map((file, i) => (
  435. <div
  436. key={file.name ?? i}
  437. data-testid="selectedFileItem"
  438. 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"
  439. >
  440. <FilePreviewComponent
  441. file={file}
  442. enhanced={clientSide}
  443. disabled={disabled}
  444. mini
  445. />
  446. </div>
  447. ))}
  448. </div>
  449. </div>
  450. )}
  451. {!multiple
  452. && fileListArray.map((file, i) => (
  453. <div
  454. key={file.name ?? i}
  455. className="pointer-events-auto w-full h-full px-4 box-border"
  456. >
  457. <div
  458. data-testid="selectedFileItem"
  459. 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"
  460. >
  461. <div
  462. className="w-full h-full relative"
  463. >
  464. <FilePreviewComponent
  465. file={file}
  466. enhanced={clientSide}
  467. disabled={disabled}
  468. />
  469. </div>
  470. </div>
  471. </div>
  472. ))}
  473. </div>
  474. <div
  475. data-testid="actions"
  476. className="absolute bottom-0 left-0 w-full text-center h-12 box-border flex"
  477. >
  478. <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
  479. <button
  480. type="button"
  481. data-testid="reselect"
  482. disabled={disabled}
  483. onMouseDown={handleReselectMouseDown}
  484. onMouseUp={handleReselectMouseUp}
  485. tabIndex={-1}
  486. className={clsx(
  487. 'flex w-full h-full focus:outline-0 bg-negative text-primary cursor-pointer items-center justify-center leading-none gap-4 select-none',
  488. {
  489. 'group-focus-within:text-secondary group-focus-within:active:text-tertiary': !aboutToSelect,
  490. 'text-tertiary': aboutToSelect,
  491. },
  492. )}
  493. >
  494. <span
  495. className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
  496. >
  497. {reselectLabel}
  498. </span>
  499. </button>
  500. </div>
  501. <div className="w-0 flex-auto flex flex-col items-center justify-center h-full">
  502. <button
  503. disabled={disabled}
  504. data-testid="clear"
  505. type="button"
  506. name="action"
  507. value="clear"
  508. onMouseDown={handleDeleteMouseDown}
  509. onMouseUp={doSetFileList}
  510. tabIndex={-1}
  511. className={clsx(
  512. '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',
  513. {
  514. 'group-focus-within:text-secondary group-focus-within:active:text-tertiary': !aboutToClear,
  515. 'text-tertiary': aboutToClear,
  516. },
  517. )}
  518. >
  519. <span
  520. className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
  521. >
  522. {clearLabel}
  523. </span>
  524. </button>
  525. </div>
  526. </div>
  527. </React.Fragment>
  528. )}
  529. {border && (
  530. <span
  531. data-testid="border"
  532. className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
  533. />
  534. )}
  535. </div>
  536. );
  537. });
  538. FileSelectBox.displayName = 'FileSelectBox';
  539. FileSelectBox.defaultProps = {
  540. border: false as const,
  541. block: false as const,
  542. enhanced: false as const,
  543. hiddenLabel: false as const,
  544. label: undefined,
  545. hint: undefined,
  546. previewComponent: FileSelectBoxDefaultPreviewComponent,
  547. reselectLabel: 'Reselect' as const,
  548. clearLabel: 'Clear' as const,
  549. };
  550. FileSelectBoxDefaultPreviewComponent.defaultProps = {
  551. file: undefined,
  552. mini: false as const,
  553. disabled: FileSelectBox.defaultProps.disabled,
  554. enhanced: FileSelectBox.defaultProps.enhanced,
  555. };