Design system.
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 

522 Zeilen
17 KiB

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