@transferwise/components 0.0.0-experimental-e5e0684 → 0.0.0-experimental-37d8e2c

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 (151) hide show
  1. package/build/avatar/Avatar.js +4 -1
  2. package/build/avatar/Avatar.js.map +1 -1
  3. package/build/avatar/Avatar.mjs +4 -1
  4. package/build/avatar/Avatar.mjs.map +1 -1
  5. package/build/avatar/colors/colors.js +1 -1
  6. package/build/avatar/colors/colors.js.map +1 -1
  7. package/build/avatar/colors/colors.mjs +1 -1
  8. package/build/avatar/colors/colors.mjs.map +1 -1
  9. package/build/avatarView/AvatarView.js +207 -0
  10. package/build/avatarView/AvatarView.js.map +1 -0
  11. package/build/avatarView/AvatarView.mjs +205 -0
  12. package/build/avatarView/AvatarView.mjs.map +1 -0
  13. package/build/avatarWrapper/AvatarWrapper.js +6 -9
  14. package/build/avatarWrapper/AvatarWrapper.js.map +1 -1
  15. package/build/avatarWrapper/AvatarWrapper.mjs +5 -8
  16. package/build/avatarWrapper/AvatarWrapper.mjs.map +1 -1
  17. package/build/badge/Badge.js +6 -1
  18. package/build/badge/Badge.js.map +1 -1
  19. package/build/badge/Badge.mjs +6 -1
  20. package/build/badge/Badge.mjs.map +1 -1
  21. package/build/circularButton/CircularButton.js +4 -7
  22. package/build/circularButton/CircularButton.js.map +1 -1
  23. package/build/circularButton/CircularButton.mjs +4 -7
  24. package/build/circularButton/CircularButton.mjs.map +1 -1
  25. package/build/common/circle/Circle.js +24 -1
  26. package/build/common/circle/Circle.js.map +1 -1
  27. package/build/common/circle/Circle.mjs +24 -1
  28. package/build/common/circle/Circle.mjs.map +1 -1
  29. package/build/common/initials.js +12 -0
  30. package/build/common/initials.js.map +1 -0
  31. package/build/common/initials.mjs +10 -0
  32. package/build/common/initials.mjs.map +1 -0
  33. package/build/common/notificationDot/NotificationDot.js +16 -0
  34. package/build/common/notificationDot/NotificationDot.js.map +1 -0
  35. package/build/common/notificationDot/NotificationDot.mjs +14 -0
  36. package/build/common/notificationDot/NotificationDot.mjs.map +1 -0
  37. package/build/display/Display.js +1 -1
  38. package/build/display/Display.js.map +1 -1
  39. package/build/display/Display.mjs +1 -1
  40. package/build/display/Display.mjs.map +1 -1
  41. package/build/index.js +6 -0
  42. package/build/index.js.map +1 -1
  43. package/build/index.mjs +3 -0
  44. package/build/index.mjs.map +1 -1
  45. package/build/main.css +82 -41
  46. package/build/statusIcon/StatusIcon.js +4 -2
  47. package/build/statusIcon/StatusIcon.js.map +1 -1
  48. package/build/statusIcon/StatusIcon.mjs +4 -2
  49. package/build/statusIcon/StatusIcon.mjs.map +1 -1
  50. package/build/styles/avatarGroup/AvatarGroup.css +28 -0
  51. package/build/styles/avatarView/AvatarView.css +22 -0
  52. package/build/styles/badge/Badge.css +5 -5
  53. package/build/styles/circularButton/CircularButton.css +0 -24
  54. package/build/styles/common/circle/Circle.css +17 -0
  55. package/build/styles/common/notificationDot/NotificationDot.css +10 -0
  56. package/build/styles/main.css +82 -41
  57. package/build/styles/statusIcon/StatusIcon.css +0 -12
  58. package/build/typeahead/util/highlight.js +14 -2
  59. package/build/typeahead/util/highlight.js.map +1 -1
  60. package/build/typeahead/util/highlight.mjs +15 -3
  61. package/build/typeahead/util/highlight.mjs.map +1 -1
  62. package/build/types/avatar/Avatar.d.ts +3 -0
  63. package/build/types/avatar/Avatar.d.ts.map +1 -1
  64. package/build/types/avatar/colors/colors.d.ts +1 -1
  65. package/build/types/avatar/colors/colors.d.ts.map +1 -1
  66. package/build/types/avatarGroup/AvatarGroup.d.ts +19 -0
  67. package/build/types/avatarGroup/AvatarGroup.d.ts.map +1 -0
  68. package/build/types/avatarGroup/index.d.ts +3 -0
  69. package/build/types/avatarGroup/index.d.ts.map +1 -0
  70. package/build/types/avatarView/AvatarView.d.ts +25 -0
  71. package/build/types/avatarView/AvatarView.d.ts.map +1 -0
  72. package/build/types/avatarView/index.d.ts +3 -0
  73. package/build/types/avatarView/index.d.ts.map +1 -0
  74. package/build/types/avatarWrapper/AvatarWrapper.d.ts +3 -0
  75. package/build/types/avatarWrapper/AvatarWrapper.d.ts.map +1 -1
  76. package/build/types/badge/Badge.d.ts +2 -1
  77. package/build/types/badge/Badge.d.ts.map +1 -1
  78. package/build/types/circularButton/CircularButton.d.ts.map +1 -1
  79. package/build/types/common/circle/Circle.d.ts +1 -1
  80. package/build/types/common/circle/Circle.d.ts.map +1 -1
  81. package/build/types/common/index.d.ts +1 -0
  82. package/build/types/common/initials.d.ts +2 -0
  83. package/build/types/common/initials.d.ts.map +1 -0
  84. package/build/types/common/notificationDot/NotificationDot.d.ts +8 -0
  85. package/build/types/common/notificationDot/NotificationDot.d.ts.map +1 -0
  86. package/build/types/index.d.ts +4 -1
  87. package/build/types/index.d.ts.map +1 -1
  88. package/build/types/statusIcon/StatusIcon.d.ts +6 -2
  89. package/build/types/statusIcon/StatusIcon.d.ts.map +1 -1
  90. package/build/types/typeahead/util/highlight.d.ts +1 -1
  91. package/build/types/typeahead/util/highlight.d.ts.map +1 -1
  92. package/build/types/uploadInput/UploadInput.d.ts +0 -9
  93. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  94. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +1 -3
  95. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  96. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
  97. package/build/uploadInput/UploadInput.js +51 -57
  98. package/build/uploadInput/UploadInput.js.map +1 -1
  99. package/build/uploadInput/UploadInput.mjs +51 -57
  100. package/build/uploadInput/UploadInput.mjs.map +1 -1
  101. package/build/uploadInput/uploadItem/UploadItem.js +3 -8
  102. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  103. package/build/uploadInput/uploadItem/UploadItem.mjs +3 -8
  104. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  105. package/build/uploadInput/uploadItem/UploadItemLink.js +0 -1
  106. package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
  107. package/build/uploadInput/uploadItem/UploadItemLink.mjs +0 -1
  108. package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
  109. package/package.json +3 -3
  110. package/src/avatar/Avatar.tsx +4 -1
  111. package/src/avatar/colors/colors.ts +4 -4
  112. package/src/avatarGroup/AvatarGroup.css +28 -0
  113. package/src/avatarGroup/AvatarGroup.less +32 -0
  114. package/src/avatarGroup/AvatarGroup.story.tsx +249 -0
  115. package/src/avatarGroup/AvatarGroup.tsx +78 -0
  116. package/src/avatarGroup/index.ts +2 -0
  117. package/src/avatarView/AvatarView.css +22 -0
  118. package/src/avatarView/AvatarView.less +24 -0
  119. package/src/avatarView/AvatarView.story.tsx +425 -0
  120. package/src/avatarView/AvatarView.tsx +217 -0
  121. package/src/avatarView/index.ts +2 -0
  122. package/src/avatarWrapper/AvatarWrapper.tsx +12 -16
  123. package/src/badge/Badge.css +5 -5
  124. package/src/badge/Badge.less +3 -3
  125. package/src/badge/Badge.tsx +10 -1
  126. package/src/circularButton/CircularButton.css +0 -24
  127. package/src/circularButton/CircularButton.less +15 -15
  128. package/src/circularButton/CircularButton.tsx +4 -5
  129. package/src/common/circle/Circle.css +17 -0
  130. package/src/common/circle/Circle.less +22 -0
  131. package/src/common/circle/Circle.tsx +33 -2
  132. package/src/common/index.js +1 -0
  133. package/src/common/initials.ts +13 -0
  134. package/src/common/notificationDot/NotificationDot.css +10 -0
  135. package/src/common/notificationDot/NotificationDot.less +7 -0
  136. package/src/common/notificationDot/NotificationDot.tsx +10 -0
  137. package/src/display/Display.tsx +1 -1
  138. package/src/index.ts +4 -0
  139. package/src/main.css +82 -41
  140. package/src/main.less +3 -0
  141. package/src/statusIcon/StatusIcon.css +0 -12
  142. package/src/statusIcon/StatusIcon.less +6 -6
  143. package/src/statusIcon/StatusIcon.story.tsx +32 -28
  144. package/src/statusIcon/StatusIcon.tsx +18 -4
  145. package/src/test-utils/avatar-img.webp +0 -0
  146. package/src/typeahead/typeaheadOption/TypeaheadOption.spec.js +7 -5
  147. package/src/typeahead/util/highlight.tsx +7 -2
  148. package/src/uploadInput/UploadInput.spec.tsx +6 -150
  149. package/src/uploadInput/UploadInput.tsx +60 -81
  150. package/src/uploadInput/uploadItem/UploadItem.tsx +6 -12
  151. package/src/uploadInput/uploadItem/UploadItemLink.tsx +1 -9
@@ -20,32 +20,36 @@ export const Basic: Story = {
20
20
  };
21
21
 
22
22
  export const Variants: Story = {
23
- render: () => (
24
- <span style={{ display: 'flex', justifyContent: 'space-between', maxWidth: '400px' }}>
25
- {[
26
- Sentiment.POSITIVE,
27
- Sentiment.NEGATIVE,
28
- Sentiment.NEUTRAL,
29
- Sentiment.WARNING,
30
- Sentiment.PENDING,
31
- ].map((sentiment) => {
32
- return (
33
- <span
34
- key={sentiment}
35
- style={{
36
- display: 'flex',
37
- justifyContent: 'space-between',
38
- flexDirection: 'column',
39
- minHeight: '150px',
40
- alignItems: 'center',
41
- }}
42
- >
43
- {([Size.SMALL, Size.MEDIUM, Size.LARGE] as const).map((size) => {
44
- return <StatusIcon key={size} size={size} sentiment={sentiment} />;
45
- })}
46
- </span>
47
- );
48
- })}
49
- </span>
50
- ),
23
+ render: () => {
24
+ const sizes = [16, 24, 32, 40, 48, 56, 72];
25
+ return (
26
+ <span style={{ display: 'flex', justifyContent: 'space-between', maxWidth: '400px' }}>
27
+ {[
28
+ Sentiment.POSITIVE,
29
+ Sentiment.NEGATIVE,
30
+ Sentiment.WARNING,
31
+ Sentiment.NEUTRAL,
32
+ Sentiment.PENDING,
33
+ ].map((sentiment) => {
34
+ return (
35
+ <span
36
+ key={sentiment}
37
+ style={{
38
+ display: 'flex',
39
+ gap: '1em',
40
+ justifyContent: 'space-between',
41
+ flexDirection: 'column',
42
+ minHeight: '150px',
43
+ alignItems: 'center',
44
+ }}
45
+ >
46
+ {sizes.map((size) => {
47
+ return <StatusIcon key={size} size={size} sentiment={sentiment} />;
48
+ })}
49
+ </span>
50
+ );
51
+ })}
52
+ </span>
53
+ );
54
+ },
51
55
  };
@@ -5,9 +5,14 @@ import { SizeSmall, SizeMedium, SizeLarge, Sentiment, Size, Breakpoint } from '.
5
5
  import Circle, { CircleProps } from '../common/circle';
6
6
  import { useMedia } from '../common/hooks/useMedia';
7
7
 
8
+ /**
9
+ * @deprecated use `16 | 24 | 32 | 40 | 48 | 56 | 72` component instead
10
+ */
11
+ type LegacySizes = SizeSmall | SizeMedium | SizeLarge;
12
+
8
13
  export type StatusIconProps = {
9
- sentiment: `${Sentiment}`;
10
- size: SizeSmall | SizeMedium | SizeLarge;
14
+ sentiment?: `${Sentiment}`;
15
+ size?: LegacySizes | 16 | 24 | 32 | 40 | 48 | 56 | 72;
11
16
  };
12
17
 
13
18
  const iconTypeMap = {
@@ -31,13 +36,22 @@ const StatusIcon = ({ sentiment = 'neutral', size: sizeProp = 'md' }: StatusIcon
31
36
  const Icon = iconTypeMap[sentiment];
32
37
  const iconColor = sentiment === 'warning' || sentiment === 'pending' ? 'dark' : 'light';
33
38
  const isTinyViewport = useMedia(`(max-width: ${Breakpoint.ZOOM_400}px)`);
34
- const size = sizeProp === Size.SMALL && isTinyViewport ? 32 : mapLegacySize[sizeProp];
39
+ const size =
40
+ sizeProp === Size.SMALL && isTinyViewport
41
+ ? 32
42
+ : typeof sizeProp === 'string'
43
+ ? mapLegacySize[sizeProp]
44
+ : sizeProp;
35
45
  return (
36
46
  <Circle
37
47
  as="span"
38
48
  size={size}
39
49
  data-testid="status-icon"
40
- className={clsx('status-circle', `status-circle-${sizeProp}`, sentiment)}
50
+ className={clsx(
51
+ 'status-circle',
52
+ // `status-circle-${sizeProp}`,
53
+ sentiment,
54
+ )}
41
55
  >
42
56
  <Icon className={clsx('status-icon', iconColor)} />
43
57
  </Circle>
Binary file
@@ -1,4 +1,4 @@
1
- import { shallow } from 'enzyme';
1
+ import { shallow, mount } from 'enzyme';
2
2
 
3
3
  import { fakeEvent } from '../../common/fakeEvents';
4
4
 
@@ -11,7 +11,7 @@ describe('Typeahead Option', () => {
11
11
 
12
12
  const labelHighlight = () => component.find(Highlight);
13
13
  const noteSpan = () => component.find('.np-text-body-default.m-l-1');
14
- const secondaryTextHighlight = () => component.find('.np-text-body-default.text-ellipsis');
14
+ const secondaryTextHighlight = () => component.find('.np-text-body-default.text-ellipsis > span');
15
15
  const dropdownItem = () => component.find('.dropdown-item');
16
16
 
17
17
  beforeEach(() => {
@@ -21,13 +21,13 @@ describe('Typeahead Option', () => {
21
21
  },
22
22
  };
23
23
 
24
- component = shallow(<TypeaheadOption {...props} />);
24
+ component = mount(<TypeaheadOption {...props} />);
25
25
  });
26
26
 
27
27
  it('renders a label', () => {
28
28
  const label = 'test';
29
29
  component.setProps({ option: { label } });
30
- expect(labelHighlight().dive().text()).toStrictEqual(label);
30
+ expect(labelHighlight().text()).toStrictEqual(label);
31
31
  });
32
32
 
33
33
  it('renders a note', () => {
@@ -41,10 +41,11 @@ describe('Typeahead Option', () => {
41
41
  const label = 'test';
42
42
  const secondary = 'test note';
43
43
  component.setProps({ option: { label, secondary } });
44
- expect(secondaryTextHighlight().dive().text()).toStrictEqual(secondary);
44
+ expect(secondaryTextHighlight().text()).toStrictEqual(secondary);
45
45
  });
46
46
 
47
47
  it('highlights when selected', () => {
48
+ component = shallow(<TypeaheadOption {...props} />);
48
49
  component.setProps({
49
50
  selected: true,
50
51
  });
@@ -53,6 +54,7 @@ describe('Typeahead Option', () => {
53
54
  });
54
55
 
55
56
  it('expect aria-selected to be true', () => {
57
+ component = shallow(<TypeaheadOption {...props} />);
56
58
  component.setProps({
57
59
  selected: true,
58
60
  });
@@ -7,16 +7,21 @@ export default function Highlight({
7
7
  value: string;
8
8
  query: string;
9
9
  }) {
10
+ const content = <HighlightContent value={value} query={query} />;
11
+ return className ? <span className={className}>{content}</span> : content;
12
+ }
13
+
14
+ function HighlightContent({ value, query }: { value: string; query: string }) {
10
15
  if (value && query) {
11
16
  const highlightStart = value.toUpperCase().indexOf(query.trim().toUpperCase());
12
17
  const highlightEnd = highlightStart + query.trim().length;
13
18
  if (highlightStart !== -1) {
14
19
  return (
15
- <span className={className}>
20
+ <>
16
21
  {value.slice(0, Math.max(0, highlightStart))}
17
22
  <strong>{value.slice(highlightStart, highlightEnd)}</strong>
18
23
  {value.slice(Math.max(0, highlightEnd))}
19
- </span>
24
+ </>
20
25
  );
21
26
  }
22
27
  }
@@ -12,36 +12,6 @@ import { TEST_IDS as UPLOAD_ITEM_TEST_IDS } from './uploadItem/UploadItem';
12
12
 
13
13
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
14
14
 
15
- const deleteFileAndWaitForFocus = async (fileToDeleteTestId: string, nextFocusTestId: string) => {
16
- const fileToDelete = screen.getByTestId(fileToDeleteTestId);
17
- const nextFocus = screen.getByTestId(nextFocusTestId);
18
-
19
- await act(async () => {
20
- within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
21
- await jest.runOnlyPendingTimersAsync();
22
- });
23
-
24
- await act(async () => {
25
- const removeButton = screen.queryByText('Remove');
26
- if (removeButton) {
27
- removeButton.click();
28
- await jest.runOnlyPendingTimersAsync();
29
- }
30
- });
31
-
32
- await waitFor(() => {
33
- expect(screen.queryByTestId(fileToDeleteTestId)).not.toBeInTheDocument();
34
- });
35
-
36
- await act(async () => {
37
- await jest.runOnlyPendingTimersAsync();
38
- });
39
-
40
- await waitFor(() => {
41
- expect(nextFocus).toHaveFocus();
42
- });
43
- };
44
-
45
15
  mockMatchMedia();
46
16
 
47
17
  describe('UploadInput', () => {
@@ -169,21 +139,15 @@ describe('UploadInput', () => {
169
139
  onFilesChange,
170
140
  });
171
141
 
172
- const fileToDelete = screen.getByTestId('1-uploadItem');
142
+ const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
143
+ within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
173
144
  await act(async () => {
174
- within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
175
145
  await jest.runOnlyPendingTimersAsync();
176
146
  });
177
147
 
178
- await act(async () => {
179
- screen.getByText('Remove').click();
180
- await jest.runOnlyPendingTimersAsync();
181
- });
182
-
183
- await waitFor(() => {
184
- expect(screen.queryByTestId('1-uploadItem')).not.toBeInTheDocument();
185
- });
148
+ screen.getByText('Remove').click();
186
149
 
150
+ await waitForElementToBeRemoved(fileToDelete);
187
151
  expect(props.onDeleteFile).toHaveBeenCalledWith(files[0].id);
188
152
 
189
153
  expect(onFilesChange).toHaveBeenCalledTimes(2);
@@ -226,9 +190,9 @@ describe('UploadInput', () => {
226
190
  onFilesChange,
227
191
  });
228
192
 
229
- const fileToDelete = screen.getByTestId('1-uploadItem');
193
+ const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
194
+ within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
230
195
  await act(async () => {
231
- within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
232
196
  await jest.runOnlyPendingTimersAsync();
233
197
  });
234
198
 
@@ -248,114 +212,6 @@ describe('UploadInput', () => {
248
212
 
249
213
  expect(screen.queryByLabelText('Remove file ', { exact: false })).not.toBeInTheDocument();
250
214
  });
251
-
252
- it('should focus the next item after a file is deleted', async () => {
253
- const files = [
254
- {
255
- id: 1,
256
- filename: 'Sales-2024-invoice.pdf',
257
- status: Status.DONE,
258
- },
259
- {
260
- id: 2,
261
- filename: 'CoWork-0317-invoice.pdf',
262
- status: Status.DONE,
263
- },
264
- {
265
- id: 3,
266
- filename: 'purchase-receipt.pdf',
267
- status: Status.DONE,
268
- },
269
- ];
270
-
271
- renderComponent({
272
- ...props,
273
- files,
274
- multiple: true,
275
- onFilesChange,
276
- });
277
-
278
- await deleteFileAndWaitForFocus('1-uploadItem', '2-action');
279
- });
280
-
281
- it('should focus the upload input after the last file is deleted', async () => {
282
- const files = [
283
- {
284
- id: 1,
285
- filename: 'Sales-2024-invoice.pdf',
286
- status: Status.DONE,
287
- },
288
- {
289
- id: 2,
290
- filename: 'CoWork-0317-invoice.pdf',
291
- status: Status.DONE,
292
- },
293
- {
294
- id: 3,
295
- filename: 'purchase-receipt.pdf',
296
- status: Status.DONE,
297
- },
298
- ];
299
-
300
- renderComponent({
301
- ...props,
302
- files,
303
- multiple: true,
304
- onFilesChange,
305
- });
306
-
307
- await deleteFileAndWaitForFocus('3-uploadItem', 'uploadInput');
308
- });
309
-
310
- it('should focus the upload input after the only file is deleted', async () => {
311
- const singleFile = [
312
- {
313
- id: 3,
314
- filename: 'purchase-receipt.pdf',
315
- status: Status.DONE,
316
- },
317
- ];
318
-
319
- renderComponent({
320
- ...props,
321
- files: singleFile,
322
- multiple: true,
323
- onFilesChange,
324
- });
325
-
326
- await deleteFileAndWaitForFocus('3-uploadItem', 'uploadInput');
327
- });
328
-
329
- it('should focus the third item, then the second and finally upload input', async () => {
330
- const filesWithFailed = [
331
- {
332
- id: 1,
333
- filename: 'Sales-2024-invoice.pdf',
334
- status: Status.DONE,
335
- },
336
- {
337
- id: 2,
338
- filename: 'CoWork-0317-invoice.pdf',
339
- status: Status.FAILED,
340
- },
341
- {
342
- id: 3,
343
- filename: 'purchase-receipt.pdf',
344
- status: Status.DONE,
345
- },
346
- ];
347
-
348
- renderComponent({
349
- ...props,
350
- files: filesWithFailed,
351
- multiple: true,
352
- onFilesChange,
353
- });
354
-
355
- await deleteFileAndWaitForFocus('3-uploadItem', 'uploadInput');
356
- await deleteFileAndWaitForFocus('1-uploadItem', '2-action');
357
- await deleteFileAndWaitForFocus('2-uploadItem', 'uploadInput');
358
- });
359
215
  });
360
216
 
361
217
  describe('Max File Upload limit', () => {
@@ -1,5 +1,5 @@
1
1
  import { clsx } from 'clsx';
2
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { useEffect, useRef, useState, useLayoutEffect } from 'react';
3
3
  import { useIntl } from 'react-intl';
4
4
 
5
5
  import Button from '../button';
@@ -99,45 +99,16 @@ 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
- */
106
102
  interface UploadItemRef {
107
- /**
108
- * Focuses the UploadItem component.
109
- */
110
103
  focus: () => void;
111
104
  }
112
105
 
113
- /**
114
- * Represents the next item to focus on after an action is performed.
115
- */
116
- type NextFocusItem = {
117
- /** The ID of the next item to focus on. If null, no specific item is targeted. */
118
- focusId: number | null;
119
- /** Indicates if the next item to focus on is the last item in the list. */
120
- isLastItem: boolean;
121
- };
122
-
123
- /**
124
- * Generates a unique ID for a file based on its name, size, and the current timestamp
125
- */
126
106
  function generateFileId(file: File) {
127
107
  const { name, size } = file;
128
108
  const uploadTimeStamp = new Date().getTime();
129
109
  return `${name}_${size}_${uploadTimeStamp}`;
130
110
  }
131
111
 
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
- */
141
112
  const UploadInput = ({
142
113
  files = [],
143
114
  fileInputName = 'file',
@@ -160,15 +131,15 @@ const UploadInput = ({
160
131
  uploadButtonTitle,
161
132
  }: UploadInputProps) => {
162
133
  const inputAttributes = useInputAttributes({ nonLabelable: true });
134
+
163
135
  const [markedFileForDelete, setMarkedFileForDelete] = useState<UploadedFile | null>(null);
164
- const [nextFocusItem, setNextFocusItem] = useState<NextFocusItem | null>(null);
136
+ const [fileToRemoveIndex, setFileToRemoveIndex] = useState<number | null>(null);
165
137
  const [mounted, setMounted] = useState(false);
166
138
  const { formatMessage } = useIntl();
167
139
  const itemRefs = useRef<(HTMLDivElement | UploadItemRef | null)[]>([]);
168
140
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
169
141
 
170
142
  const PROGRESS_STATUSES = new Set([Status.PENDING, Status.PROCESSING]);
171
- const FOCUS_TIMEOUT = 400; // Set to 400ms to allow animations and Modal to unmount
172
143
 
173
144
  const [uploadedFiles, setUploadedFiles] = useState<readonly UploadedFile[]>(
174
145
  multiple || files.length === 0 ? files : [files[0]],
@@ -176,64 +147,50 @@ const UploadInput = ({
176
147
 
177
148
  const uploadedFilesListReference = useRef(multiple || files.length === 0 ? files : [files[0]]);
178
149
 
179
- function updateFileList(updateFn: (list: readonly UploadedFile[]) => readonly UploadedFile[]) {
180
- setUploadedFiles(updateFn);
181
- uploadedFilesListReference.current = updateFn(uploadedFilesListReference.current);
182
- }
183
-
184
150
  function addFileToList(recentUploadedFile: UploadedFile) {
185
- updateFileList((list) => [...list, recentUploadedFile]);
186
- }
151
+ function addToList(listToAddTo: readonly UploadedFile[]) {
152
+ return [...listToAddTo, recentUploadedFile];
153
+ }
187
154
 
188
- function removeFileFromList(file: UploadedFile) {
189
- updateFileList((list) =>
190
- list.filter((fileInList) => file !== fileInList && file.id !== fileInList.id),
191
- );
155
+ setUploadedFiles(addToList);
156
+ uploadedFilesListReference.current = addToList(uploadedFilesListReference.current);
192
157
  }
193
158
 
194
- function modifyFileInList(file: UploadedFile, updates: Partial<UploadedFile>) {
195
- updateFileList((list) =>
196
- list.map((fileInList) =>
197
- fileInList === file || fileInList.id === file.id ? { ...file, ...updates } : fileInList,
198
- ),
199
- );
200
- }
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
+ }
201
165
 
202
- function focusNextItem(
203
- focusId: number | null = null,
204
- isLastItem = false,
205
- timeout: number = FOCUS_TIMEOUT,
206
- ) {
207
- const nextFocusIdIndex = focusId
208
- ? uploadedFilesListReference.current.findIndex((item: UploadedFile) => item.id === focusId)
209
- : -1;
210
-
211
- requestAnimationFrame(() => {
212
- setTimeout(() => {
213
- if (isLastItem || nextFocusIdIndex === -1) {
214
- // If it's the last item or no next item, focus on the upload input reference
215
- uploadInputRef.current?.focus();
216
- } else {
217
- const nextFocusElement = itemRefs.current[nextFocusIdIndex];
218
- nextFocusElement?.focus();
219
- }
220
- }, timeout);
221
- });
222
- }
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
+ });
177
+
178
+ setUploadedFiles(updateListItem);
179
+ uploadedFilesListReference.current = updateListItem(uploadedFilesListReference.current);
180
+ };
181
+
182
+ const [fileToRemove, setFileToRemove] = useState<UploadedFile | null>(null);
223
183
 
224
184
  const removeFile = (file: UploadedFile) => {
225
185
  const { id, status } = file;
226
186
  const index = uploadedFiles.findIndex((f) => f.id === file.id);
227
- const isLastItem = index === uploadedFiles.length - 1;
228
- const nextIndexId = uploadedFiles[index + 1]?.id as number;
229
- const focusId = nextIndexId ?? null;
187
+ setFileToRemoveIndex(index);
230
188
 
231
189
  if (status === Status.FAILED) {
232
190
  removeFileFromList(file);
233
- setNextFocusItem({ focusId: focusId ?? null, isLastItem });
191
+ setFileToRemove(file);
234
192
  } else if (onDeleteFile && id) {
235
193
  modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
236
- setNextFocusItem({ focusId, isLastItem });
237
194
 
238
195
  onDeleteFile(id)
239
196
  .then(() => {
@@ -241,7 +198,9 @@ const UploadInput = ({
241
198
  })
242
199
  .catch((error) => {
243
200
  modifyFileInList(file, { error: error as UploadError });
244
- setNextFocusItem({ focusId, isLastItem });
201
+ })
202
+ .finally(() => {
203
+ setFileToRemove(file);
245
204
  });
246
205
  }
247
206
  };
@@ -280,10 +239,12 @@ const UploadInput = ({
280
239
  return numberOfValidFiles >= maxFiles;
281
240
  }
282
241
 
242
+ // One or more files selected, create entries for them
283
243
  const addFiles = (selectedFiles: FileList) => {
284
244
  for (let i = 0; i < selectedFiles.length; i += 1) {
285
245
  const file = selectedFiles.item(i);
286
246
 
247
+ // Returning a FormData[] array instead of FileList so we can filter out incorrect files
287
248
  const formData = new FormData();
288
249
 
289
250
  if (file) {
@@ -292,11 +253,14 @@ const UploadInput = ({
292
253
 
293
254
  const allowedFileTypes = typeof fileTypes === 'string' ? fileTypes : fileTypes.join(',');
294
255
 
256
+ // Check if file type is valid
295
257
  if (!isTypeValid(file, allowedFileTypes)) {
296
258
  handleFileUploadFailure(file, formatMessage(MESSAGES.fileTypeNotSupported));
297
259
  continue;
298
260
  }
299
261
 
262
+ // Check if the filesize is valid
263
+ // Convert to rough bytes
300
264
  if (!isSizeValid(file, sizeLimit * 1000)) {
301
265
  const failureMessage = sizeLimitErrorMessage || formatMessage(MESSAGES.fileIsTooLarge);
302
266
  handleFileUploadFailure(file, failureMessage);
@@ -311,11 +275,14 @@ const UploadInput = ({
311
275
  continue;
312
276
  }
313
277
 
278
+ // Check if the file is already in the list
314
279
  const existingFile = uploadedFiles.find((f) => f.filename === file.name);
315
280
  if (existingFile) {
281
+ // Remove the file from the list before adding it again
316
282
  removeFileFromList(existingFile);
317
283
  }
318
284
 
285
+ // Add the file to the list
319
286
  formData.append(fileInputName, file);
320
287
  const pendingFile = {
321
288
  id: generateFileId(file),
@@ -325,8 +292,10 @@ const UploadInput = ({
325
292
 
326
293
  addFileToList(pendingFile);
327
294
 
295
+ // Start uploading the file
328
296
  onUploadFile(formData)
329
297
  .then(({ id, url, error }: UploadResponse) => {
298
+ // Replace the temporary id with the final one received from the API, and also set any errors
330
299
  modifyFileInList(pendingFile, { id, url, error, status: Status.SUCCEEDED });
331
300
  })
332
301
  .catch((error) => {
@@ -334,6 +303,7 @@ const UploadInput = ({
334
303
  });
335
304
 
336
305
  if (!multiple) {
306
+ // Only upload a single file
337
307
  break;
338
308
  }
339
309
  }
@@ -341,11 +311,20 @@ const UploadInput = ({
341
311
  };
342
312
 
343
313
  useLayoutEffect(() => {
344
- if (nextFocusItem) {
345
- focusNextItem(nextFocusItem.focusId, nextFocusItem.isLastItem);
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
346
326
  }
347
- setNextFocusItem(null);
348
- }, [nextFocusItem]);
327
+ }, [uploadedFiles, fileToRemove, fileToRemoveIndex, itemRefs, uploadInputRef]);
349
328
 
350
329
  useEffect(() => {
351
330
  setMounted(true);