@transferwise/components 46.97.5 → 46.98.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 (221) hide show
  1. package/build/alert/Alert.js +8 -0
  2. package/build/alert/Alert.js.map +1 -1
  3. package/build/alert/Alert.mjs +8 -0
  4. package/build/alert/Alert.mjs.map +1 -1
  5. package/build/common/closeButton/CloseButton.js +3 -1
  6. package/build/common/closeButton/CloseButton.js.map +1 -1
  7. package/build/common/closeButton/CloseButton.mjs +3 -1
  8. package/build/common/closeButton/CloseButton.mjs.map +1 -1
  9. package/build/i18n/cs.json +3 -0
  10. package/build/i18n/cs.json.js +3 -0
  11. package/build/i18n/cs.json.js.map +1 -1
  12. package/build/i18n/cs.json.mjs +3 -0
  13. package/build/i18n/cs.json.mjs.map +1 -1
  14. package/build/i18n/de.json +3 -0
  15. package/build/i18n/de.json.js +3 -0
  16. package/build/i18n/de.json.js.map +1 -1
  17. package/build/i18n/de.json.mjs +3 -0
  18. package/build/i18n/de.json.mjs.map +1 -1
  19. package/build/i18n/en.json +3 -0
  20. package/build/i18n/en.json.js +3 -0
  21. package/build/i18n/en.json.js.map +1 -1
  22. package/build/i18n/en.json.mjs +3 -0
  23. package/build/i18n/en.json.mjs.map +1 -1
  24. package/build/i18n/es.json +3 -0
  25. package/build/i18n/es.json.js +3 -0
  26. package/build/i18n/es.json.js.map +1 -1
  27. package/build/i18n/es.json.mjs +3 -0
  28. package/build/i18n/es.json.mjs.map +1 -1
  29. package/build/i18n/fr.json +3 -0
  30. package/build/i18n/fr.json.js +3 -0
  31. package/build/i18n/fr.json.js.map +1 -1
  32. package/build/i18n/fr.json.mjs +3 -0
  33. package/build/i18n/fr.json.mjs.map +1 -1
  34. package/build/i18n/hu.json +3 -0
  35. package/build/i18n/hu.json.js +3 -0
  36. package/build/i18n/hu.json.js.map +1 -1
  37. package/build/i18n/hu.json.mjs +3 -0
  38. package/build/i18n/hu.json.mjs.map +1 -1
  39. package/build/i18n/id.json +3 -0
  40. package/build/i18n/id.json.js +3 -0
  41. package/build/i18n/id.json.js.map +1 -1
  42. package/build/i18n/id.json.mjs +3 -0
  43. package/build/i18n/id.json.mjs.map +1 -1
  44. package/build/i18n/it.json +3 -0
  45. package/build/i18n/it.json.js +3 -0
  46. package/build/i18n/it.json.js.map +1 -1
  47. package/build/i18n/it.json.mjs +3 -0
  48. package/build/i18n/it.json.mjs.map +1 -1
  49. package/build/i18n/ja.json +3 -0
  50. package/build/i18n/ja.json.js +3 -0
  51. package/build/i18n/ja.json.js.map +1 -1
  52. package/build/i18n/ja.json.mjs +3 -0
  53. package/build/i18n/ja.json.mjs.map +1 -1
  54. package/build/i18n/nl.json +6 -3
  55. package/build/i18n/pl.json +3 -0
  56. package/build/i18n/pl.json.js +3 -0
  57. package/build/i18n/pl.json.js.map +1 -1
  58. package/build/i18n/pl.json.mjs +3 -0
  59. package/build/i18n/pl.json.mjs.map +1 -1
  60. package/build/i18n/pt.json +3 -0
  61. package/build/i18n/pt.json.js +3 -0
  62. package/build/i18n/pt.json.js.map +1 -1
  63. package/build/i18n/pt.json.mjs +3 -0
  64. package/build/i18n/pt.json.mjs.map +1 -1
  65. package/build/i18n/ro.json +3 -0
  66. package/build/i18n/ro.json.js +3 -0
  67. package/build/i18n/ro.json.js.map +1 -1
  68. package/build/i18n/ro.json.mjs +3 -0
  69. package/build/i18n/ro.json.mjs.map +1 -1
  70. package/build/i18n/ru.json +3 -0
  71. package/build/i18n/ru.json.js +3 -0
  72. package/build/i18n/ru.json.js.map +1 -1
  73. package/build/i18n/ru.json.mjs +3 -0
  74. package/build/i18n/ru.json.mjs.map +1 -1
  75. package/build/i18n/th.json +3 -0
  76. package/build/i18n/th.json.js +3 -0
  77. package/build/i18n/th.json.js.map +1 -1
  78. package/build/i18n/th.json.mjs +3 -0
  79. package/build/i18n/th.json.mjs.map +1 -1
  80. package/build/i18n/tr.json +3 -0
  81. package/build/i18n/tr.json.js +3 -0
  82. package/build/i18n/tr.json.js.map +1 -1
  83. package/build/i18n/tr.json.mjs +3 -0
  84. package/build/i18n/tr.json.mjs.map +1 -1
  85. package/build/i18n/zh-CN.json +3 -0
  86. package/build/i18n/zh-CN.json.js +3 -0
  87. package/build/i18n/zh-CN.json.js.map +1 -1
  88. package/build/i18n/zh-CN.json.mjs +3 -0
  89. package/build/i18n/zh-CN.json.mjs.map +1 -1
  90. package/build/i18n/zh-HK.json +3 -0
  91. package/build/i18n/zh-HK.json.js +3 -0
  92. package/build/i18n/zh-HK.json.js.map +1 -1
  93. package/build/i18n/zh-HK.json.mjs +3 -0
  94. package/build/i18n/zh-HK.json.mjs.map +1 -1
  95. package/build/image/Image.js +9 -10
  96. package/build/image/Image.js.map +1 -1
  97. package/build/image/Image.mjs +11 -11
  98. package/build/image/Image.mjs.map +1 -1
  99. package/build/main.css +5 -2
  100. package/build/moneyInput/MoneyInput.js +2 -6
  101. package/build/moneyInput/MoneyInput.js.map +1 -1
  102. package/build/moneyInput/MoneyInput.messages.js +3 -0
  103. package/build/moneyInput/MoneyInput.messages.js.map +1 -1
  104. package/build/moneyInput/MoneyInput.messages.mjs +3 -0
  105. package/build/moneyInput/MoneyInput.messages.mjs.map +1 -1
  106. package/build/moneyInput/MoneyInput.mjs +2 -6
  107. package/build/moneyInput/MoneyInput.mjs.map +1 -1
  108. package/build/phoneNumberInput/PhoneNumberInput.js +36 -2
  109. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  110. package/build/phoneNumberInput/PhoneNumberInput.messages.js +6 -0
  111. package/build/phoneNumberInput/PhoneNumberInput.messages.js.map +1 -1
  112. package/build/phoneNumberInput/PhoneNumberInput.messages.mjs +6 -0
  113. package/build/phoneNumberInput/PhoneNumberInput.messages.mjs.map +1 -1
  114. package/build/phoneNumberInput/PhoneNumberInput.mjs +36 -2
  115. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  116. package/build/styles/circularButton/CircularButton.css +1 -0
  117. package/build/styles/dateInput/DateInput.css +2 -1
  118. package/build/styles/main.css +5 -2
  119. package/build/styles/uploadInput/uploadItem/UploadItem.css +2 -1
  120. package/build/types/alert/Alert.d.ts.map +1 -1
  121. package/build/types/common/closeButton/CloseButton.d.ts +2 -0
  122. package/build/types/common/closeButton/CloseButton.d.ts.map +1 -1
  123. package/build/types/image/Image.d.ts +0 -1
  124. package/build/types/image/Image.d.ts.map +1 -1
  125. package/build/types/moneyInput/MoneyInput.d.ts.map +1 -1
  126. package/build/types/moneyInput/MoneyInput.messages.d.ts +5 -0
  127. package/build/types/moneyInput/MoneyInput.messages.d.ts.map +1 -1
  128. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  129. package/build/types/phoneNumberInput/PhoneNumberInput.messages.d.ts +8 -0
  130. package/build/types/phoneNumberInput/PhoneNumberInput.messages.d.ts.map +1 -1
  131. package/build/types/test-utils/index.d.ts +6 -0
  132. package/build/types/test-utils/index.d.ts.map +1 -1
  133. package/build/types/upload/Upload.d.ts +1 -2
  134. package/build/types/upload/Upload.d.ts.map +1 -1
  135. package/build/types/upload/steps/processingStep/processingStep.d.ts +1 -3
  136. package/build/types/upload/steps/processingStep/processingStep.d.ts.map +1 -1
  137. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  138. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +1 -1
  139. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  140. package/build/types/withDisplayFormat/WithDisplayFormat.d.ts.map +1 -1
  141. package/build/upload/Upload.js +27 -43
  142. package/build/upload/Upload.js.map +1 -1
  143. package/build/upload/Upload.mjs +27 -43
  144. package/build/upload/Upload.mjs.map +1 -1
  145. package/build/upload/steps/processingStep/processingStep.js +1 -3
  146. package/build/upload/steps/processingStep/processingStep.js.map +1 -1
  147. package/build/upload/steps/processingStep/processingStep.mjs +1 -3
  148. package/build/upload/steps/processingStep/processingStep.mjs.map +1 -1
  149. package/build/uploadInput/UploadInput.js +55 -6
  150. package/build/uploadInput/UploadInput.js.map +1 -1
  151. package/build/uploadInput/UploadInput.mjs +55 -6
  152. package/build/uploadInput/UploadInput.mjs.map +1 -1
  153. package/build/uploadInput/uploadItem/UploadItem.js +12 -6
  154. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  155. package/build/uploadInput/uploadItem/UploadItem.mjs +12 -6
  156. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  157. package/build/withDisplayFormat/WithDisplayFormat.js +3 -2
  158. package/build/withDisplayFormat/WithDisplayFormat.js.map +1 -1
  159. package/build/withDisplayFormat/WithDisplayFormat.mjs +3 -2
  160. package/build/withDisplayFormat/WithDisplayFormat.mjs.map +1 -1
  161. package/package.json +6 -9
  162. package/src/alert/Alert.spec.tsx +11 -0
  163. package/src/alert/Alert.story.tsx +23 -9
  164. package/src/alert/Alert.tsx +14 -1
  165. package/src/circularButton/CircularButton.css +1 -0
  166. package/src/circularButton/CircularButton.less +1 -0
  167. package/src/circularButton/CircularButton.tests.story.tsx +23 -0
  168. package/src/common/closeButton/CloseButton.spec.tsx +13 -1
  169. package/src/common/closeButton/CloseButton.tsx +3 -0
  170. package/src/dateInput/DateInput.css +2 -1
  171. package/src/dateInput/DateInput.less +7 -4
  172. package/src/i18n/cs.json +3 -0
  173. package/src/i18n/de.json +3 -0
  174. package/src/i18n/en.json +3 -0
  175. package/src/i18n/es.json +3 -0
  176. package/src/i18n/fr.json +3 -0
  177. package/src/i18n/hu.json +3 -0
  178. package/src/i18n/id.json +3 -0
  179. package/src/i18n/it.json +3 -0
  180. package/src/i18n/ja.json +3 -0
  181. package/src/i18n/nl.json +6 -3
  182. package/src/i18n/pl.json +3 -0
  183. package/src/i18n/pt.json +3 -0
  184. package/src/i18n/ro.json +3 -0
  185. package/src/i18n/ru.json +3 -0
  186. package/src/i18n/th.json +3 -0
  187. package/src/i18n/tr.json +3 -0
  188. package/src/i18n/zh-CN.json +3 -0
  189. package/src/i18n/zh-HK.json +3 -0
  190. package/src/image/Image.spec.tsx +3 -3
  191. package/src/image/Image.tsx +10 -12
  192. package/src/main.css +5 -2
  193. package/src/moneyInput/MoneyInput.messages.ts +5 -0
  194. package/src/moneyInput/MoneyInput.spec.tsx +42 -5
  195. package/src/moneyInput/MoneyInput.story.tsx +11 -2
  196. package/src/moneyInput/MoneyInput.tsx +5 -7
  197. package/src/phoneNumberInput/PhoneNumberInput.messages.ts +8 -0
  198. package/src/phoneNumberInput/PhoneNumberInput.spec.tsx +77 -43
  199. package/src/phoneNumberInput/PhoneNumberInput.tsx +34 -2
  200. package/src/promoCard/__snapshots__/PromoCard.spec.tsx.snap +1 -0
  201. package/src/promoCard/__snapshots__/PromoCardGroup.spec.tsx.snap +2 -0
  202. package/src/test-utils/jest.setup.ts +0 -4
  203. package/src/typeahead/Typeahead.spec.tsx +182 -0
  204. package/src/typeahead/typeaheadInput/TypeaheadInput.spec.tsx +103 -0
  205. package/src/typeahead/util/highlight.spec.tsx +43 -0
  206. package/src/upload/Upload.spec.tsx +63 -0
  207. package/src/upload/Upload.story.tsx +0 -51
  208. package/src/upload/Upload.tests.story.tsx +93 -0
  209. package/src/upload/Upload.tsx +28 -49
  210. package/src/upload/steps/processingStep/processingStep.tsx +2 -7
  211. package/src/uploadInput/UploadInput.tsx +81 -8
  212. package/src/uploadInput/uploadItem/UploadItem.css +2 -1
  213. package/src/uploadInput/uploadItem/UploadItem.less +1 -1
  214. package/src/uploadInput/uploadItem/UploadItem.tsx +11 -6
  215. package/src/withDisplayFormat/WithDisplayFormat.spec.js +11 -15
  216. package/src/withDisplayFormat/WithDisplayFormat.tsx +3 -2
  217. package/src/typeahead/Typeahead.rtl.spec.tsx +0 -54
  218. package/src/typeahead/Typeahead.spec.js +0 -404
  219. package/src/typeahead/typeaheadInput/TypeaheadInput.spec.js +0 -74
  220. package/src/typeahead/typeaheadOption/TypeaheadOption.spec.js +0 -75
  221. package/src/typeahead/util/highlight.spec.js +0 -34
@@ -0,0 +1,103 @@
1
+ import { render, fireEvent, screen } from '../../test-utils';
2
+ import TypeaheadInput, { TypeaheadInputProps } from './TypeaheadInput';
3
+ import { TypeaheadOption } from '../Typeahead';
4
+
5
+ const defaultProps: TypeaheadInputProps<number> = {
6
+ id: 'test-id',
7
+ name: 'test-name',
8
+ typeaheadId: 'test-id',
9
+ value: '',
10
+ selected: [],
11
+ onChange: jest.fn(),
12
+ onKeyDown: jest.fn(),
13
+ onFocus: jest.fn(),
14
+ onPaste: jest.fn(),
15
+ autoComplete: 'off',
16
+ placeholder: 'Search...',
17
+ renderChip: jest.fn(),
18
+ };
19
+
20
+ describe('TypeaheadInput', () => {
21
+ afterEach(() => {
22
+ jest.clearAllMocks();
23
+ });
24
+
25
+ it('renders input with placeholder', () => {
26
+ render(<TypeaheadInput {...defaultProps} />);
27
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
28
+ });
29
+
30
+ it('renders with given value', () => {
31
+ render(<TypeaheadInput {...defaultProps} value="hello" />);
32
+ expect(screen.getByDisplayValue('hello')).toBeInTheDocument();
33
+ });
34
+
35
+ it('calls onChange when input value changes', () => {
36
+ const onChange = jest.fn();
37
+ render(<TypeaheadInput {...defaultProps} onChange={onChange} />);
38
+ const input = screen.getByPlaceholderText('Search...');
39
+ fireEvent.change(input, { target: { value: 'test' } });
40
+ expect(onChange).toHaveBeenCalled();
41
+ });
42
+
43
+ it('calls onFocus when input is focused', () => {
44
+ const onFocus = jest.fn();
45
+ render(<TypeaheadInput {...defaultProps} onFocus={onFocus} />);
46
+ const input = screen.getByPlaceholderText('Search...');
47
+ fireEvent.focus(input);
48
+ expect(onFocus).toHaveBeenCalled();
49
+ });
50
+
51
+ it('calls onPaste when input is pasted into', () => {
52
+ const onPaste = jest.fn();
53
+ render(<TypeaheadInput {...defaultProps} onPaste={onPaste} />);
54
+ const input = screen.getByPlaceholderText('Search...');
55
+ fireEvent.paste(input, { clipboardData: { getData: () => 'pasted' } });
56
+ expect(onPaste).toHaveBeenCalled();
57
+ });
58
+
59
+ it('calls onKeyDown when key is pressed', () => {
60
+ const onKeyDown = jest.fn();
61
+ render(<TypeaheadInput {...defaultProps} onKeyDown={onKeyDown} />);
62
+ const input = screen.getByPlaceholderText('Search...');
63
+ fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
64
+ expect(onKeyDown).toHaveBeenCalled();
65
+ });
66
+
67
+ it('renders chips when multiple is true and selected has items', () => {
68
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
69
+ const renderChip = jest.fn((chip, idx) => <span key={idx}>{chip.label}</span>);
70
+ const selected = [
71
+ { label: 'Chip 1', value: 1 },
72
+ { label: 'Chip 2', value: 2 },
73
+ ];
74
+ render(
75
+ <TypeaheadInput {...defaultProps} multiple selected={selected} renderChip={renderChip} />,
76
+ );
77
+ expect(screen.getByText('Chip 1')).toBeInTheDocument();
78
+ expect(screen.getByText('Chip 2')).toBeInTheDocument();
79
+ });
80
+
81
+ it('does not show placeholder if multiple is true and selected has items', () => {
82
+ const selected: TypeaheadOption<number>[] = [{ label: 'Chip', value: 1 }];
83
+ render(<TypeaheadInput<number> {...defaultProps} multiple selected={selected} />);
84
+ expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument();
85
+ });
86
+
87
+ it('applies input width style when multiple and selected has items', () => {
88
+ const selected = [{ label: 'Chip', value: 1 }];
89
+ const { container } = render(
90
+ <TypeaheadInput {...defaultProps} multiple selected={selected} value="test" />,
91
+ );
92
+ const input = container.querySelector('input');
93
+ expect(input?.style.width).not.toBe('');
94
+ });
95
+
96
+ it('sets aria attributes correctly', () => {
97
+ render(<TypeaheadInput {...defaultProps} dropdownOpen ariaActivedescendant="option-1" />);
98
+ const input = screen.getByRole('combobox');
99
+ expect(input).toHaveAttribute('aria-expanded', 'true');
100
+ expect(input).toHaveAttribute('aria-haspopup', 'listbox');
101
+ expect(input).toHaveAttribute('aria-activedescendant', expect.stringContaining('option-1'));
102
+ });
103
+ });
@@ -0,0 +1,43 @@
1
+ import { render, screen } from '../../test-utils';
2
+ import Highlight from './highlight';
3
+
4
+ describe('Highlight', () => {
5
+ it('renders value with highlighted query (case-insensitive)', () => {
6
+ render(<Highlight value="Hello World" query="world" />);
7
+ expect(screen.getByText('World')).toBeInTheDocument();
8
+ expect(screen.getByText('World').tagName).toBe('STRONG');
9
+ expect(screen.getByText('Hello', { exact: false })).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders value with highlighted query (case-sensitive in output)', () => {
13
+ render(<Highlight value="Hello World" query="HELLO" />);
14
+ expect(screen.getByText('Hello').tagName).toBe('STRONG');
15
+ expect(screen.getByText('World', { exact: false })).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders value without highlight if query not found', () => {
19
+ render(<Highlight value="Hello World" query="foo" />);
20
+ expect(screen.getByText('Hello World')).toBeInTheDocument();
21
+ expect(screen.queryByRole('strong')).not.toBeInTheDocument();
22
+ });
23
+
24
+ it('renders value as is if query is empty', () => {
25
+ render(<Highlight value="Hello World" query="" />);
26
+ expect(screen.getByText('Hello World')).toBeInTheDocument();
27
+ expect(screen.queryByRole('strong')).not.toBeInTheDocument();
28
+ });
29
+
30
+ it('renders value as is if value is empty', () => {
31
+ render(<Highlight value="" query="test" />);
32
+ expect(screen.queryByText(/./)).not.toBeInTheDocument();
33
+ expect(screen.queryByRole('strong')).not.toBeInTheDocument();
34
+ });
35
+
36
+ it('wraps content in span if className is provided', () => {
37
+ const { container } = render(
38
+ <Highlight value="Hello World" query="world" className="highlighted" />,
39
+ );
40
+ expect(container.querySelector('span.highlighted')).toBeInTheDocument();
41
+ expect(container.querySelector('strong')).toBeInTheDocument();
42
+ });
43
+ });
@@ -6,6 +6,14 @@ import { postData } from './utils/postData';
6
6
 
7
7
  jest.mock('./utils/asyncFileRead');
8
8
  jest.mock('./utils/postData');
9
+ jest.mock('commonmark', () => ({
10
+ Parser: jest.fn().mockImplementation(() => ({
11
+ parse: jest.fn(() => ({})),
12
+ })),
13
+ HtmlRenderer: jest.fn().mockImplementation(() => ({
14
+ render: jest.fn(() => ''),
15
+ })),
16
+ }));
9
17
 
10
18
  const TEST_FILE = new File(['test content'], 'test.png', { type: 'image/png' });
11
19
  const INVALID_FILE = new File(['invalid content'], 'invalid.txt', { type: 'text/plain' });
@@ -76,6 +84,11 @@ describe('Upload Component', () => {
76
84
  dispatchEvent: jest.fn(),
77
85
  })),
78
86
  });
87
+ props.onStart.mockReset();
88
+ props.onSuccess.mockReset();
89
+ props.onFailure.mockReset();
90
+ props.onCancel.mockReset();
91
+ props.onChange.mockReset();
79
92
  });
80
93
 
81
94
  afterEach(async () => {
@@ -292,4 +305,54 @@ describe('Upload Component', () => {
292
305
  const errorIcon = await screen.findByLabelText(/Custom error label/i);
293
306
  expect(errorIcon).toBeInTheDocument();
294
307
  });
308
+
309
+ function runAnimationDelayTest(label: string, delay: number) {
310
+ test(`should respect a ${label} animationDelay`, async () => {
311
+ (asyncFileRead as jest.Mock).mockResolvedValue('mockBase64Image');
312
+ (postData as jest.Mock).mockResolvedValue('mockSuccessResponse');
313
+
314
+ const { container, unmount } = render(<Upload {...props} animationDelay={delay} />);
315
+
316
+ await act(async () => {
317
+ const droppableElement = await waitFor(() => container.querySelector('.droppable-area'));
318
+ const dataTransfer = new DataTransfer();
319
+ dataTransfer.items.add(TEST_FILE);
320
+ fireEvent.drop(droppableElement!, { dataTransfer });
321
+ });
322
+
323
+ // Advance half the delay, should still be processing
324
+ await act(async () => {
325
+ jest.advanceTimersByTime(delay / 2);
326
+ });
327
+ expect(container.querySelector('.droppable-processing')).toBeInTheDocument();
328
+ expect(container.querySelector('.droppable-complete')).not.toBeInTheDocument();
329
+
330
+ // Advance the rest of the delay
331
+ await act(async () => {
332
+ jest.advanceTimersByTime(delay / 2 + 10);
333
+ });
334
+ expect(container.querySelector('.droppable-complete')).toBeInTheDocument();
335
+ expect(container.querySelector('.droppable-processing')).not.toBeInTheDocument();
336
+
337
+ unmount();
338
+ });
339
+ }
340
+
341
+ runAnimationDelayTest('short', 50);
342
+ runAnimationDelayTest('long', 500);
343
+
344
+ test('should handle disabled state correctly', async () => {
345
+ const { container } = render(<Upload {...props} usDisabled />);
346
+ const droppableElement = await waitFor(() => container.querySelector('.droppable-area'));
347
+
348
+ const dataTransfer = new DataTransfer();
349
+ dataTransfer.items.add(TEST_FILE);
350
+
351
+ await act(async () => {
352
+ fireEvent.drop(droppableElement!, { dataTransfer });
353
+ });
354
+
355
+ expect(container.querySelector('.droppable-processing')).not.toBeInTheDocument();
356
+ expect(props.onStart).not.toHaveBeenCalled();
357
+ });
295
358
  });
@@ -38,54 +38,3 @@ export const Basic: Story = {
38
38
  onCancel: fn(),
39
39
  },
40
40
  } satisfies Story;
41
-
42
- export const MaxSizes = () => {
43
- const bKB = 1024;
44
- const bMB = 1024 * bKB;
45
-
46
- const dKB = 1000;
47
- const dMB = 1000 * dKB;
48
-
49
- const binarySizes = [
50
- 10 * bMB,
51
- 5 * bMB,
52
- 1 * bMB,
53
- 500 * bKB,
54
- 100 * bKB,
55
- 50 * bKB,
56
- 10 * bKB,
57
- 5 * bKB,
58
- 1 * bKB,
59
- ];
60
-
61
- const decimalSizes = [
62
- 10 * dMB,
63
- 5 * dMB,
64
- 1 * dMB,
65
- 500 * dKB,
66
- 100 * dKB,
67
- 50 * dKB,
68
- 10 * dKB,
69
- 5 * dKB,
70
- 1 * dKB,
71
- ];
72
-
73
- return (
74
- <div style={{ display: 'flex', gap: '1rem' }}>
75
- <div style={{ flex: 1 }}>
76
- {binarySizes.map((maxSize) => (
77
- <Field key={maxSize} label={`Max size: ${maxSize} bytes`}>
78
- <Upload usLabel="Pick a file, any file" maxSize={maxSize} />
79
- </Field>
80
- ))}
81
- </div>
82
- <div style={{ flex: 1 }}>
83
- {decimalSizes.map((maxSize) => (
84
- <Field key={maxSize} label={`Max size: ${maxSize} bytes`}>
85
- <Upload usLabel="Pick a file, any file" maxSize={maxSize} />
86
- </Field>
87
- ))}
88
- </div>
89
- </div>
90
- );
91
- };
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { fn } from '@storybook/test';
3
+
4
+ import Upload from '.';
5
+ import { MAX_SIZE_DEFAULT } from './Upload';
6
+ import { Field } from '../field/Field';
7
+
8
+ const meta = {
9
+ component: Upload,
10
+ title: 'Forms/Upload/Tests',
11
+ argTypes: {
12
+ maxSize: {
13
+ control: {
14
+ type: 'number',
15
+ min: 0,
16
+ },
17
+ },
18
+ },
19
+ } satisfies Meta<typeof Upload>;
20
+
21
+ export default meta;
22
+
23
+ export const MaxSizes = () => {
24
+ const bKB = 1024;
25
+ const bMB = 1024 * bKB;
26
+
27
+ const dKB = 1000;
28
+ const dMB = 1000 * dKB;
29
+
30
+ const binarySizes = [
31
+ 10 * bMB,
32
+ 5 * bMB,
33
+ 1 * bMB,
34
+ 500 * bKB,
35
+ 100 * bKB,
36
+ 50 * bKB,
37
+ 10 * bKB,
38
+ 5 * bKB,
39
+ 1 * bKB,
40
+ ];
41
+
42
+ const decimalSizes = [
43
+ 10 * dMB,
44
+ 5 * dMB,
45
+ 1 * dMB,
46
+ 500 * dKB,
47
+ 100 * dKB,
48
+ 50 * dKB,
49
+ 10 * dKB,
50
+ 5 * dKB,
51
+ 1 * dKB,
52
+ ];
53
+
54
+ return (
55
+ <div style={{ display: 'flex', gap: '1rem' }}>
56
+ <div style={{ flex: 1 }}>
57
+ {binarySizes.map((maxSize) => (
58
+ <Field key={maxSize} label={`Max size: ${maxSize} bytes`}>
59
+ <Upload usLabel="Pick a file, any file" maxSize={maxSize} />
60
+ </Field>
61
+ ))}
62
+ </div>
63
+ <div style={{ flex: 1 }}>
64
+ {decimalSizes.map((maxSize) => (
65
+ <Field key={maxSize} label={`Max size: ${maxSize} bytes`}>
66
+ <Upload usLabel="Pick a file, any file" maxSize={maxSize} />
67
+ </Field>
68
+ ))}
69
+ </div>
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export const AllVariants = () => {
75
+ return (
76
+ <div style={{ display: 'flex', gap: '1rem' }}>
77
+ <div style={{ flex: 1 }}>
78
+ <Field key="small" label="Small Upload">
79
+ <Upload usLabel="Pick a file, any file" size="sm" />
80
+ </Field>
81
+ <Field key="medium" label="Medium Upload">
82
+ <Upload usLabel="Pick a file, any file" size="md" />
83
+ </Field>
84
+ <Field key="large" label="Large Upload">
85
+ <Upload usLabel="Pick a file, any file" size="lg" />
86
+ </Field>
87
+ <Field key="disabled" label="Disabled Upload">
88
+ <Upload usLabel="Pick a file, any file" usDisabled />
89
+ </Field>
90
+ </div>
91
+ </div>
92
+ );
93
+ };
@@ -12,11 +12,6 @@ import { postData, asyncFileRead, isSizeValid, isTypeValid, getFileType } from '
12
12
  import { PostDataFetcher, PostDataHTTPOptions, ResponseError } from './utils/postData/postData';
13
13
  import { ProcessIndicatorStatus } from '../processIndicator';
14
14
 
15
- /*
16
- * This delay is required for the isError/isSuccess to be fired after isProcessing so the processIndicator, will be
17
- * rendered first and then updated with the right status.
18
- */
19
- const ANIMATION_FIX = 10;
20
15
  export const MAX_SIZE_DEFAULT = 5000000;
21
16
 
22
17
  export enum UploadStep {
@@ -85,7 +80,7 @@ export class Upload extends Component<UploadProps, UploadState> {
85
80
  declare props: UploadProps & Required<Pick<UploadProps, keyof typeof Upload.defaultProps>>;
86
81
 
87
82
  static defaultProps = {
88
- animationDelay: 700,
83
+ animationDelay: 300,
89
84
  maxSize: MAX_SIZE_DEFAULT,
90
85
  psButtonDisabled: false,
91
86
  size: 'md',
@@ -160,41 +155,6 @@ export class Upload extends Component<UploadProps, UploadState> {
160
155
  }
161
156
  }
162
157
 
163
- onAnimationCompleted = async (status: ProcessIndicatorStatus) => {
164
- const { response, isProcessing, fileName } = this.state;
165
- const { animationDelay } = this.props;
166
-
167
- if (isProcessing && status === 'succeeded') {
168
- const { onSuccess } = this.props;
169
- this.timeouts = window.setTimeout(() => {
170
- this.setState(
171
- {
172
- isProcessing: false,
173
- isComplete: true,
174
- },
175
- onSuccess
176
- ? () => {
177
- onSuccess(response as string | Response, fileName);
178
- }
179
- : undefined,
180
- );
181
- }, animationDelay);
182
- }
183
-
184
- if (isProcessing && status === 'failed') {
185
- const { onFailure } = this.props;
186
- this.timeouts = window.setTimeout(() => {
187
- this.setState(
188
- {
189
- isProcessing: false,
190
- isComplete: true,
191
- },
192
- onFailure ? () => onFailure(response) : undefined,
193
- );
194
- }, animationDelay);
195
- }
196
- };
197
-
198
158
  asyncPost = async (file: File) => {
199
159
  const { httpOptions, fetcher } = this.props;
200
160
  if (httpOptions == null) {
@@ -209,15 +169,37 @@ export class Upload extends Component<UploadProps, UploadState> {
209
169
  return postData(httpOptions, formData, fetcher);
210
170
  };
211
171
 
212
- asyncResponse = (response: unknown, type: 'success' | 'error') => {
213
- // Gives time to the animation callback to fire.
172
+ handleUploadComplete = (type: 'success' | 'error', response: unknown) => {
173
+ const { animationDelay, onSuccess, onFailure } = this.props;
174
+ const { fileName } = this.state;
175
+
176
+ window.clearTimeout(this.timeouts);
214
177
  this.timeouts = window.setTimeout(() => {
215
- this.setState({
178
+ this.setState(
179
+ {
180
+ isProcessing: false,
181
+ isComplete: true,
182
+ },
183
+ () => {
184
+ if (type === 'success') {
185
+ onSuccess?.(response as string | Response, fileName);
186
+ } else {
187
+ onFailure?.(response);
188
+ }
189
+ },
190
+ );
191
+ }, animationDelay);
192
+ };
193
+
194
+ asyncResponse = (response: unknown, type: 'success' | 'error') => {
195
+ this.setState(
196
+ {
216
197
  response,
217
198
  isError: type === 'error',
218
199
  isSuccess: type === 'success',
219
- });
220
- }, ANIMATION_FIX);
200
+ },
201
+ () => this.handleUploadComplete(type, response),
202
+ );
221
203
  };
222
204
 
223
205
  handleOnClear: React.MouseEventHandler<HTMLButtonElement> = (event) => {
@@ -395,8 +377,6 @@ export class Upload extends Component<UploadProps, UploadState> {
395
377
  />
396
378
  )}
397
379
 
398
- {/* Starts render the step when isSuccess is true so markup is there when css transition kicks in
399
- css transition to work properly */}
400
380
  {(isSuccess || isComplete) && !isError && (
401
381
  <CompleteStep
402
382
  fileName={fileName}
@@ -443,7 +423,6 @@ export class Upload extends Component<UploadProps, UploadState> {
443
423
  psButtonText={psButtonText || intl.formatMessage(messages.psButtonText)}
444
424
  psProcessingText={psProcessingText || intl.formatMessage(messages.psProcessingText)}
445
425
  psButtonDisabled={psButtonDisabled}
446
- onAnimationCompleted={async (status) => this.onAnimationCompleted(status)}
447
426
  onClear={(event) => this.handleOnClear(event)}
448
427
  />
449
428
  )}
@@ -1,13 +1,12 @@
1
1
  import Button from '../../../button';
2
2
  import { Status, Typography } from '../../../common';
3
- import ProcessIndicator, { ProcessIndicatorStatus } from '../../../processIndicator';
3
+ import ProcessIndicator from '../../../processIndicator';
4
4
  import Title from '../../../title';
5
5
 
6
6
  export interface ProcessingStepProps {
7
7
  isComplete: boolean;
8
8
  isError: boolean;
9
9
  isSuccess: boolean;
10
- onAnimationCompleted: (status: ProcessIndicatorStatus) => void;
11
10
  onClear: React.MouseEventHandler<HTMLButtonElement>;
12
11
  psButtonText: string;
13
12
  psProcessingText: string;
@@ -18,7 +17,6 @@ export default function ProcessingStep({
18
17
  isComplete,
19
18
  isError,
20
19
  isSuccess,
21
- onAnimationCompleted,
22
20
  onClear,
23
21
  psButtonText,
24
22
  psProcessingText,
@@ -35,10 +33,7 @@ export default function ProcessingStep({
35
33
  return (
36
34
  <div className="droppable-processing-card droppable-card" aria-hidden={isComplete}>
37
35
  <div className="droppable-card-content">
38
- <ProcessIndicator
39
- status={processStatus}
40
- onAnimationCompleted={(status) => onAnimationCompleted(status)}
41
- />
36
+ <ProcessIndicator status={processStatus} />
42
37
  <Title className="m-y-2" type={Typography.TITLE_BODY} aria-live="polite">
43
38
  {psProcessingText}
44
39
  </Title>
@@ -161,6 +161,7 @@ const UploadInput = ({
161
161
  }: UploadInputProps) => {
162
162
  const inputAttributes = useInputAttributes({ nonLabelable: true });
163
163
  const [markedFileForDelete, setMarkedFileForDelete] = useState<UploadedFile | null>(null);
164
+ const [lastAttemptedDeleteId, setLastAttemptedDeleteId] = useState<string | number | null>(null);
164
165
  const [mounted, setMounted] = useState(false);
165
166
  const { formatMessage } = useIntl();
166
167
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -321,7 +322,13 @@ const UploadInput = ({
321
322
  }
322
323
  }, [onFilesChange, uploadedFiles]); // eslint-disable-line react-hooks/exhaustive-deps
323
324
 
324
- const [nextFocusable, setNextFocusable] = useState<HTMLDivElement | UploadItemRef | null>(
325
+ type NextFocusable =
326
+ | HTMLDivElement
327
+ | UploadItemRef
328
+ | { ref: HTMLDivElement | UploadItemRef; target: 'button' | 'link' }
329
+ | null;
330
+
331
+ const [nextFocusable, setNextFocusable] = useState<NextFocusable>(
325
332
  uploadInputRef.current,
326
333
  );
327
334
 
@@ -331,7 +338,15 @@ const UploadInput = ({
331
338
  });
332
339
 
333
340
  const filesCount = fileRefs.length;
334
- let next: HTMLDivElement | UploadItemRef | null = uploadInputRef.current;
341
+ let next: UploadItemRef | HTMLDivElement | null = uploadInputRef.current;
342
+ let focusTarget: 'button' | 'link' = 'button';
343
+
344
+ // If there will be no files left after deletion, focus the upload button
345
+ if (filesCount === 1) {
346
+ next = uploadInputRef.current;
347
+ setNextFocusable(next);
348
+ return;
349
+ }
335
350
 
336
351
  if (filesCount > 1) {
337
352
  const currentFileIndex = fileRefs.findIndex((file) => file?.id === fileId);
@@ -344,15 +359,68 @@ const UploadInput = ({
344
359
  } else {
345
360
  next = fileRefs[currentFileIndex + 1];
346
361
  }
362
+
363
+ // If next is an UploadItemRef, check if it has a URL (succeeded)
364
+ if (next && 'status' in next) {
365
+ // Find the file object for this ref
366
+ const fileObj = uploadedFiles.find(f => f.id === next?.id);
367
+ if (
368
+ fileObj &&
369
+ (fileObj.status === Status.SUCCEEDED || fileObj.status === Status.DONE) &&
370
+ fileObj.url
371
+ ) {
372
+ focusTarget = 'link';
373
+ }
374
+ }
375
+ setNextFocusable(() => {
376
+ if (next && typeof (next as UploadItemRef).focus === 'function') {
377
+ return { ref: next, target: focusTarget };
378
+ }
379
+ return next;
380
+ });
347
381
  }
348
- setNextFocusable(next);
349
382
  };
350
383
 
351
384
  const handleRefocus = () => {
352
- if (nextFocusable && 'focus' in nextFocusable && typeof nextFocusable.focus === 'function') {
353
- setTimeout(() => {
354
- nextFocusable.focus();
355
- }, 0);
385
+ const focusTarget = nextFocusable;
386
+ if (lastAttemptedDeleteId) {
387
+ setLastAttemptedDeleteId(null);
388
+ return;
389
+ }
390
+ if (focusTarget) {
391
+ // If there are no files left, focus the upload button
392
+ if (
393
+ uploadedFiles.length === 0 &&
394
+ uploadInputRef.current &&
395
+ typeof uploadInputRef.current.focus === 'function'
396
+ ) {
397
+ setTimeout(() => {
398
+ uploadInputRef.current!.focus();
399
+ }, 0);
400
+ } else if (
401
+ typeof focusTarget === 'object' &&
402
+ 'ref' in focusTarget &&
403
+ focusTarget.ref &&
404
+ typeof focusTarget.ref.focus === 'function'
405
+ ) {
406
+ setTimeout(() => {
407
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
408
+ if (
409
+ focusTarget.ref &&
410
+ typeof (focusTarget.ref as UploadItemRef).focus === 'function'
411
+ ) {
412
+ // @ts-expect-error: focus may not exist on all possible ref types, but is safe here
413
+ (focusTarget.ref as UploadItemRef).focus(focusTarget.target);
414
+ }
415
+ }, 0);
416
+ } else if (
417
+ focusTarget &&
418
+ typeof (focusTarget as UploadItemRef).focus === 'function'
419
+ ) {
420
+ setTimeout(() => {
421
+ (focusTarget as UploadItemRef).focus();
422
+ }, 0);
423
+ }
356
424
  }
357
425
  };
358
426
 
@@ -391,10 +459,14 @@ const UploadInput = ({
391
459
  onDelete={
392
460
  file.status === Status.FAILED
393
461
  ? async () => {
462
+ setLastAttemptedDeleteId(file.id);
394
463
  await removeFile(file);
395
464
  handleRefocus();
396
465
  }
397
- : () => setMarkedFileForDelete(file)
466
+ : () => {
467
+ setLastAttemptedDeleteId(file.id);
468
+ setMarkedFileForDelete(file);
469
+ }
398
470
  }
399
471
  onDownload={onDownload}
400
472
  onFocus={() => handleFocus(file.id)}
@@ -451,6 +523,7 @@ const UploadInput = ({
451
523
  void removeFile(markedFileForDelete);
452
524
  }
453
525
  setMarkedFileForDelete(null);
526
+ setLastAttemptedDeleteId(null);
454
527
  }}
455
528
  >
456
529
  {deleteConfirm?.confirmText || formatMessage(MESSAGES.deleteModalConfirmButtonText)}
@@ -152,7 +152,8 @@
152
152
  padding-left: var(--size-16);
153
153
  }
154
154
  .np-upload-input__item .np-upload-input-errors > li::before {
155
- content: '•';
155
+ content: '•' ;
156
+ content: '•' / '';
156
157
  position: absolute;
157
158
  display: block;
158
159
  left: 0;
@@ -158,7 +158,7 @@
158
158
  padding-left: var(--size-16);
159
159
 
160
160
  &::before {
161
- content: '•';
161
+ content: '•' / '';
162
162
  position: absolute;
163
163
  display: block;
164
164
  left: 0;