@transferwise/components 46.63.0 → 46.64.0

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 (49) hide show
  1. package/build/card/Card.js.map +1 -1
  2. package/build/card/Card.mjs.map +1 -1
  3. package/build/circularButton/CircularButton.js.map +1 -1
  4. package/build/circularButton/CircularButton.mjs.map +1 -1
  5. package/build/common/locale/index.js.map +1 -1
  6. package/build/common/locale/index.mjs.map +1 -1
  7. package/build/dateLookup/tableLink/TableLink.js.map +1 -1
  8. package/build/dateLookup/tableLink/TableLink.mjs.map +1 -1
  9. package/build/instructionsList/InstructionsList.js.map +1 -1
  10. package/build/instructionsList/InstructionsList.mjs.map +1 -1
  11. package/build/types/card/Card.d.ts.map +1 -1
  12. package/build/types/circularButton/CircularButton.d.ts.map +1 -1
  13. package/build/types/instructionsList/InstructionsList.d.ts.map +1 -1
  14. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  15. package/build/types/uploadInput/uploadButton/UploadButton.d.ts +1 -1
  16. package/build/types/uploadInput/uploadButton/UploadButton.d.ts.map +1 -1
  17. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +5 -1
  18. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  19. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts +5 -5
  20. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
  21. package/build/uploadInput/UploadInput.js +42 -11
  22. package/build/uploadInput/UploadInput.js.map +1 -1
  23. package/build/uploadInput/UploadInput.mjs +43 -12
  24. package/build/uploadInput/UploadInput.mjs.map +1 -1
  25. package/build/uploadInput/uploadButton/UploadButton.js +14 -7
  26. package/build/uploadInput/uploadButton/UploadButton.js.map +1 -1
  27. package/build/uploadInput/uploadButton/UploadButton.mjs +15 -8
  28. package/build/uploadInput/uploadButton/UploadButton.mjs.map +1 -1
  29. package/build/uploadInput/uploadItem/UploadItem.js +18 -3
  30. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  31. package/build/uploadInput/uploadItem/UploadItem.mjs +18 -3
  32. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  33. package/build/uploadInput/uploadItem/UploadItemLink.js +6 -3
  34. package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
  35. package/build/uploadInput/uploadItem/UploadItemLink.mjs +6 -3
  36. package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
  37. package/package.json +1 -1
  38. package/src/card/Card.spec.tsx +4 -5
  39. package/src/card/Card.story.tsx +4 -6
  40. package/src/card/Card.tsx +3 -2
  41. package/src/circularButton/CircularButton.tsx +1 -1
  42. package/src/common/locale/index.ts +1 -1
  43. package/src/dateLookup/tableLink/TableLink.tsx +15 -15
  44. package/src/instructionsList/InstructionsList.tsx +1 -4
  45. package/src/uploadInput/UploadInput.tests.story.tsx +7 -3
  46. package/src/uploadInput/UploadInput.tsx +50 -8
  47. package/src/uploadInput/uploadButton/UploadButton.tsx +163 -141
  48. package/src/uploadInput/uploadItem/UploadItem.tsx +146 -124
  49. package/src/uploadInput/uploadItem/UploadItemLink.tsx +23 -25
@@ -1,5 +1,5 @@
1
1
  import { clsx } from 'clsx';
2
- import { useEffect, 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';
@@ -101,6 +101,10 @@ export type UploadInputProps = {
101
101
  Pick<UploadItemProps, 'onDownload'> &
102
102
  CommonProps;
103
103
 
104
+ interface UploadItemRef {
105
+ focus: () => void;
106
+ }
107
+
104
108
  function generateFileId(file: File) {
105
109
  const { name, size } = file;
106
110
  const uploadTimeStamp = new Date().getTime();
@@ -131,8 +135,11 @@ const UploadInput = ({
131
135
  const inputAttributes = useInputAttributes({ nonLabelable: true });
132
136
 
133
137
  const [markedFileForDelete, setMarkedFileForDelete] = useState<UploadedFile | null>(null);
138
+ const [fileToRemoveIndex, setFileToRemoveIndex] = useState<number | null>(null);
134
139
  const [mounted, setMounted] = useState(false);
135
140
  const { formatMessage } = useIntl();
141
+ const itemRefs = useRef<(HTMLDivElement | UploadItemRef | null)[]>([]);
142
+ const uploadInputRef = useRef<HTMLInputElement | null>(null);
136
143
 
137
144
  const PROGRESS_STATUSES = new Set([Status.PENDING, Status.PROCESSING]);
138
145
 
@@ -174,21 +181,28 @@ const UploadInput = ({
174
181
  uploadedFilesListReference.current = updateListItem(uploadedFilesListReference.current);
175
182
  };
176
183
 
184
+ const [fileToRemove, setFileToRemove] = useState<UploadedFile | null>(null);
185
+
177
186
  const removeFile = (file: UploadedFile) => {
178
187
  const { id, status } = file;
188
+ const index = uploadedFiles.findIndex((f) => f.id === file.id);
189
+ setFileToRemoveIndex(index);
179
190
 
180
191
  if (status === Status.FAILED) {
181
- // If removing a failed upload, we're just updating the view
182
192
  removeFileFromList(file);
193
+ setFileToRemove(file);
183
194
  } else if (onDeleteFile && id) {
184
- // Set status to PROCESSING
185
195
  modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
186
196
 
187
- // Notify host app about deletion
188
197
  onDeleteFile(id)
189
- .then(() => removeFileFromList(file))
198
+ .then(() => {
199
+ removeFileFromList(file);
200
+ })
190
201
  .catch((error) => {
191
202
  modifyFileInList(file, { error: error as UploadError });
203
+ })
204
+ .finally(() => {
205
+ setFileToRemove(file);
192
206
  });
193
207
  }
194
208
  };
@@ -263,10 +277,18 @@ const UploadInput = ({
263
277
  continue;
264
278
  }
265
279
 
280
+ // Check if the file is already in the list
281
+ const existingFile = uploadedFiles.find((f) => f.filename === file.name);
282
+ if (existingFile) {
283
+ // Remove the file from the list before adding it again
284
+ removeFileFromList(existingFile);
285
+ }
286
+
287
+ // Add the file to the list
266
288
  formData.append(fileInputName, file);
267
289
  const pendingFile = {
268
- id,
269
- filename: name,
290
+ id: generateFileId(file),
291
+ filename: file.name,
270
292
  status: Status.PENDING,
271
293
  };
272
294
 
@@ -290,6 +312,22 @@ const UploadInput = ({
290
312
  }
291
313
  };
292
314
 
315
+ useLayoutEffect(() => {
316
+ if (fileToRemove && fileToRemoveIndex !== null) {
317
+ requestAnimationFrame(() => {
318
+ const nextFocusIndex = Math.min(fileToRemoveIndex, uploadedFiles.length - 1);
319
+ if (itemRefs.current[nextFocusIndex]) {
320
+ itemRefs.current[nextFocusIndex].focus(); // Focus the next UploadItem
321
+ } else {
322
+ // If there's only one item left, focus the UploadButton
323
+ uploadInputRef.current?.focus();
324
+ }
325
+ });
326
+ setFileToRemove(null); // Reset the state
327
+ setFileToRemoveIndex(null); // Reset the index
328
+ }
329
+ }, [uploadedFiles, fileToRemove, fileToRemoveIndex, itemRefs, uploadInputRef]);
330
+
293
331
  useEffect(() => {
294
332
  setMounted(true);
295
333
  }, []);
@@ -307,9 +345,12 @@ const UploadInput = ({
307
345
  className={clsx('np-upload-input', className, { disabled })}
308
346
  {...inputAttributes}
309
347
  >
310
- {uploadedFiles.map((file) => (
348
+ {uploadedFiles.map((file, index) => (
311
349
  <UploadItem
312
350
  key={file.id}
351
+ ref={(el: UploadItemRef | null) => {
352
+ itemRefs.current[index] = el;
353
+ }}
313
354
  file={file}
314
355
  singleFileUpload={!multiple}
315
356
  canDelete={
@@ -326,6 +367,7 @@ const UploadInput = ({
326
367
  ))}
327
368
  {(multiple || (!multiple && !uploadedFiles.length)) && (
328
369
  <UploadButton
370
+ ref={uploadInputRef}
329
371
  id={id}
330
372
  uploadButtonTitle={uploadButtonTitle}
331
373
  disabled={areMaximumFilesUploadedAlready() || disabled}
@@ -1,6 +1,14 @@
1
1
  import { PlusCircle as PlusIcon, Upload as UploadIcon } from '@transferwise/icons';
2
2
  import { clsx } from 'clsx';
3
- import { ChangeEvent, DragEvent, useRef, useState } from 'react';
3
+ import {
4
+ ChangeEvent,
5
+ DragEvent,
6
+ useRef,
7
+ useState,
8
+ forwardRef,
9
+ useImperativeHandle,
10
+ ForwardedRef,
11
+ } from 'react';
4
12
  import { useIntl } from 'react-intl';
5
13
 
6
14
  import Body from '../../body';
@@ -70,169 +78,183 @@ const onDragOver = (event: DragEvent): void => {
70
78
  };
71
79
 
72
80
  const DEFAULT_FILE_INPUT_ID = 'np-upload-button';
73
- const UploadButton = ({
74
- disabled,
75
- multiple,
76
- description,
77
- fileTypes = imageFileTypes,
78
- sizeLimit = DEFAULT_SIZE_LIMIT,
79
- maxFiles,
80
- onChange,
81
- id = DEFAULT_FILE_INPUT_ID,
82
- uploadButtonTitle,
83
- }: UploadButtonProps) => {
84
- const { formatMessage } = useIntl();
85
- const inputReference = useRef<HTMLInputElement>(null);
86
-
87
- const [isDropping, setIsDropping] = useState(false);
88
-
89
- const dragCounter = useRef(0);
90
-
91
- const reset = (): void => {
92
- dragCounter.current = 0;
93
- setIsDropping(false);
94
- };
95
-
96
- const onDragLeave = (event: DragEvent): void => {
97
- event.preventDefault();
98
- dragCounter.current -= 1;
99
- if (dragCounter.current === 0) {
81
+ const UploadButton = forwardRef<HTMLInputElement | null, UploadButtonProps>(
82
+ (
83
+ {
84
+ disabled,
85
+ multiple,
86
+ description,
87
+ fileTypes = imageFileTypes,
88
+ sizeLimit = DEFAULT_SIZE_LIMIT,
89
+ maxFiles,
90
+ onChange,
91
+ id = DEFAULT_FILE_INPUT_ID,
92
+ uploadButtonTitle,
93
+ },
94
+ ref: ForwardedRef<HTMLInputElement | null>,
95
+ ) => {
96
+ const { formatMessage } = useIntl();
97
+ const inputRef = useRef<HTMLInputElement | null>(null);
98
+
99
+ useImperativeHandle(ref, () => {
100
+ if (!inputRef.current) {
101
+ throw new Error('inputRef.current is null');
102
+ }
103
+ return inputRef.current;
104
+ }, []);
105
+
106
+ const [isDropping, setIsDropping] = useState(false);
107
+
108
+ const dragCounter = useRef(0);
109
+
110
+ const reset = (): void => {
111
+ dragCounter.current = 0;
100
112
  setIsDropping(false);
101
- }
102
- };
113
+ };
103
114
 
104
- const onDragEnter = (event: DragEvent): void => {
105
- event.preventDefault();
106
- dragCounter.current += 1;
107
- if (dragCounter.current === 1) {
108
- setIsDropping(true);
109
- }
110
- };
115
+ const onDragLeave = (event: DragEvent): void => {
116
+ event.preventDefault();
117
+ dragCounter.current -= 1;
118
+ if (dragCounter.current === 0) {
119
+ setIsDropping(false);
120
+ }
121
+ };
111
122
 
112
- const onDrop = (event: DragEvent): void => {
113
- event.preventDefault();
114
- reset();
115
- if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
116
- onChange(event.dataTransfer.files);
117
- }
118
- };
123
+ const onDragEnter = (event: DragEvent): void => {
124
+ event.preventDefault();
125
+ dragCounter.current += 1;
126
+ if (dragCounter.current === 1) {
127
+ setIsDropping(true);
128
+ }
129
+ };
130
+
131
+ const onDrop = (event: DragEvent): void => {
132
+ event.preventDefault();
133
+ reset();
134
+ if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
135
+ onChange(event.dataTransfer.files);
136
+ }
137
+ };
119
138
 
120
- const filesSelected = (event: ChangeEvent<HTMLInputElement>): void => {
121
- const { files } = event.target;
139
+ const filesSelected = (event: ChangeEvent<HTMLInputElement>): void => {
140
+ const { files } = event.target;
122
141
 
123
- if (files) {
124
- onChange(files);
142
+ if (files) {
143
+ onChange(files);
125
144
 
126
- if (inputReference.current) {
127
- inputReference.current.value = '';
145
+ if (inputRef.current) {
146
+ inputRef.current.value = '';
147
+ }
128
148
  }
129
- }
130
- };
149
+ };
131
150
 
132
- const getFileTypesDescription = (): string => {
133
- if (fileTypes === '*') {
134
- return fileTypes;
135
- }
151
+ const getFileTypesDescription = (): string => {
152
+ if (fileTypes === '*') {
153
+ return fileTypes;
154
+ }
136
155
 
137
- return getAllowedFileTypes(Array.isArray(fileTypes) ? fileTypes : [fileTypes]).join(', ');
138
- };
156
+ return getAllowedFileTypes(Array.isArray(fileTypes) ? fileTypes : [fileTypes]).join(', ');
157
+ };
139
158
 
140
- function getDescription() {
141
- if (description) {
142
- return description;
143
- }
159
+ function getDescription() {
160
+ if (description) {
161
+ return description;
162
+ }
163
+
164
+ const fileTypesDescription = getFileTypesDescription();
144
165
 
145
- const fileTypesDescription = getFileTypesDescription();
166
+ const derivedFileDescription =
167
+ fileTypesDescription === '*' ? formatMessage(MESSAGES.allFileTypes) : fileTypesDescription;
146
168
 
147
- const derivedFileDescription =
148
- fileTypesDescription === '*' ? formatMessage(MESSAGES.allFileTypes) : fileTypesDescription;
169
+ return formatMessage(MESSAGES.instructions, {
170
+ fileTypes: derivedFileDescription,
171
+ size: Math.round(sizeLimit / 1000),
172
+ });
173
+ }
149
174
 
150
- return formatMessage(MESSAGES.instructions, {
151
- fileTypes: derivedFileDescription,
152
- size: Math.round(sizeLimit / 1000),
153
- });
154
- }
175
+ function getAcceptedTypes(): Pick<React.ComponentPropsWithoutRef<'input'>, 'accept'> {
176
+ const areAllFilesAllowed = getFileTypesDescription() === '*';
155
177
 
156
- function getAcceptedTypes(): Pick<React.ComponentPropsWithoutRef<'input'>, 'accept'> {
157
- const areAllFilesAllowed = getFileTypesDescription() === '*';
178
+ if (areAllFilesAllowed) {
179
+ return {}; // file input by default allows all files
180
+ }
158
181
 
159
- if (areAllFilesAllowed) {
160
- return {}; // file input by default allows all files
182
+ if (Array.isArray(fileTypes)) {
183
+ return { accept: fileTypes.join(',') };
184
+ }
185
+
186
+ return { accept: fileTypes as string };
161
187
  }
162
188
 
163
- if (Array.isArray(fileTypes)) {
164
- return { accept: fileTypes.join(',') };
189
+ function renderDescription() {
190
+ return (
191
+ <Body className={clsx({ 'text-primary': !disabled })}>
192
+ {getDescription()}
193
+ {maxFiles && (
194
+ <>
195
+ <br />
196
+ {`Maximum ${maxFiles} files.`}
197
+ </>
198
+ )}
199
+ </Body>
200
+ );
165
201
  }
166
202
 
167
- return { accept: fileTypes as string };
168
- }
203
+ function renderButtonTitle() {
204
+ if (uploadButtonTitle) {
205
+ return uploadButtonTitle;
206
+ }
207
+ return formatMessage(multiple ? MESSAGES.uploadFiles : MESSAGES.uploadFile);
208
+ }
169
209
 
170
- function renderDescription() {
171
210
  return (
172
- <Body className={clsx({ 'text-primary': !disabled })}>
173
- {getDescription()}
174
- {maxFiles && (
175
- <>
176
- <br />
177
- {`Maximum ${maxFiles} files.`}
178
- </>
211
+ <div
212
+ className={clsx('np-upload-button-container', 'droppable', {
213
+ 'droppable-dropping': isDropping,
214
+ })}
215
+ {...(!disabled && { onDragEnter, onDragLeave, onDrop, onDragOver })}
216
+ >
217
+ <input
218
+ ref={inputRef}
219
+ id={id}
220
+ type="file"
221
+ {...getAcceptedTypes()}
222
+ {...(multiple && { multiple: true })}
223
+ className="tw-droppable-input"
224
+ disabled={disabled}
225
+ name="file-upload"
226
+ data-testid={TEST_IDS.uploadInput}
227
+ onChange={filesSelected}
228
+ />
229
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
230
+ <label htmlFor={id} className={clsx('btn', 'np-upload-button')}>
231
+ <div className="media">
232
+ <div className="np-upload-icon media-middle media-left">
233
+ <UploadIcon size={24} className="text-link" />
234
+ </div>
235
+ <div className="media-body text-xs-left" data-testid={TEST_IDS.mediaBody}>
236
+ <Body type={Typography.BODY_LARGE_BOLD} className="d-block">
237
+ {renderButtonTitle()}
238
+ </Body>
239
+ {renderDescription()}
240
+ </div>
241
+ </div>
242
+ </label>
243
+
244
+ {/* Drop area overlay */}
245
+ {isDropping && (
246
+ <div
247
+ className={clsx('droppable-card', 'droppable-dropping-card', 'droppable-card-content')}
248
+ >
249
+ <PlusIcon className="m-x-1" size={24} />
250
+ <div>{formatMessage(MESSAGES.dropFile)}</div>
251
+ </div>
179
252
  )}
180
- </Body>
253
+ </div>
181
254
  );
182
- }
255
+ },
256
+ );
183
257
 
184
- function renderButtonTitle() {
185
- if (uploadButtonTitle) {
186
- return uploadButtonTitle;
187
- }
188
- return formatMessage(multiple ? MESSAGES.uploadFiles : MESSAGES.uploadFile);
189
- }
190
-
191
- return (
192
- <div
193
- className={clsx('np-upload-button-container', 'droppable', {
194
- 'droppable-dropping': isDropping,
195
- })}
196
- {...(!disabled && { onDragEnter, onDragLeave, onDrop, onDragOver })}
197
- >
198
- <input
199
- ref={inputReference}
200
- id={id}
201
- type="file"
202
- {...getAcceptedTypes()}
203
- {...(multiple && { multiple: true })}
204
- className="tw-droppable-input"
205
- disabled={disabled}
206
- name="file-upload"
207
- data-testid={TEST_IDS.uploadInput}
208
- onChange={filesSelected}
209
- />
210
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
211
- <label htmlFor={id} className={clsx('btn', 'np-upload-button')}>
212
- <div className="media">
213
- <div className="np-upload-icon media-middle media-left">
214
- <UploadIcon size={24} className="text-link" />
215
- </div>
216
- <div className="media-body text-xs-left" data-testid={TEST_IDS.mediaBody}>
217
- <Body type={Typography.BODY_LARGE_BOLD} className="d-block">
218
- {renderButtonTitle()}
219
- </Body>
220
- {renderDescription()}
221
- </div>
222
- </div>
223
- </label>
224
-
225
- {/* Drop area overlay */}
226
- {isDropping && (
227
- <div
228
- className={clsx('droppable-card', 'droppable-dropping-card', 'droppable-card-content')}
229
- >
230
- <PlusIcon className="m-x-1" size={24} />
231
- <div>{formatMessage(MESSAGES.dropFile)}</div>
232
- </div>
233
- )}
234
- </div>
235
- );
236
- };
258
+ UploadButton.displayName = 'UploadButton';
237
259
 
238
260
  export default UploadButton;