@transferwise/components 0.0.0-experimental-8001135 → 0.0.0-experimental-4b8556a
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.
- package/build/alert/Alert.js +49 -52
- package/build/alert/Alert.js.map +1 -1
- package/build/alert/Alert.mjs +50 -53
- package/build/alert/Alert.mjs.map +1 -1
- package/build/types/alert/Alert.d.ts +1 -3
- package/build/types/alert/Alert.d.ts.map +1 -1
- package/build/types/uploadInput/UploadInput.d.ts +9 -0
- package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
- package/build/types/uploadInput/uploadItem/UploadItem.d.ts +3 -1
- package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
- package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
- package/build/uploadInput/UploadInput.js +57 -51
- package/build/uploadInput/UploadInput.js.map +1 -1
- package/build/uploadInput/UploadInput.mjs +57 -51
- package/build/uploadInput/UploadInput.mjs.map +1 -1
- package/build/uploadInput/uploadItem/UploadItem.js +8 -3
- package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
- package/build/uploadInput/uploadItem/UploadItem.mjs +8 -3
- package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
- package/build/uploadInput/uploadItem/UploadItemLink.js +1 -0
- package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
- package/build/uploadInput/uploadItem/UploadItemLink.mjs +1 -0
- package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
- package/package.json +3 -3
- package/src/alert/Alert.spec.tsx +0 -20
- package/src/alert/Alert.story.tsx +2 -50
- package/src/alert/Alert.tsx +50 -57
- package/src/uploadInput/UploadInput.spec.tsx +150 -6
- package/src/uploadInput/UploadInput.tsx +83 -60
- package/src/uploadInput/uploadItem/UploadItem.tsx +12 -6
- package/src/uploadInput/uploadItem/UploadItemLink.tsx +9 -1
package/src/alert/Alert.tsx
CHANGED
|
@@ -52,8 +52,6 @@ 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;
|
|
57
55
|
/** @deprecated Use `InlineAlert` instead. */
|
|
58
56
|
arrow?: `${AlertArrowPosition}`;
|
|
59
57
|
/** @deprecated Use `message` instead. Be aware `message` only accepts plain text or text with **bold** markdown. */
|
|
@@ -90,7 +88,6 @@ export default function Alert({
|
|
|
90
88
|
title,
|
|
91
89
|
type = 'neutral',
|
|
92
90
|
variant = 'desktop',
|
|
93
|
-
active = true,
|
|
94
91
|
}: AlertProps) {
|
|
95
92
|
useEffect(() => {
|
|
96
93
|
if (arrow !== undefined) {
|
|
@@ -136,63 +133,59 @@ export default function Alert({
|
|
|
136
133
|
const closeButtonReference = useRef<HTMLButtonElement>(null);
|
|
137
134
|
|
|
138
135
|
return (
|
|
139
|
-
<div
|
|
140
|
-
{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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>
|
|
177
179
|
)}
|
|
178
|
-
<
|
|
179
|
-
<
|
|
180
|
-
|
|
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>
|
|
180
|
+
<Body as="span" className="d-block" type={Typography.BODY_LARGE}>
|
|
181
|
+
{children || <InlineMarkdown>{message}</InlineMarkdown>}
|
|
182
|
+
</Body>
|
|
191
183
|
</div>
|
|
192
|
-
{
|
|
193
|
-
<CloseButton ref={closeButtonReference} className="m-l-2" onClick={onDismiss} />
|
|
194
|
-
)}
|
|
184
|
+
{action && <Action action={action} className="m-t-1" />}
|
|
195
185
|
</div>
|
|
186
|
+
</div>
|
|
187
|
+
{onDismiss && (
|
|
188
|
+
<CloseButton ref={closeButtonReference} className="m-l-2" onClick={onDismiss} />
|
|
196
189
|
)}
|
|
197
190
|
</div>
|
|
198
191
|
);
|
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 [
|
|
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 FOCUS_TIMEOUT = 400; // 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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
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
|
-
|
|
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 (
|
|
315
|
-
|
|
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
|
-
|
|
349
|
+
setNextFocusItem(null);
|
|
350
|
+
}, [nextFocusItem]);
|
|
328
351
|
|
|
329
352
|
useEffect(() => {
|
|
330
353
|
setMounted(true);
|
|
@@ -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
|
|
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';
|
|
@@ -38,6 +37,8 @@ interface UploadItemRef {
|
|
|
38
37
|
export enum TEST_IDS {
|
|
39
38
|
uploadItem = 'uploadItem',
|
|
40
39
|
mediaBody = 'mediaBody',
|
|
40
|
+
link = 'link',
|
|
41
|
+
action = 'action',
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
|
|
@@ -46,6 +47,7 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
|
|
|
46
47
|
const { status, filename, error, errors, url } = file;
|
|
47
48
|
const linkRef = useRef<HTMLAnchorElement>(null);
|
|
48
49
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
50
|
+
const isSucceeded = [Status.SUCCEEDED, undefined].includes(status) && !!url;
|
|
49
51
|
|
|
50
52
|
useImperativeHandle<UploadItemRef, UploadItemRef>(ref, () => ({
|
|
51
53
|
focus: (): void => {
|
|
@@ -57,8 +59,6 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
|
|
|
57
59
|
},
|
|
58
60
|
}));
|
|
59
61
|
|
|
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,16 +152,20 @@ 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={TEST_IDS.uploadItem}
|
|
155
|
+
data-testid={`${file.id}-${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}`}
|
|
161
162
|
onDownload={onDownloadFile}
|
|
162
163
|
>
|
|
163
164
|
<span className="np-upload-input__icon">{getIcon()}</span>
|
|
164
|
-
<div
|
|
165
|
+
<div
|
|
166
|
+
className="np-upload-input__item-content"
|
|
167
|
+
data-testid={`${file.id}-${TEST_IDS.mediaBody}`}
|
|
168
|
+
>
|
|
165
169
|
<Body type={Typography.BODY_LARGE} className="np-upload-input__title text-word-break">
|
|
166
170
|
{getTitle()}
|
|
167
171
|
</Body>
|
|
@@ -175,6 +179,8 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
|
|
|
175
179
|
aria-label={formatMessage(MESSAGES.removeFile, { filename })}
|
|
176
180
|
className="np-upload-input__item-button"
|
|
177
181
|
type="button"
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
data-testid={`${file.id}-${TEST_IDS.action}`}
|
|
178
184
|
onClick={() => onDelete()}
|
|
179
185
|
>
|
|
180
186
|
<Bin size={16} />
|
|
@@ -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
|
|
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}
|