@transferwise/components 0.0.0-experimental-2aa3886 → 0.0.0-experimental-e783ca8

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 (45) hide show
  1. package/build/i18n/en.json +2 -0
  2. package/build/i18n/en.json.js +2 -0
  3. package/build/i18n/en.json.js.map +1 -1
  4. package/build/i18n/en.json.mjs +2 -0
  5. package/build/i18n/en.json.mjs.map +1 -1
  6. package/build/phoneNumberInput/PhoneNumberInput.js +36 -2
  7. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  8. package/build/phoneNumberInput/PhoneNumberInput.messages.js +6 -0
  9. package/build/phoneNumberInput/PhoneNumberInput.messages.js.map +1 -1
  10. package/build/phoneNumberInput/PhoneNumberInput.messages.mjs +6 -0
  11. package/build/phoneNumberInput/PhoneNumberInput.messages.mjs.map +1 -1
  12. package/build/phoneNumberInput/PhoneNumberInput.mjs +36 -2
  13. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  14. package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
  15. package/build/types/phoneNumberInput/PhoneNumberInput.messages.d.ts +8 -0
  16. package/build/types/phoneNumberInput/PhoneNumberInput.messages.d.ts.map +1 -1
  17. package/build/types/test-utils/index.d.ts +4 -0
  18. package/build/types/test-utils/index.d.ts.map +1 -1
  19. package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
  20. package/build/types/uploadInput/uploadItem/UploadItem.d.ts +1 -1
  21. package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
  22. package/build/uploadInput/UploadInput.js +6 -55
  23. package/build/uploadInput/UploadInput.js.map +1 -1
  24. package/build/uploadInput/UploadInput.mjs +6 -55
  25. package/build/uploadInput/UploadInput.mjs.map +1 -1
  26. package/build/uploadInput/uploadItem/UploadItem.js +6 -12
  27. package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
  28. package/build/uploadInput/uploadItem/UploadItem.mjs +6 -12
  29. package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
  30. package/package.json +3 -6
  31. package/src/i18n/en.json +2 -0
  32. package/src/phoneNumberInput/PhoneNumberInput.messages.ts +8 -0
  33. package/src/phoneNumberInput/PhoneNumberInput.spec.tsx +77 -43
  34. package/src/phoneNumberInput/PhoneNumberInput.tsx +34 -2
  35. package/src/test-utils/jest.setup.ts +0 -4
  36. package/src/typeahead/Typeahead.spec.tsx +182 -0
  37. package/src/typeahead/typeaheadInput/TypeaheadInput.spec.tsx +103 -0
  38. package/src/typeahead/util/highlight.spec.tsx +43 -0
  39. package/src/uploadInput/UploadInput.tsx +8 -81
  40. package/src/uploadInput/uploadItem/UploadItem.tsx +6 -11
  41. package/src/typeahead/Typeahead.rtl.spec.tsx +0 -54
  42. package/src/typeahead/Typeahead.spec.js +0 -404
  43. package/src/typeahead/typeaheadInput/TypeaheadInput.spec.js +0 -74
  44. package/src/typeahead/typeaheadOption/TypeaheadOption.spec.js +0 -75
  45. 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
+ });
@@ -161,7 +161,6 @@ 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);
165
164
  const [mounted, setMounted] = useState(false);
166
165
  const { formatMessage } = useIntl();
167
166
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -322,13 +321,7 @@ const UploadInput = ({
322
321
  }
323
322
  }, [onFilesChange, uploadedFiles]); // eslint-disable-line react-hooks/exhaustive-deps
324
323
 
325
- type NextFocusable =
326
- | HTMLDivElement
327
- | UploadItemRef
328
- | { ref: HTMLDivElement | UploadItemRef; target: 'button' | 'link' }
329
- | null;
330
-
331
- const [nextFocusable, setNextFocusable] = useState<NextFocusable>(
324
+ const [nextFocusable, setNextFocusable] = useState<HTMLDivElement | UploadItemRef | null>(
332
325
  uploadInputRef.current,
333
326
  );
334
327
 
@@ -338,15 +331,7 @@ const UploadInput = ({
338
331
  });
339
332
 
340
333
  const filesCount = fileRefs.length;
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
- }
334
+ let next: HTMLDivElement | UploadItemRef | null = uploadInputRef.current;
350
335
 
351
336
  if (filesCount > 1) {
352
337
  const currentFileIndex = fileRefs.findIndex((file) => file?.id === fileId);
@@ -359,68 +344,15 @@ const UploadInput = ({
359
344
  } else {
360
345
  next = fileRefs[currentFileIndex + 1];
361
346
  }
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
- });
381
347
  }
348
+ setNextFocusable(next);
382
349
  };
383
350
 
384
351
  const handleRefocus = () => {
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
- }
352
+ if (nextFocusable && 'focus' in nextFocusable && typeof nextFocusable.focus === 'function') {
353
+ setTimeout(() => {
354
+ nextFocusable.focus();
355
+ }, 0);
424
356
  }
425
357
  };
426
358
 
@@ -459,14 +391,10 @@ const UploadInput = ({
459
391
  onDelete={
460
392
  file.status === Status.FAILED
461
393
  ? async () => {
462
- setLastAttemptedDeleteId(file.id);
463
394
  await removeFile(file);
464
395
  handleRefocus();
465
396
  }
466
- : () => {
467
- setLastAttemptedDeleteId(file.id);
468
- setMarkedFileForDelete(file);
469
- }
397
+ : () => setMarkedFileForDelete(file)
470
398
  }
471
399
  onDownload={onDownload}
472
400
  onFocus={() => handleFocus(file.id)}
@@ -523,7 +451,6 @@ const UploadInput = ({
523
451
  void removeFile(markedFileForDelete);
524
452
  }
525
453
  setMarkedFileForDelete(null);
526
- setLastAttemptedDeleteId(null);
527
454
  }}
528
455
  >
529
456
  {deleteConfirm?.confirmText || formatMessage(MESSAGES.deleteModalConfirmButtonText)}
@@ -19,7 +19,7 @@ export type UploadItemProps = React.JSX.IntrinsicAttributes & {
19
19
  singleFileUpload: boolean;
20
20
  canDelete: boolean;
21
21
  onDelete: () => void;
22
- onFocus: (target?: 'button' | 'link') => void;
22
+ onFocus: () => void;
23
23
 
24
24
  /**
25
25
  * Callback to be called when the file link is clicked.
@@ -63,15 +63,11 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
63
63
  const buttonRef = useRef<HTMLButtonElement>(null);
64
64
 
65
65
  useImperativeHandle<UploadItemRef, UploadItemRef>(ref, () => ({
66
- focus: (target?: 'button' | 'link'): void => {
67
- if (target === 'button' && buttonRef.current) {
68
- buttonRef.current.focus();
69
- } else if (target === 'link' && linkRef.current) {
70
- linkRef.current.focus();
71
- } else if (buttonRef.current) {
72
- buttonRef.current.focus();
73
- } else if (linkRef.current) {
74
- linkRef.current.focus();
66
+ focus: (): void => {
67
+ if (url) {
68
+ linkRef.current?.focus();
69
+ } else {
70
+ buttonRef.current?.focus();
75
71
  }
76
72
  },
77
73
  id: file.id,
@@ -203,7 +199,6 @@ const UploadItem = forwardRef<UploadItemRef, UploadItemProps>(
203
199
  tabIndex={0}
204
200
  data-testid={`${file.id}-${TEST_IDS.action}`}
205
201
  onClick={() => onDelete()}
206
- // @ts-expect-error: handleFocus is not a standard FocusEventHandler, but required for parent logic
207
202
  onFocus={handleFocus}
208
203
  >
209
204
  <Bin size={16} />
@@ -1,54 +0,0 @@
1
- import { Field } from '../field/Field';
2
- import { mockMatchMedia, render, screen } from '../test-utils';
3
- import Typeahead from './Typeahead';
4
- import { createIntl, createIntlCache } from 'react-intl';
5
- import messages from '../i18n';
6
- import { DEFAULT_LANG, DEFAULT_LOCALE } from '../common';
7
-
8
- mockMatchMedia();
9
-
10
- const cache = createIntlCache();
11
- const intl = createIntl({ locale: DEFAULT_LOCALE, messages: messages[DEFAULT_LANG] }, cache);
12
-
13
- describe('Typeahead', () => {
14
- it('supports `Field` for labeling', () => {
15
- render(
16
- <Field id="test" label="Tags">
17
- <Typeahead
18
- id="test"
19
- name="test"
20
- options={[{ label: 'Test' }]}
21
- intl={intl}
22
- onChange={() => {}}
23
- />
24
- </Field>,
25
- );
26
- expect(screen.getAllByRole('group')[0]).toHaveAccessibleName(/^Tags/);
27
- });
28
-
29
- describe('when no options are provided', () => {
30
- it('does not render a dropdown when no options and no footer are provided', () => {
31
- render(
32
- <Field id="test" label="Tags">
33
- <Typeahead id="test" name="test" options={[]} intl={intl} onChange={() => {}} />
34
- </Field>,
35
- );
36
- expect(screen.queryByRole('menu')).not.toBeInTheDocument();
37
- });
38
- it('does render a dropdown when only a footer is provided', () => {
39
- render(
40
- <Field id="test" label="Tags">
41
- <Typeahead
42
- id="test"
43
- name="test"
44
- options={[]}
45
- intl={intl}
46
- footer={<p>hello</p>}
47
- onChange={() => {}}
48
- />
49
- </Field>,
50
- );
51
- expect(screen.getByRole('menu')).toBeInTheDocument();
52
- });
53
- });
54
- });