@transferwise/components 0.0.0-experimental-4b8556a → 0.0.0-experimental-d1715ff

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 (34) hide show
  1. package/build/alert/Alert.js +52 -49
  2. package/build/alert/Alert.js.map +1 -1
  3. package/build/alert/Alert.mjs +53 -50
  4. package/build/alert/Alert.mjs.map +1 -1
  5. package/build/types/alert/Alert.d.ts +3 -1
  6. package/build/types/alert/Alert.d.ts.map +1 -1
  7. package/build/types/uploadInput/UploadInput.d.ts +0 -9
  8. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  9. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +1 -3
  10. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  11. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
  12. package/build/uploadInput/UploadInput.js +51 -57
  13. package/build/uploadInput/UploadInput.js.map +1 -1
  14. package/build/uploadInput/UploadInput.mjs +51 -57
  15. package/build/uploadInput/UploadInput.mjs.map +1 -1
  16. package/build/uploadInput/uploadItem/UploadItem.js +3 -8
  17. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  18. package/build/uploadInput/uploadItem/UploadItem.mjs +3 -8
  19. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  20. package/build/uploadInput/uploadItem/UploadItemLink.js +0 -1
  21. package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
  22. package/build/uploadInput/uploadItem/UploadItemLink.mjs +0 -1
  23. package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
  24. package/package.json +5 -5
  25. package/src/alert/Alert.spec.tsx +20 -0
  26. package/src/alert/Alert.story.tsx +50 -2
  27. package/src/alert/Alert.tsx +57 -50
  28. package/src/test-utils/assets/avatar-rectangle-fox.webp +0 -0
  29. package/src/test-utils/assets/avatar-square-dude.webp +0 -0
  30. package/src/test-utils/assets/tapestry-01.png +0 -0
  31. package/src/uploadInput/UploadInput.spec.tsx +6 -150
  32. package/src/uploadInput/UploadInput.tsx +60 -83
  33. package/src/uploadInput/uploadItem/UploadItem.tsx +6 -12
  34. package/src/uploadInput/uploadItem/UploadItemLink.tsx +1 -9
@@ -52,6 +52,8 @@ export interface AlertProps {
52
52
  /** The type dictates which icon and colour will be used */
53
53
  type?: AlertType;
54
54
  variant?: `${Variant}`;
55
+ /** Controls rendering of the Alert component. <br /> Toggle this prop instead using conditional rendering and logical AND (&&) operator, to make the component work with screen readers. */
56
+ active?: boolean;
55
57
  /** @deprecated Use `InlineAlert` instead. */
56
58
  arrow?: `${AlertArrowPosition}`;
57
59
  /** @deprecated Use `message` instead. Be aware `message` only accepts plain text or text with **bold** markdown. */
@@ -88,6 +90,7 @@ export default function Alert({
88
90
  title,
89
91
  type = 'neutral',
90
92
  variant = 'desktop',
93
+ active = true,
91
94
  }: AlertProps) {
92
95
  useEffect(() => {
93
96
  if (arrow !== undefined) {
@@ -133,59 +136,63 @@ export default function Alert({
133
136
  const closeButtonReference = useRef<HTMLButtonElement>(null);
134
137
 
135
138
  return (
136
- <div
137
- className={clsx(
138
- 'alert d-flex',
139
- `alert-${resolvedType}`,
140
- arrow != null && alertArrowClassNames(arrow),
141
- className,
142
- )}
143
- data-testid="alert"
144
- onTouchStart={() => setShouldFire(true)}
145
- onTouchEnd={(event) => {
146
- if (
147
- shouldFire &&
148
- action?.href &&
149
- // Check if current event is triggered from closeButton
150
- event.target instanceof Node &&
151
- closeButtonReference.current &&
152
- !closeButtonReference.current.contains(event.target)
153
- ) {
154
- if (action.target === '_blank') {
155
- window.top?.open(action.href);
156
- } else {
157
- window.top?.location.assign(action.href);
158
- }
159
- }
160
- setShouldFire(false);
161
- }}
162
- onTouchMove={() => setShouldFire(false)}
163
- >
164
- <div
165
- className={clsx('alert__content', 'd-flex', 'flex-grow-1', variant)}
166
- data-testid={variant}
167
- >
168
- {icon ? (
169
- <div className="alert__icon">{icon}</div>
170
- ) : (
171
- <StatusIcon size={Size.LARGE} sentiment={resolvedType} />
172
- )}
173
- <div className="alert__message">
174
- <div role={Sentiment.NEGATIVE === resolvedType ? 'alert' : 'status'}>
175
- {title && (
176
- <Title className="m-b-1" type={Typography.TITLE_BODY}>
177
- {title}
178
- </Title>
139
+ <div role={resolvedType === Sentiment.NEGATIVE ? 'alert' : 'status'}>
140
+ {active && (
141
+ <div
142
+ className={clsx(
143
+ 'alert d-flex',
144
+ `alert-${resolvedType}`,
145
+ arrow != null && alertArrowClassNames(arrow),
146
+ className,
147
+ )}
148
+ data-testid="alert"
149
+ onTouchStart={() => setShouldFire(true)}
150
+ onTouchEnd={(event) => {
151
+ if (
152
+ shouldFire &&
153
+ action?.href &&
154
+ // Check if current event is triggered from closeButton
155
+ event.target instanceof Node &&
156
+ closeButtonReference.current &&
157
+ !closeButtonReference.current.contains(event.target)
158
+ ) {
159
+ if (action.target === '_blank') {
160
+ window.top?.open(action.href);
161
+ } else {
162
+ window.top?.location.assign(action.href);
163
+ }
164
+ }
165
+ setShouldFire(false);
166
+ }}
167
+ onTouchMove={() => setShouldFire(false)}
168
+ >
169
+ <div
170
+ className={clsx('alert__content', 'd-flex', 'flex-grow-1', variant)}
171
+ data-testid={variant}
172
+ >
173
+ {icon ? (
174
+ <div className="alert__icon">{icon}</div>
175
+ ) : (
176
+ <StatusIcon size={Size.LARGE} sentiment={resolvedType} />
179
177
  )}
180
- <Body as="span" className="d-block" type={Typography.BODY_LARGE}>
181
- {children || <InlineMarkdown>{message}</InlineMarkdown>}
182
- </Body>
178
+ <div className="alert__message">
179
+ <div>
180
+ {title && (
181
+ <Title className="m-b-1" type={Typography.TITLE_BODY}>
182
+ {title}
183
+ </Title>
184
+ )}
185
+ <Body as="span" className="d-block" type={Typography.BODY_LARGE}>
186
+ {children || <InlineMarkdown>{message}</InlineMarkdown>}
187
+ </Body>
188
+ </div>
189
+ {action && <Action action={action} className="m-t-1" />}
190
+ </div>
183
191
  </div>
184
- {action && <Action action={action} className="m-t-1" />}
192
+ {onDismiss && (
193
+ <CloseButton ref={closeButtonReference} className="m-l-2" onClick={onDismiss} />
194
+ )}
185
195
  </div>
186
- </div>
187
- {onDismiss && (
188
- <CloseButton ref={closeButtonReference} className="m-l-2" onClick={onDismiss} />
189
196
  )}
190
197
  </div>
191
198
  );
@@ -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,65 +147,49 @@ 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
- 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
- }
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);
225
183
 
226
184
  const removeFile = (file: UploadedFile) => {
227
185
  const { id, status } = file;
228
186
  const index = uploadedFiles.findIndex((f) => f.id === file.id);
229
- const isLastItem = index === uploadedFiles.length - 1;
230
- const nextIndexId = uploadedFiles[index + 1]?.id as number;
231
- const focusId = nextIndexId ?? null;
187
+ setFileToRemoveIndex(index);
232
188
 
233
189
  if (status === Status.FAILED) {
234
- setNextFocusItem({ focusId: focusId ?? null, isLastItem });
235
190
  removeFileFromList(file);
191
+ setFileToRemove(file);
236
192
  } else if (onDeleteFile && id) {
237
- setNextFocusItem({ focusId, isLastItem });
238
193
  modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
239
194
 
240
195
  onDeleteFile(id)
@@ -242,8 +197,10 @@ const UploadInput = ({
242
197
  removeFileFromList(file);
243
198
  })
244
199
  .catch((error) => {
245
- setNextFocusItem({ focusId, isLastItem });
246
200
  modifyFileInList(file, { error: error as UploadError });
201
+ })
202
+ .finally(() => {
203
+ setFileToRemove(file);
247
204
  });
248
205
  }
249
206
  };
@@ -282,10 +239,12 @@ const UploadInput = ({
282
239
  return numberOfValidFiles >= maxFiles;
283
240
  }
284
241
 
242
+ // One or more files selected, create entries for them
285
243
  const addFiles = (selectedFiles: FileList) => {
286
244
  for (let i = 0; i < selectedFiles.length; i += 1) {
287
245
  const file = selectedFiles.item(i);
288
246
 
247
+ // Returning a FormData[] array instead of FileList so we can filter out incorrect files
289
248
  const formData = new FormData();
290
249
 
291
250
  if (file) {
@@ -294,11 +253,14 @@ const UploadInput = ({
294
253
 
295
254
  const allowedFileTypes = typeof fileTypes === 'string' ? fileTypes : fileTypes.join(',');
296
255
 
256
+ // Check if file type is valid
297
257
  if (!isTypeValid(file, allowedFileTypes)) {
298
258
  handleFileUploadFailure(file, formatMessage(MESSAGES.fileTypeNotSupported));
299
259
  continue;
300
260
  }
301
261
 
262
+ // Check if the filesize is valid
263
+ // Convert to rough bytes
302
264
  if (!isSizeValid(file, sizeLimit * 1000)) {
303
265
  const failureMessage = sizeLimitErrorMessage || formatMessage(MESSAGES.fileIsTooLarge);
304
266
  handleFileUploadFailure(file, failureMessage);
@@ -313,11 +275,14 @@ const UploadInput = ({
313
275
  continue;
314
276
  }
315
277
 
278
+ // Check if the file is already in the list
316
279
  const existingFile = uploadedFiles.find((f) => f.filename === file.name);
317
280
  if (existingFile) {
281
+ // Remove the file from the list before adding it again
318
282
  removeFileFromList(existingFile);
319
283
  }
320
284
 
285
+ // Add the file to the list
321
286
  formData.append(fileInputName, file);
322
287
  const pendingFile = {
323
288
  id: generateFileId(file),
@@ -327,8 +292,10 @@ const UploadInput = ({
327
292
 
328
293
  addFileToList(pendingFile);
329
294
 
295
+ // Start uploading the file
330
296
  onUploadFile(formData)
331
297
  .then(({ id, url, error }: UploadResponse) => {
298
+ // Replace the temporary id with the final one received from the API, and also set any errors
332
299
  modifyFileInList(pendingFile, { id, url, error, status: Status.SUCCEEDED });
333
300
  })
334
301
  .catch((error) => {
@@ -336,6 +303,7 @@ const UploadInput = ({
336
303
  });
337
304
 
338
305
  if (!multiple) {
306
+ // Only upload a single file
339
307
  break;
340
308
  }
341
309
  }
@@ -343,11 +311,20 @@ const UploadInput = ({
343
311
  };
344
312
 
345
313
  useLayoutEffect(() => {
346
- if (nextFocusItem) {
347
- 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
348
326
  }
349
- setNextFocusItem(null);
350
- }, [nextFocusItem]);
327
+ }, [uploadedFiles, fileToRemove, fileToRemoveIndex, itemRefs, uploadInputRef]);
351
328
 
352
329
  useEffect(() => {
353
330
  setMounted(true);
@@ -4,8 +4,9 @@ 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 } from '../../common';
7
+ import { Size, Status, Typography, Sentiment } from '../../common';
8
8
  import ProcessIndicator from '../../processIndicator/ProcessIndicator';
9
+ import StatusIcon from '../../statusIcon/StatusIcon';
9
10
  import { UploadedFile, UploadError } from '../types';
10
11
 
11
12
  import MESSAGES from './UploadItem.messages';
@@ -37,8 +38,6 @@ interface UploadItemRef {
37
38
  export enum TEST_IDS {
38
39
  uploadItem = 'uploadItem',
39
40
  mediaBody = 'mediaBody',
40
- link = 'link',
41
- action = 'action',
42
41
  }
43
42
 
44
43
  const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
@@ -47,7 +46,6 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
47
46
  const { status, filename, error, errors, url } = file;
48
47
  const linkRef = useRef<HTMLAnchorElement>(null);
49
48
  const buttonRef = useRef<HTMLButtonElement>(null);
50
- const isSucceeded = [Status.SUCCEEDED, undefined].includes(status) && !!url;
51
49
 
52
50
  useImperativeHandle<UploadItemRef, UploadItemRef>(ref, () => ({
53
51
  focus: (): void => {
@@ -59,6 +57,8 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
59
57
  },
60
58
  }));
61
59
 
60
+ const isSucceeded = [Status.SUCCEEDED, undefined].includes(status) && !!url;
61
+
62
62
  /**
63
63
  * We're temporarily reverting to the regular icon components,
64
64
  * until the StatusIcon receives 24px sizing. Some misalignment
@@ -152,20 +152,16 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
152
152
  return (
153
153
  <div
154
154
  className={clsx('np-upload-input__item', { 'is-interactive': isSucceeded && url })}
155
- data-testid={`${file.id}-${TEST_IDS.uploadItem}`}
155
+ data-testid={TEST_IDS.uploadItem}
156
156
  >
157
157
  <UploadItemLink
158
158
  ref={linkRef}
159
159
  url={isSucceeded ? url : undefined}
160
160
  singleFileUpload={singleFileUpload}
161
- data-testid={`${file.id}-${TEST_IDS.link}`}
162
161
  onDownload={onDownloadFile}
163
162
  >
164
163
  <span className="np-upload-input__icon">{getIcon()}</span>
165
- <div
166
- className="np-upload-input__item-content"
167
- data-testid={`${file.id}-${TEST_IDS.mediaBody}`}
168
- >
164
+ <div className="np-upload-input__item-content" data-testid={TEST_IDS.mediaBody}>
169
165
  <Body type={Typography.BODY_LARGE} className="np-upload-input__title text-word-break">
170
166
  {getTitle()}
171
167
  </Body>
@@ -179,8 +175,6 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
179
175
  aria-label={formatMessage(MESSAGES.removeFile, { filename })}
180
176
  className="np-upload-input__item-button"
181
177
  type="button"
182
- tabIndex={0}
183
- data-testid={`${file.id}-${TEST_IDS.action}`}
184
178
  onClick={() => onDelete()}
185
179
  >
186
180
  <Bin size={16} />
@@ -10,14 +10,7 @@ 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 (
14
- <div
15
- ref={ref as React.RefObject<HTMLDivElement>}
16
- className={clsx('np-upload-input__item-container')}
17
- >
18
- {children}
19
- </div>
20
- );
13
+ return <div ref={ref as React.RefObject<HTMLDivElement>} className={clsx('np-upload-input__item-container')}>{children}</div>;
21
14
  }
22
15
 
23
16
  return (
@@ -30,7 +23,6 @@ export const UploadItemLink = forwardRef<HTMLAnchorElement | HTMLDivElement, Upl
30
23
  'np-upload-input__item-link',
31
24
  singleFileUpload ? 'np-upload-input__item-link--single-file' : '',
32
25
  )}
33
- tabIndex={0}
34
26
  onClick={onDownload}
35
27
  >
36
28
  {children}