Design system.
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 

633 rader
19 KiB

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