@transferwise/components 0.0.0-experimental-68d1b00 → 0.0.0-experimental-40e4505

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 (62) hide show
  1. package/build/i18n/en.json +0 -2
  2. package/build/i18n/en.json.js +0 -2
  3. package/build/i18n/en.json.js.map +1 -1
  4. package/build/i18n/en.json.mjs +0 -2
  5. package/build/i18n/en.json.mjs.map +1 -1
  6. package/build/main.css +0 -215
  7. package/build/styles/main.css +0 -215
  8. package/build/types/uploadInput/UploadInput.d.ts +9 -0
  9. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  10. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +3 -1
  11. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  12. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
  13. package/build/uploadInput/UploadInput.js +57 -51
  14. package/build/uploadInput/UploadInput.js.map +1 -1
  15. package/build/uploadInput/UploadInput.mjs +57 -51
  16. package/build/uploadInput/UploadInput.mjs.map +1 -1
  17. package/build/uploadInput/uploadItem/UploadItem.js +8 -3
  18. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  19. package/build/uploadInput/uploadItem/UploadItem.mjs +8 -3
  20. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  21. package/build/uploadInput/uploadItem/UploadItemLink.js +1 -0
  22. package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
  23. package/build/uploadInput/uploadItem/UploadItemLink.mjs +1 -0
  24. package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
  25. package/package.json +3 -3
  26. package/src/i18n/en.json +0 -2
  27. package/src/main.css +0 -215
  28. package/src/main.less +0 -1
  29. package/src/uploadInput/UploadInput.spec.tsx +150 -6
  30. package/src/uploadInput/UploadInput.tsx +83 -60
  31. package/src/uploadInput/uploadItem/UploadItem.tsx +12 -6
  32. package/src/uploadInput/uploadItem/UploadItemLink.tsx +9 -1
  33. package/build/styles/table/Table.css +0 -215
  34. package/build/types/table/Table.d.ts +0 -23
  35. package/build/types/table/Table.d.ts.map +0 -1
  36. package/build/types/table/Table.messages.d.ts +0 -12
  37. package/build/types/table/Table.messages.d.ts.map +0 -1
  38. package/build/types/table/TableCell.d.ts +0 -37
  39. package/build/types/table/TableCell.d.ts.map +0 -1
  40. package/build/types/table/TableHeader.d.ts +0 -12
  41. package/build/types/table/TableHeader.d.ts.map +0 -1
  42. package/build/types/table/TableRow.d.ts +0 -17
  43. package/build/types/table/TableRow.d.ts.map +0 -1
  44. package/build/types/table/TableStatusText.d.ts +0 -9
  45. package/build/types/table/TableStatusText.d.ts.map +0 -1
  46. package/build/types/table/index.d.ts +0 -6
  47. package/build/types/table/index.d.ts.map +0 -1
  48. package/src/table/Table.css +0 -215
  49. package/src/table/Table.less +0 -261
  50. package/src/table/Table.messages.ts +0 -12
  51. package/src/table/Table.spec.tsx +0 -87
  52. package/src/table/Table.story.tsx +0 -352
  53. package/src/table/Table.tsx +0 -121
  54. package/src/table/TableCell.spec.tsx +0 -298
  55. package/src/table/TableCell.tsx +0 -153
  56. package/src/table/TableHeader.spec.tsx +0 -58
  57. package/src/table/TableHeader.tsx +0 -50
  58. package/src/table/TableRow.spec.tsx +0 -104
  59. package/src/table/TableRow.tsx +0 -62
  60. package/src/table/TableStatusText.spec.tsx +0 -53
  61. package/src/table/TableStatusText.tsx +0 -35
  62. package/src/table/index.ts +0 -11
package/src/main.css CHANGED
@@ -5025,221 +5025,6 @@ html:not([dir="rtl"]) .np-navigation-option {
5025
5025
  padding: 0 var(--size-24);
5026
5026
  }
5027
5027
  }
5028
- .np-table {
5029
- width: 100%;
5030
- background-color: transparent;
5031
- }
5032
- .np-table-container {
5033
- padding: 8px;
5034
- padding: var(--size-8);
5035
- background-color: rgba(134,167,189,0.10196);
5036
- background-color: var(--color-background-neutral);
5037
- border-radius: 16px;
5038
- border-radius: var(--radius-medium);
5039
- }
5040
- .np-table-container--loading .np-table-header {
5041
- min-width: calc(160px * 2);
5042
- }
5043
- .np-table-container--center {
5044
- margin-right: auto;
5045
- margin-left: auto;
5046
- }
5047
- .np-table-container--full-width {
5048
- width: 100%;
5049
- }
5050
- .np-table-inner-container {
5051
- background-image: linear-gradient(to right, var(--color-contrast-overlay), var(--color-contrast-overlay)), linear-gradient(to right, var(--color-contrast-overlay), var(--color-contrast-overlay)), linear-gradient(to right, rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0)), linear-gradient(to left, rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0));
5052
- background-position: left center, right center, left center, right center;
5053
- background-repeat: no-repeat;
5054
- background-color: var(--color-contrast-overlay);
5055
- background-size: 15px 100%, 15px 100%, 15px 100%, 15px 100%;
5056
- background-attachment: local, local, scroll, scroll;
5057
- overflow-x: auto;
5058
- border-radius: 10px;
5059
- }
5060
- .np-theme-personal--dark .np-table-inner-container {
5061
- background-image: linear-gradient(to right, var(--color-contrast-overlay), var(--color-contrast-overlay)), linear-gradient(to right, var(--color-contrast-overlay), var(--color-contrast-overlay)), linear-gradient(to right, #000000, rgba(0, 0, 0, 0)), linear-gradient(to left, #000000, rgba(0, 0, 0, 0));
5062
- }
5063
- .np-table-row:last-child .np-table-cell:first-child {
5064
- border-bottom-left-radius: 10px;
5065
- }
5066
- .np-table-row:last-child .np-table-cell:last-child {
5067
- border-bottom-right-radius: 10px;
5068
- }
5069
- .np-table-row--clickable:hover .np-table-cell {
5070
- background-color: var(--color-background-screen-hover);
5071
- cursor: pointer;
5072
- }
5073
- .np-table-row--separator .np-table-cell {
5074
- padding: 0;
5075
- }
5076
- .np-table-header,
5077
- .np-table-cell {
5078
- padding: 0;
5079
- }
5080
- .np-table-header:first-child,
5081
- .np-table-cell:first-child {
5082
- padding-left: 16px;
5083
- padding-left: var(--size-16);
5084
- }
5085
- .np-table-header:first-child .np-table-header-content,
5086
- .np-table-cell:first-child .np-table-header-content,
5087
- .np-table-header:first-child .np-table-cell-content,
5088
- .np-table-cell:first-child .np-table-cell-content {
5089
- padding-left: 0;
5090
- }
5091
- .np-table-header:last-child,
5092
- .np-table-cell:last-child {
5093
- padding-right: 16px;
5094
- padding-right: var(--size-16);
5095
- }
5096
- .np-table-header:last-child .np-table-header-content,
5097
- .np-table-cell:last-child .np-table-header-content,
5098
- .np-table-header:last-child .np-table-cell-content,
5099
- .np-table-cell:last-child .np-table-cell-content {
5100
- padding-right: 0;
5101
- }
5102
- .np-table-header .np-text-body-large-bold,
5103
- .np-table-cell .np-text-body-large-bold {
5104
- font-size: 0.875rem;
5105
- font-size: var(--font-size-14);
5106
- }
5107
- .np-table-header.np-table-header--right,
5108
- .np-table-cell.np-table-cell--right {
5109
- padding-right: calc(8px + 12px);
5110
- padding-right: calc(var(--size-8) + var(--size-12));
5111
- }
5112
- .np-table-header.np-table-header--right > .np-text-body-default,
5113
- .np-table-cell.np-table-cell--right > .np-text-body-default {
5114
- text-align: right;
5115
- }
5116
- .np-table-header.np-table-header--right .np-table-header-content,
5117
- .np-table-cell.np-table-cell--right .np-table-content {
5118
- justify-content: end;
5119
- }
5120
- .np-table-header-content,
5121
- .np-table-content {
5122
- display: flex;
5123
- align-items: center;
5124
- justify-content: start;
5125
- }
5126
- .np-table-header {
5127
- min-width: 160px;
5128
- padding-right: 8px;
5129
- padding-right: var(--size-8);
5130
- padding-bottom: 8px;
5131
- padding-bottom: var(--size-8);
5132
- padding-left: 8px;
5133
- padding-left: var(--size-8);
5134
- background-color: rgba(134,167,189,0.10196);
5135
- background-color: var(--color-background-neutral);
5136
- color: #37517e;
5137
- color: var(--color-content-primary);
5138
- }
5139
- .np-table-header-content {
5140
- padding-top: 5px;
5141
- padding-bottom: 5px;
5142
- white-space: nowrap;
5143
- }
5144
- .np-table-header--has-error {
5145
- color: var(--color-sentiment-negative);
5146
- }
5147
- .np-table-cell {
5148
- padding: 16px 8px;
5149
- padding: var(--size-16) var(--size-8);
5150
- }
5151
- .np-table-cell--primary {
5152
- min-width: 200px;
5153
- }
5154
- .np-table-cell--currency .np-text-body-default {
5155
- white-space: nowrap;
5156
- }
5157
- .np-table-cell--status .status-circle {
5158
- width: 24px;
5159
- height: 24px;
5160
- }
5161
- .np-table-cell--status .status-circle .status-icon > svg {
5162
- width: 21px;
5163
- height: 21px;
5164
- }
5165
- .np-table-cell .tw-chevron {
5166
- margin-left: 8px;
5167
- margin-left: var(--size-8);
5168
- }
5169
- .np-table-cell-separator {
5170
- margin-top: 4px;
5171
- margin-top: var(--size-4);
5172
- margin-bottom: 4px;
5173
- margin-bottom: var(--size-4);
5174
- height: 1px;
5175
- padding: 0;
5176
- background-color: rgba(134,167,189,0.10196);
5177
- background-color: var(--color-background-neutral);
5178
- }
5179
- .np-table-cell .np-text-body-default {
5180
- line-height: 1.375rem;
5181
- line-height: var(--line-height-22);
5182
- }
5183
- .np-table-cell .np-text-body-large-bold {
5184
- display: flex;
5185
- align-items: center;
5186
- color: #37517e;
5187
- color: var(--color-content-primary);
5188
- white-space: nowrap;
5189
- }
5190
- .np-table-cell .np-table-content--success {
5191
- color: var(--color-sentiment-positive);
5192
- }
5193
- .np-table-cell .np-table-content--error {
5194
- color: var(--color-sentiment-negative);
5195
- }
5196
- .np-table-cell .tw-loader {
5197
- margin: 150px auto;
5198
- }
5199
- @media (max-width: 320px) {
5200
- .np-table-cell .tw-loader {
5201
- margin-top: 70px;
5202
- margin-bottom: 70px;
5203
- }
5204
- }
5205
- .np-table-content {
5206
- gap: 12px;
5207
- gap: var(--size-12);
5208
- }
5209
- .np-table-content--success,
5210
- .np-table-content--error {
5211
- gap: 4px;
5212
- gap: var(--size-4);
5213
- }
5214
- .np-table-content--success .np-table-content-text,
5215
- .np-table-content--error .np-table-content-text {
5216
- line-height: 155%;
5217
- }
5218
- .np-table-content-media {
5219
- flex-shrink: 0;
5220
- }
5221
- .np-table-content-body {
5222
- display: flex;
5223
- flex-direction: column;
5224
- font-size: 0.75rem;
5225
- font-size: var(--font-size-12);
5226
- color: #768e9c;
5227
- color: var(--color-content-tertiary);
5228
- }
5229
- .np-table-content--reversed {
5230
- flex-direction: row-reverse;
5231
- }
5232
- .np-table-content--reversed .np-table-content-body {
5233
- align-items: end;
5234
- }
5235
- .np-table-empty-data {
5236
- display: flex;
5237
- align-items: center;
5238
- }
5239
- .np-table-empty-data .status-circle {
5240
- margin-right: 12px;
5241
- margin-right: var(--size-12);
5242
- }
5243
5028
  .np-tile {
5244
5029
  min-width: 120px;
5245
5030
  border-radius: 10px;
package/src/main.less CHANGED
@@ -60,7 +60,6 @@
60
60
  @import "./summary/Summary.less";
61
61
  @import "./switch/Switch.less";
62
62
  @import "./tabs/Tabs.less";
63
- @import "./table/Table.less";
64
63
  @import "./tile/Tile.less";
65
64
  @import "./tooltip/Tooltip.less";
66
65
  @import "./typeahead/Typeahead.less";
@@ -12,6 +12,36 @@ 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
+
15
45
  mockMatchMedia();
16
46
 
17
47
  describe('UploadInput', () => {
@@ -139,15 +169,21 @@ describe('UploadInput', () => {
139
169
  onFilesChange,
140
170
  });
141
171
 
142
- const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
143
- within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
172
+ const fileToDelete = screen.getByTestId('1-uploadItem');
144
173
  await act(async () => {
174
+ within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
145
175
  await jest.runOnlyPendingTimersAsync();
146
176
  });
147
177
 
148
- screen.getByText('Remove').click();
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
+ });
149
186
 
150
- await waitForElementToBeRemoved(fileToDelete);
151
187
  expect(props.onDeleteFile).toHaveBeenCalledWith(files[0].id);
152
188
 
153
189
  expect(onFilesChange).toHaveBeenCalledTimes(2);
@@ -190,9 +226,9 @@ describe('UploadInput', () => {
190
226
  onFilesChange,
191
227
  });
192
228
 
193
- const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
194
- within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
229
+ const fileToDelete = screen.getByTestId('1-uploadItem');
195
230
  await act(async () => {
231
+ within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
196
232
  await jest.runOnlyPendingTimersAsync();
197
233
  });
198
234
 
@@ -212,6 +248,114 @@ describe('UploadInput', () => {
212
248
 
213
249
  expect(screen.queryByLabelText('Remove file ', { exact: false })).not.toBeInTheDocument();
214
250
  });
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
+ });
215
359
  });
216
360
 
217
361
  describe('Max File Upload limit', () => {
@@ -1,5 +1,5 @@
1
1
  import { clsx } from 'clsx';
2
- import { useEffect, useRef, useState, useLayoutEffect } from 'react';
2
+ import { useEffect, useLayoutEffect, 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;
104
111
  }
105
112
 
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
+ */
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,15 +160,15 @@ 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);
164
+ const [nextFocusItem, setNextFocusItem] = useState<NextFocusItem | null>(null);
137
165
  const [mounted, setMounted] = useState(false);
138
166
  const { formatMessage } = useIntl();
139
167
  const itemRefs = useRef<(HTMLDivElement | UploadItemRef | null)[]>([]);
140
168
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
141
169
 
142
170
  const PROGRESS_STATUSES = new Set([Status.PENDING, Status.PROCESSING]);
171
+ const REFOCUS_DELAY = 375; // Set to 400ms to allow animations and Modal to unmount
143
172
 
144
173
  const [uploadedFiles, setUploadedFiles] = useState<readonly UploadedFile[]>(
145
174
  multiple || files.length === 0 ? files : [files[0]],
@@ -147,49 +176,65 @@ const UploadInput = ({
147
176
 
148
177
  const uploadedFilesListReference = useRef(multiple || files.length === 0 ? files : [files[0]]);
149
178
 
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);
179
+ function updateFileList(updateFn: (list: readonly UploadedFile[]) => readonly UploadedFile[]) {
180
+ setUploadedFiles(updateFn);
181
+ uploadedFilesListReference.current = updateFn(uploadedFilesListReference.current);
157
182
  }
158
183
 
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
- };
184
+ function addFileToList(recentUploadedFile: UploadedFile) {
185
+ updateFileList((list) => [...list, recentUploadedFile]);
186
+ }
169
187
 
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
- });
188
+ function removeFileFromList(file: UploadedFile) {
189
+ updateFileList((list) =>
190
+ list.filter((fileInList) => file !== fileInList && file.id !== fileInList.id),
191
+ );
192
+ }
177
193
 
178
- setUploadedFiles(updateListItem);
179
- uploadedFilesListReference.current = updateListItem(uploadedFilesListReference.current);
180
- };
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
+ }
181
201
 
182
- const [fileToRemove, setFileToRemove] = useState<UploadedFile | null>(null);
202
+ function focusNextItem(
203
+ focusId: number | null = null,
204
+ isLastItem = false,
205
+ timeout: number = REFOCUS_DELAY,
206
+ ) {
207
+ requestAnimationFrame(() => {
208
+ setTimeout(() => {
209
+ const nextFocusIdIndex = focusId
210
+ ? uploadedFilesListReference.current.findIndex(
211
+ (item: UploadedFile) => item.id === focusId,
212
+ )
213
+ : -1;
214
+
215
+ if (isLastItem || nextFocusIdIndex === -1) {
216
+ // If it's the last item or no next item, focus on the upload input reference
217
+ uploadInputRef.current?.focus();
218
+ } else {
219
+ const nextFocusElement = itemRefs.current[nextFocusIdIndex];
220
+ nextFocusElement?.focus();
221
+ }
222
+ }, timeout);
223
+ });
224
+ }
183
225
 
184
226
  const removeFile = (file: UploadedFile) => {
185
227
  const { id, status } = file;
186
228
  const index = uploadedFiles.findIndex((f) => f.id === file.id);
187
- setFileToRemoveIndex(index);
229
+ const isLastItem = index === uploadedFiles.length - 1;
230
+ const nextIndexId = uploadedFiles[index + 1]?.id as number;
231
+ const focusId = nextIndexId ?? null;
188
232
 
189
233
  if (status === Status.FAILED) {
234
+ setNextFocusItem({ focusId: focusId ?? null, isLastItem });
190
235
  removeFileFromList(file);
191
- setFileToRemove(file);
192
236
  } else if (onDeleteFile && id) {
237
+ setNextFocusItem({ focusId, isLastItem });
193
238
  modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
194
239
 
195
240
  onDeleteFile(id)
@@ -197,10 +242,8 @@ const UploadInput = ({
197
242
  removeFileFromList(file);
198
243
  })
199
244
  .catch((error) => {
245
+ setNextFocusItem({ focusId, isLastItem });
200
246
  modifyFileInList(file, { error: error as UploadError });
201
- })
202
- .finally(() => {
203
- setFileToRemove(file);
204
247
  });
205
248
  }
206
249
  };
@@ -239,12 +282,10 @@ const UploadInput = ({
239
282
  return numberOfValidFiles >= maxFiles;
240
283
  }
241
284
 
242
- // One or more files selected, create entries for them
243
285
  const addFiles = (selectedFiles: FileList) => {
244
286
  for (let i = 0; i < selectedFiles.length; i += 1) {
245
287
  const file = selectedFiles.item(i);
246
288
 
247
- // Returning a FormData[] array instead of FileList so we can filter out incorrect files
248
289
  const formData = new FormData();
249
290
 
250
291
  if (file) {
@@ -253,14 +294,11 @@ const UploadInput = ({
253
294
 
254
295
  const allowedFileTypes = typeof fileTypes === 'string' ? fileTypes : fileTypes.join(',');
255
296
 
256
- // Check if file type is valid
257
297
  if (!isTypeValid(file, allowedFileTypes)) {
258
298
  handleFileUploadFailure(file, formatMessage(MESSAGES.fileTypeNotSupported));
259
299
  continue;
260
300
  }
261
301
 
262
- // Check if the filesize is valid
263
- // Convert to rough bytes
264
302
  if (!isSizeValid(file, sizeLimit * 1000)) {
265
303
  const failureMessage = sizeLimitErrorMessage || formatMessage(MESSAGES.fileIsTooLarge);
266
304
  handleFileUploadFailure(file, failureMessage);
@@ -275,14 +313,11 @@ const UploadInput = ({
275
313
  continue;
276
314
  }
277
315
 
278
- // Check if the file is already in the list
279
316
  const existingFile = uploadedFiles.find((f) => f.filename === file.name);
280
317
  if (existingFile) {
281
- // Remove the file from the list before adding it again
282
318
  removeFileFromList(existingFile);
283
319
  }
284
320
 
285
- // Add the file to the list
286
321
  formData.append(fileInputName, file);
287
322
  const pendingFile = {
288
323
  id: generateFileId(file),
@@ -292,10 +327,8 @@ const UploadInput = ({
292
327
 
293
328
  addFileToList(pendingFile);
294
329
 
295
- // Start uploading the file
296
330
  onUploadFile(formData)
297
331
  .then(({ id, url, error }: UploadResponse) => {
298
- // Replace the temporary id with the final one received from the API, and also set any errors
299
332
  modifyFileInList(pendingFile, { id, url, error, status: Status.SUCCEEDED });
300
333
  })
301
334
  .catch((error) => {
@@ -303,7 +336,6 @@ const UploadInput = ({
303
336
  });
304
337
 
305
338
  if (!multiple) {
306
- // Only upload a single file
307
339
  break;
308
340
  }
309
341
  }
@@ -311,20 +343,11 @@ const UploadInput = ({
311
343
  };
312
344
 
313
345
  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
346
+ if (nextFocusItem) {
347
+ focusNextItem(nextFocusItem.focusId, nextFocusItem.isLastItem);
326
348
  }
327
- }, [uploadedFiles, fileToRemove, fileToRemoveIndex, itemRefs, uploadInputRef]);
349
+ setNextFocusItem(null);
350
+ }, [nextFocusItem]);
328
351
 
329
352
  useEffect(() => {
330
353
  setMounted(true);