Design system.
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 

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