@transferwise/components 46.74.0 → 46.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/build/dimmer/Dimmer.js +3 -1
  2. package/build/dimmer/Dimmer.js.map +1 -1
  3. package/build/dimmer/Dimmer.mjs +3 -1
  4. package/build/dimmer/Dimmer.mjs.map +1 -1
  5. package/build/drawer/Drawer.js +2 -0
  6. package/build/drawer/Drawer.js.map +1 -1
  7. package/build/drawer/Drawer.mjs +2 -0
  8. package/build/drawer/Drawer.mjs.map +1 -1
  9. package/build/modal/Modal.js +3 -0
  10. package/build/modal/Modal.js.map +1 -1
  11. package/build/modal/Modal.mjs +3 -0
  12. package/build/modal/Modal.mjs.map +1 -1
  13. package/build/types/dimmer/Dimmer.d.ts +2 -1
  14. package/build/types/dimmer/Dimmer.d.ts.map +1 -1
  15. package/build/types/drawer/Drawer.d.ts +2 -1
  16. package/build/types/drawer/Drawer.d.ts.map +1 -1
  17. package/build/types/modal/Modal.d.ts +2 -1
  18. package/build/types/modal/Modal.d.ts.map +1 -1
  19. package/build/types/uploadInput/UploadInput.d.ts +9 -0
  20. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  21. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +16 -1
  22. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  23. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
  24. package/build/uploadInput/UploadInput.js +71 -66
  25. package/build/uploadInput/UploadInput.js.map +1 -1
  26. package/build/uploadInput/UploadInput.mjs +72 -67
  27. package/build/uploadInput/UploadInput.mjs.map +1 -1
  28. package/build/uploadInput/uploadItem/UploadItem.js +13 -4
  29. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  30. package/build/uploadInput/uploadItem/UploadItem.mjs +13 -4
  31. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  32. package/build/uploadInput/uploadItem/UploadItemLink.js +1 -0
  33. package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
  34. package/build/uploadInput/uploadItem/UploadItemLink.mjs +1 -0
  35. package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
  36. package/package.json +2 -2
  37. package/src/dimmer/Dimmer.spec.js +8 -0
  38. package/src/dimmer/Dimmer.tsx +4 -0
  39. package/src/drawer/Drawer.spec.js +25 -6
  40. package/src/drawer/Drawer.tsx +3 -1
  41. package/src/modal/Modal.spec.js +19 -1
  42. package/src/modal/Modal.tsx +4 -0
  43. package/src/uploadInput/UploadInput.spec.tsx +121 -9
  44. package/src/uploadInput/UploadInput.tests.story.tsx +207 -140
  45. package/src/uploadInput/UploadInput.tsx +110 -77
  46. package/src/uploadInput/uploadItem/UploadItem.spec.tsx +1 -0
  47. package/src/uploadInput/uploadItem/UploadItem.tsx +30 -6
  48. package/src/uploadInput/uploadItem/UploadItemLink.tsx +9 -1
@@ -1,5 +1,5 @@
1
1
  import { clsx } from 'clsx';
2
- import { useEffect, useRef, useState, useLayoutEffect } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { useIntl } from 'react-intl';
4
4
 
5
5
  import Button from '../button';
@@ -99,16 +99,45 @@ export type UploadInputProps = {
99
99
  'disabled' | 'multiple' | 'fileTypes' | 'sizeLimit' | 'description' | 'id' | 'uploadButtonTitle'
100
100
  > & { onDownload?: UploadItemProps['onDownload'] } & CommonProps;
101
101
 
102
+ /**
103
+ * Interface representing a reference to an UploadItem component.
104
+ * Provides a method to focus the UploadItem.
105
+ */
102
106
  interface UploadItemRef {
107
+ /**
108
+ * Focuses the UploadItem component.
109
+ */
103
110
  focus: () => void;
111
+
112
+ /**
113
+ * Required id of the UploadItem component.
114
+ */
115
+ id: string | number;
116
+
117
+ /**
118
+ * Optional status of the UploadItem component.
119
+ */
120
+ status?: string;
104
121
  }
105
122
 
123
+ /**
124
+ * Generates a unique ID for a file based on its name, size, and the current timestamp
125
+ */
106
126
  function generateFileId(file: File) {
107
127
  const { name, size } = file;
108
128
  const uploadTimeStamp = new Date().getTime();
109
129
  return `${name}_${size}_${uploadTimeStamp}`;
110
130
  }
111
131
 
132
+ /**
133
+ * The component allows users to upload files, manage the list of uploaded files,
134
+ * and handle file validation and deletion.
135
+ *
136
+ * @param {UploadInputProps} props - The properties for the UploadInput component.
137
+ *
138
+ * @see {@link UploadInput} for further information.
139
+ * @see {@link https://storybook.wise.design/?path=/docs/forms-uploadinput--docs|Storybook Wise Design}
140
+ */
112
141
  const UploadInput = ({
113
142
  files = [],
114
143
  fileInputName = 'file',
@@ -131,13 +160,11 @@ const UploadInput = ({
131
160
  uploadButtonTitle,
132
161
  }: UploadInputProps) => {
133
162
  const inputAttributes = useInputAttributes({ nonLabelable: true });
134
-
135
163
  const [markedFileForDelete, setMarkedFileForDelete] = useState<UploadedFile | null>(null);
136
- const [fileToRemoveIndex, setFileToRemoveIndex] = useState<number | null>(null);
137
164
  const [mounted, setMounted] = useState(false);
138
165
  const { formatMessage } = useIntl();
139
- const itemRefs = useRef<(HTMLDivElement | UploadItemRef | null)[]>([]);
140
166
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
167
+ let fileRefs: (HTMLDivElement | UploadItemRef | null)[] = [];
141
168
 
142
169
  const PROGRESS_STATUSES = new Set([Status.PENDING, Status.PROCESSING]);
143
170
 
@@ -147,69 +174,57 @@ const UploadInput = ({
147
174
 
148
175
  const uploadedFilesListReference = useRef(multiple || files.length === 0 ? files : [files[0]]);
149
176
 
150
- function addFileToList(recentUploadedFile: UploadedFile) {
151
- function addToList(listToAddTo: readonly UploadedFile[]) {
152
- return [...listToAddTo, recentUploadedFile];
153
- }
154
-
155
- setUploadedFiles(addToList);
156
- uploadedFilesListReference.current = addToList(uploadedFilesListReference.current);
177
+ function updateFileList(updateFn: (list: readonly UploadedFile[]) => readonly UploadedFile[]) {
178
+ setUploadedFiles(updateFn);
179
+ uploadedFilesListReference.current = updateFn(uploadedFilesListReference.current);
157
180
  }
158
181
 
159
- const removeFileFromList = (file: UploadedFile) => {
160
- function filterOutFrom(listToFilterFrom: readonly UploadedFile[]) {
161
- return listToFilterFrom.filter(
162
- (fileInList) => file !== fileInList && file.id !== fileInList.id,
163
- );
164
- }
165
-
166
- setUploadedFiles(filterOutFrom);
167
- uploadedFilesListReference.current = filterOutFrom(uploadedFilesListReference.current);
168
- };
169
-
170
- const modifyFileInList = (file: UploadedFile, updates: Partial<UploadedFile>) => {
171
- const updateListItem = (listToUpdate: readonly UploadedFile[]) =>
172
- listToUpdate.map((fileInList) => {
173
- return fileInList === file || fileInList.id === file.id
174
- ? { ...file, ...updates }
175
- : fileInList;
176
- });
182
+ function addFileToList(recentUploadedFile: UploadedFile) {
183
+ updateFileList((list) => [...list, recentUploadedFile]);
184
+ }
177
185
 
178
- setUploadedFiles(updateListItem);
179
- uploadedFilesListReference.current = updateListItem(uploadedFilesListReference.current);
180
- };
186
+ function removeFileFromList(file: UploadedFile) {
187
+ updateFileList((list) =>
188
+ list.filter((fileInList) => file !== fileInList && file.id !== fileInList.id),
189
+ );
190
+ fileRefs = fileRefs.filter((ref) => ref && ref.id !== file.id);
191
+ }
181
192
 
182
- const [fileToRemove, setFileToRemove] = useState<UploadedFile | null>(null);
193
+ function modifyFileInList(file: UploadedFile, updates: Partial<UploadedFile>) {
194
+ updateFileList((list) =>
195
+ list.map((fileInList) =>
196
+ fileInList === file || fileInList.id === file.id ? { ...file, ...updates } : fileInList,
197
+ ),
198
+ );
199
+ }
183
200
 
184
- const removeFile = (file: UploadedFile) => {
201
+ const removeFile = async (file: UploadedFile) => {
185
202
  const { id, status } = file;
186
- const index = uploadedFiles.findIndex((f) => f.id === file.id);
187
- setFileToRemoveIndex(index);
203
+ fileRefs = fileRefs.filter((item) => item && item.id !== file.id);
188
204
 
189
205
  if (status === Status.FAILED) {
190
206
  removeFileFromList(file);
191
- setFileToRemove(file);
192
- } else if (onDeleteFile && id) {
207
+ return Promise.resolve();
208
+ }
209
+
210
+ if (onDeleteFile && id) {
193
211
  modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
194
212
 
195
- onDeleteFile(id)
213
+ return onDeleteFile(id)
196
214
  .then(() => {
197
215
  removeFileFromList(file);
198
216
  })
199
217
  .catch((error) => {
200
218
  modifyFileInList(file, { error: error as UploadError });
201
- })
202
- .finally(() => {
203
- setFileToRemove(file);
204
219
  });
205
220
  }
206
221
  };
207
222
 
208
223
  function handleFileUploadFailure(file: File, failureMessage: string) {
209
224
  const { name } = file;
210
- const id = generateFileId(file);
225
+
211
226
  const failedUpload = {
212
- id,
227
+ id: generateFileId(file),
213
228
  filename: name,
214
229
  status: Status.FAILED,
215
230
  error: failureMessage,
@@ -239,28 +254,20 @@ const UploadInput = ({
239
254
  return numberOfValidFiles >= maxFiles;
240
255
  }
241
256
 
242
- // One or more files selected, create entries for them
243
257
  const addFiles = (selectedFiles: FileList) => {
244
258
  for (let i = 0; i < selectedFiles.length; i += 1) {
245
259
  const file = selectedFiles.item(i);
246
260
 
247
- // Returning a FormData[] array instead of FileList so we can filter out incorrect files
248
261
  const formData = new FormData();
249
262
 
250
263
  if (file) {
251
- const { name } = file;
252
- const id = generateFileId(file);
253
-
254
264
  const allowedFileTypes = typeof fileTypes === 'string' ? fileTypes : fileTypes.join(',');
255
265
 
256
- // Check if file type is valid
257
266
  if (!isTypeValid(file, allowedFileTypes)) {
258
267
  handleFileUploadFailure(file, formatMessage(MESSAGES.fileTypeNotSupported));
259
268
  continue;
260
269
  }
261
270
 
262
- // Check if the filesize is valid
263
- // Convert to rough bytes
264
271
  if (!isSizeValid(file, sizeLimit * 1000)) {
265
272
  const failureMessage = sizeLimitErrorMessage || formatMessage(MESSAGES.fileIsTooLarge);
266
273
  handleFileUploadFailure(file, failureMessage);
@@ -275,14 +282,11 @@ const UploadInput = ({
275
282
  continue;
276
283
  }
277
284
 
278
- // Check if the file is already in the list
279
285
  const existingFile = uploadedFiles.find((f) => f.filename === file.name);
280
286
  if (existingFile) {
281
- // Remove the file from the list before adding it again
282
287
  removeFileFromList(existingFile);
283
288
  }
284
289
 
285
- // Add the file to the list
286
290
  formData.append(fileInputName, file);
287
291
  const pendingFile = {
288
292
  id: generateFileId(file),
@@ -292,10 +296,8 @@ const UploadInput = ({
292
296
 
293
297
  addFileToList(pendingFile);
294
298
 
295
- // Start uploading the file
296
299
  onUploadFile(formData)
297
300
  .then(({ id, url, error }: UploadResponse) => {
298
- // Replace the temporary id with the final one received from the API, and also set any errors
299
301
  modifyFileInList(pendingFile, { id, url, error, status: Status.SUCCEEDED });
300
302
  })
301
303
  .catch((error) => {
@@ -303,29 +305,12 @@ const UploadInput = ({
303
305
  });
304
306
 
305
307
  if (!multiple) {
306
- // Only upload a single file
307
308
  break;
308
309
  }
309
310
  }
310
311
  }
311
312
  };
312
313
 
313
- useLayoutEffect(() => {
314
- if (fileToRemove && fileToRemoveIndex !== null) {
315
- requestAnimationFrame(() => {
316
- const nextFocusIndex = Math.min(fileToRemoveIndex, uploadedFiles.length - 1);
317
- if (itemRefs.current[nextFocusIndex]) {
318
- itemRefs.current[nextFocusIndex].focus(); // Focus the next UploadItem
319
- } else {
320
- // If there's only one item left, focus the UploadButton
321
- uploadInputRef.current?.focus();
322
- }
323
- });
324
- setFileToRemove(null); // Reset the state
325
- setFileToRemoveIndex(null); // Reset the index
326
- }
327
- }, [uploadedFiles, fileToRemove, fileToRemoveIndex, itemRefs, uploadInputRef]);
328
-
329
314
  useEffect(() => {
330
315
  setMounted(true);
331
316
  }, []);
@@ -336,6 +321,41 @@ const UploadInput = ({
336
321
  }
337
322
  }, [onFilesChange, uploadedFiles]); // eslint-disable-line react-hooks/exhaustive-deps
338
323
 
324
+ const [nextFocusable, setNextFocusable] = useState<HTMLDivElement | UploadItemRef | null>(
325
+ uploadInputRef.current,
326
+ );
327
+
328
+ const handleFocus = (fileId: string | number) => {
329
+ fileRefs = fileRefs.filter((ref) => {
330
+ return ref && ref.id !== markedFileForDelete?.id;
331
+ });
332
+
333
+ const filesCount = fileRefs.length;
334
+ let next: HTMLDivElement | UploadItemRef | null = uploadInputRef.current;
335
+
336
+ if (filesCount > 1) {
337
+ const currentFileIndex = fileRefs.findIndex((file) => file?.id === fileId);
338
+ const currentFileId = fileRefs?.[currentFileIndex]?.id;
339
+ const lastFileId = fileRefs?.[filesCount - 1]?.id;
340
+
341
+ // if last file, select a previous one
342
+ if (currentFileId === lastFileId) {
343
+ next = fileRefs[filesCount - 2];
344
+ } else {
345
+ next = fileRefs[currentFileIndex + 1];
346
+ }
347
+ }
348
+ setNextFocusable(next);
349
+ };
350
+
351
+ const handleRefocus = () => {
352
+ if (nextFocusable && 'focus' in nextFocusable && typeof nextFocusable.focus === 'function') {
353
+ setTimeout(() => {
354
+ nextFocusable.focus();
355
+ }, 0);
356
+ }
357
+ };
358
+
339
359
  return (
340
360
  <>
341
361
  <div
@@ -353,7 +373,14 @@ const UploadInput = ({
353
373
  <UploadItem
354
374
  key={file.id}
355
375
  ref={(el: UploadItemRef | null) => {
356
- itemRefs.current[index] = el;
376
+ if (
377
+ el &&
378
+ el.id !== markedFileForDelete?.id &&
379
+ !fileRefs.some((ref) => ref && ref.id === el.id) &&
380
+ el.status !== 'processing'
381
+ ) {
382
+ fileRefs.push(el);
383
+ }
357
384
  }}
358
385
  file={file}
359
386
  singleFileUpload={!multiple}
@@ -363,10 +390,14 @@ const UploadInput = ({
363
390
  }
364
391
  onDelete={
365
392
  file.status === Status.FAILED
366
- ? () => removeFile(file)
393
+ ? async () => {
394
+ await removeFile(file);
395
+ handleRefocus();
396
+ }
367
397
  : () => setMarkedFileForDelete(file)
368
398
  }
369
399
  onDownload={onDownload}
400
+ onFocus={() => handleFocus(file.id)}
370
401
  />
371
402
  ))}
372
403
  </div>
@@ -414,9 +445,10 @@ const UploadInput = ({
414
445
  block
415
446
  priority={Priority.SECONDARY}
416
447
  type={ControlType.NEGATIVE}
448
+ tabIndex={markedFileForDelete ? 0 : -1}
417
449
  onClick={() => {
418
450
  if (markedFileForDelete) {
419
- removeFile(markedFileForDelete);
451
+ void removeFile(markedFileForDelete);
420
452
  }
421
453
  setMarkedFileForDelete(null);
422
454
  }}
@@ -425,6 +457,7 @@ const UploadInput = ({
425
457
  </Button>
426
458
  </>
427
459
  }
460
+ onUnmount={handleRefocus}
428
461
  onClose={() => {
429
462
  setMarkedFileForDelete(null);
430
463
  }}
@@ -16,6 +16,7 @@ describe('UploadItem', () => {
16
16
  canDelete: true,
17
17
  onDelete: jest.fn(),
18
18
  singleFileUpload: true,
19
+ onFocus: jest.fn(),
19
20
  };
20
21
 
21
22
  const renderComponent = (customProps = props) => render(<UploadItem {...customProps} />);
@@ -4,9 +4,8 @@ import { forwardRef, useImperativeHandle, useRef } from 'react';
4
4
  import { useIntl } from 'react-intl';
5
5
 
6
6
  import Body from '../../body';
7
- import { Size, Status, Typography, Sentiment } from '../../common';
7
+ import { Size, Status, Typography } from '../../common';
8
8
  import ProcessIndicator from '../../processIndicator/ProcessIndicator';
9
- import StatusIcon from '../../statusIcon/StatusIcon';
10
9
  import { UploadedFile, UploadError } from '../types';
11
10
 
12
11
  import MESSAGES from './UploadItem.messages';
@@ -20,6 +19,7 @@ export type UploadItemProps = React.JSX.IntrinsicAttributes & {
20
19
  singleFileUpload: boolean;
21
20
  canDelete: boolean;
22
21
  onDelete: () => void;
22
+ onFocus: () => void;
23
23
 
24
24
  /**
25
25
  * Callback to be called when the file link is clicked.
@@ -30,18 +30,33 @@ export type UploadItemProps = React.JSX.IntrinsicAttributes & {
30
30
  onDownload?: (file: UploadedFile) => void;
31
31
  ref?: React.Ref<UploadItemRef>;
32
32
  };
33
-
34
33
  interface UploadItemRef {
34
+ /**
35
+ * A method to set focus on the upload item.
36
+ * @returns {void}
37
+ */
35
38
  focus: () => void;
39
+
40
+ /**
41
+ * A required unique identifier for the upload item.
42
+ */
43
+ id: string | number;
44
+
45
+ /**
46
+ * An optional status of the upload item.
47
+ */
48
+ status?: string;
36
49
  }
37
50
 
38
51
  export enum TEST_IDS {
39
52
  uploadItem = 'uploadItem',
40
53
  mediaBody = 'mediaBody',
54
+ link = 'link',
55
+ action = 'action',
41
56
  }
42
57
 
43
58
  const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
44
- ({ file, canDelete, onDelete, onDownload, singleFileUpload }, ref) => {
59
+ ({ file, canDelete, onDelete, onDownload, singleFileUpload, onFocus: handleFocus }, ref) => {
45
60
  const { formatMessage } = useIntl();
46
61
  const { status, filename, error, errors, url } = file;
47
62
  const linkRef = useRef<HTMLAnchorElement>(null);
@@ -55,6 +70,8 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
55
70
  buttonRef.current?.focus();
56
71
  }
57
72
  },
73
+ id: file.id,
74
+ status: file.status,
58
75
  }));
59
76
 
60
77
  const isSucceeded = [Status.SUCCEEDED, undefined].includes(status) && !!url;
@@ -152,16 +169,20 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
152
169
  return (
153
170
  <div
154
171
  className={clsx('np-upload-input__item', { 'is-interactive': isSucceeded && url })}
155
- data-testid={TEST_IDS.uploadItem}
172
+ data-testid={`${file.id}-${TEST_IDS.uploadItem}`}
156
173
  >
157
174
  <UploadItemLink
158
175
  ref={linkRef}
159
176
  url={isSucceeded ? url : undefined}
160
177
  singleFileUpload={singleFileUpload}
178
+ data-testid={`${file.id}-${TEST_IDS.link}`}
161
179
  onDownload={onDownloadFile}
162
180
  >
163
181
  <span className="np-upload-input__icon">{getIcon()}</span>
164
- <div className="np-upload-input__item-content" data-testid={TEST_IDS.mediaBody}>
182
+ <div
183
+ className="np-upload-input__item-content"
184
+ data-testid={`${file.id}-${TEST_IDS.mediaBody}`}
185
+ >
165
186
  <Body type={Typography.BODY_LARGE} className="np-upload-input__title text-word-break">
166
187
  {getTitle()}
167
188
  </Body>
@@ -175,7 +196,10 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
175
196
  aria-label={formatMessage(MESSAGES.removeFile, { filename })}
176
197
  className="np-upload-input__item-button"
177
198
  type="button"
199
+ tabIndex={0}
200
+ data-testid={`${file.id}-${TEST_IDS.action}`}
178
201
  onClick={() => onDelete()}
202
+ onFocus={handleFocus}
179
203
  >
180
204
  <Bin size={16} />
181
205
  </button>
@@ -10,7 +10,14 @@ type UploadItemLinkProps = PropsWithChildren<{
10
10
  export const UploadItemLink = forwardRef<HTMLAnchorElement | HTMLDivElement, UploadItemLinkProps>(
11
11
  ({ children, url, onDownload, singleFileUpload }, ref) => {
12
12
  if (!url) {
13
- return <div ref={ref as React.RefObject<HTMLDivElement>} className={clsx('np-upload-input__item-container')}>{children}</div>;
13
+ return (
14
+ <div
15
+ ref={ref as React.RefObject<HTMLDivElement>}
16
+ className={clsx('np-upload-input__item-container')}
17
+ >
18
+ {children}
19
+ </div>
20
+ );
14
21
  }
15
22
 
16
23
  return (
@@ -23,6 +30,7 @@ export const UploadItemLink = forwardRef<HTMLAnchorElement | HTMLDivElement, Upl
23
30
  'np-upload-input__item-link',
24
31
  singleFileUpload ? 'np-upload-input__item-link--single-file' : '',
25
32
  )}
33
+ tabIndex={0}
26
34
  onClick={onDownload}
27
35
  >
28
36
  {children}