Design system.
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 

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