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.
 
 
 

505 rader
16 KiB

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