@transferwise/components 0.0.0-experimental-2b74aa5 → 0.0.0-experimental-81a961c

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 (37) hide show
  1. package/build/inputs/SelectInput.js +4 -0
  2. package/build/inputs/SelectInput.js.map +1 -1
  3. package/build/inputs/SelectInput.mjs +4 -0
  4. package/build/inputs/SelectInput.mjs.map +1 -1
  5. package/build/moneyInput/MoneyInput.js +1 -1
  6. package/build/moneyInput/MoneyInput.js.map +1 -1
  7. package/build/moneyInput/MoneyInput.mjs +1 -1
  8. package/build/moneyInput/MoneyInput.mjs.map +1 -1
  9. package/build/typeahead/Typeahead.js +13 -35
  10. package/build/typeahead/Typeahead.js.map +1 -1
  11. package/build/typeahead/Typeahead.mjs +14 -35
  12. package/build/typeahead/Typeahead.mjs.map +1 -1
  13. package/build/typeahead/typeaheadInput/TypeaheadInput.js +1 -3
  14. package/build/typeahead/typeaheadInput/TypeaheadInput.js.map +1 -1
  15. package/build/typeahead/typeaheadInput/TypeaheadInput.mjs +1 -3
  16. package/build/typeahead/typeaheadInput/TypeaheadInput.mjs.map +1 -1
  17. package/build/typeahead/typeaheadOption/TypeaheadOption.js +5 -7
  18. package/build/typeahead/typeaheadOption/TypeaheadOption.js.map +1 -1
  19. package/build/typeahead/typeaheadOption/TypeaheadOption.mjs +5 -7
  20. package/build/typeahead/typeaheadOption/TypeaheadOption.mjs.map +1 -1
  21. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  22. package/build/types/typeahead/Typeahead.d.ts +1 -1
  23. package/build/types/typeahead/Typeahead.d.ts.map +1 -1
  24. package/build/types/typeahead/typeaheadInput/TypeaheadInput.d.ts +0 -1
  25. package/build/types/typeahead/typeaheadInput/TypeaheadInput.d.ts.map +1 -1
  26. package/build/types/typeahead/typeaheadOption/TypeaheadOption.d.ts +1 -1
  27. package/build/types/typeahead/typeaheadOption/TypeaheadOption.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/inputs/SelectInput.tsx +5 -2
  30. package/src/moneyInput/MoneyInput.docs.mdx +40 -0
  31. package/src/moneyInput/MoneyInput.rtl.spec.tsx +38 -0
  32. package/src/moneyInput/MoneyInput.spec.js +0 -11
  33. package/src/moneyInput/MoneyInput.story.tsx +10 -7
  34. package/src/moneyInput/MoneyInput.tsx +1 -1
  35. package/src/typeahead/Typeahead.tsx +18 -44
  36. package/src/typeahead/typeaheadInput/TypeaheadInput.tsx +0 -3
  37. package/src/typeahead/typeaheadOption/TypeaheadOption.tsx +5 -7
@@ -258,7 +258,6 @@ export function SelectInput<T = string, M extends boolean = false>({
258
258
  }: SelectInputProps<T, M>) {
259
259
  const inputAttributes = useInputAttributes();
260
260
  const id = idProp ?? inputAttributes.id;
261
-
262
261
  const [open, setOpen] = useState(false);
263
262
 
264
263
  const initialized = useRef(false);
@@ -387,6 +386,7 @@ export function SelectInput<T = string, M extends boolean = false>({
387
386
  }}
388
387
  >
389
388
  <SelectInputOptions
389
+ id={`${id}Search`}
390
390
  items={items}
391
391
  renderValue={renderValue}
392
392
  renderFooter={renderFooter}
@@ -488,7 +488,7 @@ const SelectInputOptionsContainer = forwardRef(function SelectInputOptionsContai
488
488
  interface SelectInputOptionsProps<T = string>
489
489
  extends Pick<
490
490
  SelectInputProps<T>,
491
- 'items' | 'renderValue' | 'renderFooter' | 'filterable' | 'filterPlaceholder'
491
+ 'items' | 'renderValue' | 'renderFooter' | 'filterable' | 'filterPlaceholder' | 'id'
492
492
  > {
493
493
  searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
494
494
  listboxRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -497,6 +497,7 @@ interface SelectInputOptionsProps<T = string>
497
497
  }
498
498
 
499
499
  function SelectInputOptions<T = string>({
500
+ id,
500
501
  items,
501
502
  renderValue = String,
502
503
  renderFooter,
@@ -609,9 +610,11 @@ function SelectInputOptions<T = string>({
609
610
  <div className="np-select-input-query-container">
610
611
  <SearchInput
611
612
  ref={searchInputRef}
613
+ id={id}
612
614
  role="combobox"
613
615
  shape="rectangle"
614
616
  placeholder={filterPlaceholder}
617
+ aria-label={filterPlaceholder}
615
618
  defaultValue={filterQuery}
616
619
  aria-autocomplete="list"
617
620
  aria-expanded
@@ -0,0 +1,40 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Forms/MoneyInput/Dev notes" />
4
+
5
+ # Dev notes
6
+
7
+ ## Known accessibility issues
8
+
9
+ There are few issues reported by Axe that are not fixable or safe to ignore at this point in time.
10
+
11
+ ### Form elements should have a visible label / Form elements must have labels
12
+
13
+ While this requirement is normally considered as very serious, it's missing the fact that the input is wrapped by a labelled element with `role="group"`. We’ve tested it in NVDA on Windows and VoiceOver on MacOS, and discovered that if we were to satisfy Axe’s requirements, the screen reader would read out the label twice, something similar to `{Label Text} group, {Label Text} editable` (depending on the SR and the browser) which would result in an unnecessary noise. While the rule is valid, in this particular case the group label seems sufficient.
14
+
15
+ #### Further resources
16
+
17
+ - [Deque reference 1](https://dequeuniversity.com/rules/axe/4.9/label?application=axeAPI)
18
+ - [Deque reference 2](https://dequeuniversity.com/rules/axe/4.9/label-title-only?application=axeAPI)
19
+ - ['Group Labels Do Not Guarantee… Uniquity?' by Adrian Roselli](https://adrianroselli.com/2019/06/group-labels-do-not-guarantee-uniquity.html)
20
+
21
+ ---
22
+
23
+ ### Buttons must have discernible text
24
+
25
+ This affects HTML buttons with `role="combobox"` and deeply nested label text, not being discoverable by Axe. It is a known false-positive.
26
+
27
+ #### Further resources
28
+
29
+ - [Deque reference](https://dequeuniversity.com/rules/axe/4.9/button-name?application=axeAPI)
30
+ - [axe-core github issue](https://github.com/dequelabs/axe-core/issues/4472)
31
+
32
+ ---
33
+
34
+ ### ARIA hidden element must not be focusable or contain focusable elements
35
+
36
+ This is a genuine problem – according to the accessibility guidelines, no element with `aria-hidden="true"` should have any focusable children. This issue is caused by the `SelectInput`'s third party dependency (`Headless UI`) and, by extension, is visible in few other places. Work is planned to address it.
37
+
38
+ #### Further resources
39
+
40
+ - [Deque reference](https://dequeuniversity.com/rules/axe/4.9/aria-hidden-focus?application=axeAPI)
@@ -51,6 +51,7 @@ describe('MoneyInput', () => {
51
51
  const props = {
52
52
  currencies,
53
53
  selectedCurrency: currencies[1],
54
+ searchPlaceholder: 'Type a currency / country',
54
55
  amount: 1000,
55
56
  onAmountChange: jest.fn(),
56
57
  onCurrencyChange: jest.fn(),
@@ -90,4 +91,41 @@ describe('MoneyInput', () => {
90
91
  );
91
92
  expect(screen.getAllByRole('group')[0]).toHaveAccessibleName(/^Recipient gets/);
92
93
  });
94
+
95
+ describe('ids', () => {
96
+ it('should guarantee id and connect the input with the selected currency via withId HoC', () => {
97
+ render(<MoneyInput {...props} />);
98
+ const input = screen.getByRole('textbox');
99
+ const button = screen.getByRole('combobox');
100
+ expect(input.getAttribute('id')).toBeTruthy();
101
+ expect(input).toHaveAttribute('aria-describedby', button.getAttribute('id'));
102
+ });
103
+
104
+ it('should have unique id for the select filter with predefined id', async () => {
105
+ const fieldId = 'myFieldId';
106
+ render(
107
+ <Field label="Multiple currencies" id={fieldId}>
108
+ <MoneyInput {...props} />
109
+ </Field>,
110
+ );
111
+ await userEvent.click(screen.getByRole('combobox'));
112
+ expect(screen.getByLabelText(props.searchPlaceholder)).toHaveAttribute(
113
+ 'id',
114
+ `${fieldId}SelectedCurrencySearch`,
115
+ );
116
+ });
117
+
118
+ it('should have unique id for the select filter without predefined id', async () => {
119
+ render(
120
+ <Field label="Multiple currencies">
121
+ <MoneyInput {...props} />
122
+ </Field>,
123
+ );
124
+ await userEvent.click(screen.getByRole('combobox'));
125
+ expect(screen.getByLabelText(props.searchPlaceholder)).toHaveAttribute(
126
+ 'id',
127
+ expect.stringMatching(/^:.*?:SelectedCurrencySearch$/),
128
+ );
129
+ });
130
+ });
93
131
  });
@@ -1,5 +1,4 @@
1
1
  import { shallow } from 'enzyme';
2
- import { render, screen } from '@testing-library/react';
3
2
 
4
3
  import { MoneyInput, Title, Input, SelectInput } from '..';
5
4
  import { mockMatchMedia, mockResizeObserver } from '../test-utils';
@@ -818,14 +817,4 @@ describe('Money Input', () => {
818
817
  });
819
818
  });
820
819
  });
821
-
822
- describe('withId', () => {
823
- it('should guarantee id and connect the input with the selected currency', () => {
824
- render(<MoneyInput {...props} />);
825
- const input = screen.getByRole('textbox');
826
- const button = screen.getByRole('combobox');
827
- expect(input.getAttribute('id')).toBeTruthy();
828
- expect(input).toHaveAttribute('aria-describedby', button.getAttribute('id'));
829
- });
830
- });
831
820
  });
@@ -9,13 +9,13 @@ import { Field } from '../field/Field';
9
9
  export default {
10
10
  component: MoneyInput,
11
11
  title: 'Forms/MoneyInput',
12
- render: function Render(args) {
12
+ render: function Render({ id, ...args }) {
13
13
  const [selectedCurrency, setSelectedCurrency] = useState(args.selectedCurrency);
14
14
 
15
15
  const handleOnCurrencyChange = (value: CurrencyOptionItem) => setSelectedCurrency(value);
16
16
 
17
17
  return (
18
- <Field label="Editable money input label" required>
18
+ <Field label="Editable money input label" required id={id}>
19
19
  <MoneyInput
20
20
  {...args}
21
21
  selectedCurrency={selectedCurrency}
@@ -129,6 +129,7 @@ export const MultipleCurrencies: Story = {
129
129
  },
130
130
  selectedCurrency: exampleCurrency.usd,
131
131
  searchPlaceholder: 'Type a currency / country',
132
+ id: 'moneyInput',
132
133
  },
133
134
  };
134
135
 
@@ -155,20 +156,22 @@ export const OpenedInput: Story = {
155
156
  };
156
157
 
157
158
  export const SmallInput: Story = {
158
- render: (args) => {
159
+ render: ({ id, ...args }) => {
159
160
  return (
160
161
  <>
161
- <Field id={args.id} label="Money inputs" required>
162
+ <Field label="Money inputs" required>
162
163
  <MoneyInput {...args} {...SingleCurrency.args} />
163
164
  </Field>
164
165
  <br />
165
- <MoneyInput {...args} {...MultipleCurrencies.args} />
166
+ <Field label="Multiple currencies">
167
+ <MoneyInput {...args} {...MultipleCurrencies.args} />
168
+ </Field>
166
169
  <hr />
167
- <Field id={args.id} label="Error states" sentiment="negative" required>
170
+ <Field label="Error state" sentiment="negative" required>
168
171
  <MoneyInput {...args} {...SingleCurrency.args} />
169
172
  </Field>
170
173
  <br />
171
- <Field sentiment="negative">
174
+ <Field label="Multiple currencies: error state" sentiment="negative">
172
175
  <MoneyInput {...args} {...MultipleCurrencies.args} />
173
176
  </Field>
174
177
  </>
@@ -314,7 +314,7 @@ class MoneyInput extends Component<MoneyInputPropsWithInputAttributes, MoneyInpu
314
314
 
315
315
  const isFixedCurrency = (!this.state.searchQuery && hasSingleCurrency()) || !onCurrencyChange;
316
316
  const disabled = !this.props.onAmountChange;
317
- const selectedCurrencyElementId = `${amountInputId}SelectedCurrency`;
317
+ const selectedCurrencyElementId = `${inputAttributes?.id ?? amountInputId}SelectedCurrency`;
318
318
 
319
319
  return (
320
320
  <div
@@ -3,7 +3,7 @@ import { clsx } from 'clsx';
3
3
  import { DebouncedFunc } from 'lodash';
4
4
  import clamp from 'lodash.clamp';
5
5
  import debounce from 'lodash.debounce';
6
- import React, { Component, ReactNode } from 'react';
6
+ import { Component, ReactNode } from 'react';
7
7
  import { injectIntl, WrappedComponentProps } from 'react-intl';
8
8
 
9
9
  import Chip from '../chips/Chip';
@@ -101,7 +101,6 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
101
101
  size: Size.MEDIUM,
102
102
  validateChip: () => true,
103
103
  } satisfies Partial<TypeaheadProps<unknown>>;
104
- optionRefs: (React.RefObject<HTMLLIElement> | null)[];
105
104
 
106
105
  constructor(props: TypeaheadPropsWithInputAttributes<T>) {
107
106
  super(props);
@@ -116,7 +115,6 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
116
115
  optionsShown: false,
117
116
  isFocused: false,
118
117
  };
119
- this.optionRefs = [] as (React.RefObject<HTMLLIElement> | null)[];
120
118
  }
121
119
 
122
120
  handleSearchDebounced: DebouncedFunc<Typeahead<T>['handleSearch']>;
@@ -199,9 +197,6 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
199
197
  const { keyboardFocusedOptionIndex, query, selected } = this.state;
200
198
  const chipsMode = !showSuggestions && allowNew && multiple;
201
199
 
202
- const { key } = event;
203
- console.log(`Key pressed: ${key}`);
204
-
205
200
  if (chipsMode && ['Enter', 'Tab', ...chipSeparators].includes(event.key) && query.trim()) {
206
201
  event.preventDefault();
207
202
  this.selectItem({ label: query });
@@ -209,14 +204,10 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
209
204
  switch (event.key) {
210
205
  case 'ArrowDown':
211
206
  event.preventDefault();
212
- console.log('Down');
213
- console.log(keyboardFocusedOptionIndex);
214
207
  this.moveFocusedOption(1);
215
208
  break;
216
209
  case 'ArrowUp':
217
210
  event.preventDefault();
218
- console.log('Up');
219
- console.log(keyboardFocusedOptionIndex);
220
211
  this.moveFocusedOption(-1);
221
212
  break;
222
213
  case 'Enter':
@@ -237,29 +228,17 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
237
228
  }
238
229
  }
239
230
  };
231
+
240
232
  moveFocusedOption(offset: number) {
241
233
  this.setState((previousState) => {
242
- let { keyboardFocusedOptionIndex } = previousState;
234
+ const { keyboardFocusedOptionIndex } = previousState;
243
235
  const { options } = this.props;
244
-
245
- // Set keyboardFocusedOptionIndex to 0 if it's null
246
- if (keyboardFocusedOptionIndex === null) {
247
- keyboardFocusedOptionIndex = 0;
248
- } else {
249
- keyboardFocusedOptionIndex = clamp(
250
- keyboardFocusedOptionIndex + offset,
251
- 0,
252
- options.length - 1,
253
- );
254
- }
255
-
256
- const optionRef = this.optionRefs[keyboardFocusedOptionIndex];
257
- if (optionRef?.current) {
258
- optionRef.current.focus(); // Set focus on the option element
236
+ let index = 0;
237
+ if (keyboardFocusedOptionIndex !== null) {
238
+ index = clamp(keyboardFocusedOptionIndex + offset, 0, options.length - 1);
259
239
  }
260
-
261
240
  return {
262
- keyboardFocusedOptionIndex,
241
+ keyboardFocusedOptionIndex: index,
263
242
  };
264
243
  });
265
244
  }
@@ -423,22 +402,17 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
423
402
  id={`menu-${id}`}
424
403
  >
425
404
  <ul className="dropdown-menu" role="menu" aria-labelledby="dropdownMenu1">
426
- {optionsToRender.map((option, idx) => {
427
- const ref = React.createRef<HTMLLIElement>();
428
- this.optionRefs[idx] = ref;
429
- return (
430
- <TypeaheadOption
431
- key={`${option.label}${idx.toString()}`}
432
- ref={ref}
433
- query={query}
434
- option={option}
435
- selected={keyboardFocusedOptionIndex === idx}
436
- onClick={(event) => {
437
- this.onOptionSelected(event, option);
438
- }}
439
- />
440
- );
441
- })}
405
+ {optionsToRender.map((option, idx) => (
406
+ <TypeaheadOption
407
+ key={`${option.label}${idx.toString()}`}
408
+ query={query}
409
+ option={option}
410
+ selected={keyboardFocusedOptionIndex === idx}
411
+ onClick={(event) => {
412
+ this.onOptionSelected(event, option);
413
+ }}
414
+ />
415
+ ))}
442
416
  {footer}
443
417
  </ul>
444
418
  </div>
@@ -24,7 +24,6 @@ export type TypeaheadInputProps<T> = {
24
24
 
25
25
  type TypeaheadInputState = {
26
26
  inputWidth: number;
27
- keyboardFocusedOptionIndex: number | null;
28
27
  };
29
28
 
30
29
  export default class TypeaheadInput<T> extends Component<
@@ -38,7 +37,6 @@ export default class TypeaheadInput<T> extends Component<
38
37
  super(props);
39
38
  this.state = {
40
39
  inputWidth: DEFAULT_INPUT_MIN_WIDTH,
41
- keyboardFocusedOptionIndex: null,
42
40
  };
43
41
  }
44
42
 
@@ -88,7 +86,6 @@ export default class TypeaheadInput<T> extends Component<
88
86
  className={clsx(multiple && 'typeahead__input')}
89
87
  name={name}
90
88
  id={`input-${typeaheadId}`}
91
- aria-activedescendant={`menu-${typeaheadId} ${this.state.keyboardFocusedOptionIndex !== null ? `option-${this.state.keyboardFocusedOptionIndex}` : ''}`}
92
89
  autoFocus={autoFocus}
93
90
  placeholder={hasPlaceholder ? placeholder : ''}
94
91
  aria-autocomplete="list"
@@ -1,5 +1,5 @@
1
+ /* eslint-disable jsx-a11y/anchor-is-valid */
1
2
  import { clsx } from 'clsx';
2
- import { forwardRef } from 'react';
3
3
 
4
4
  import { TypeaheadOption } from '../Typeahead';
5
5
  import highlight from '../util/highlight';
@@ -11,21 +11,19 @@ export type TypeaheadOptionProps<T> = {
11
11
  query?: string;
12
12
  };
13
13
 
14
- const Option = forwardRef<HTMLLIElement, TypeaheadOptionProps<any>>((props, ref) => {
14
+ const Option = <T,>(props: TypeaheadOptionProps<T>) => {
15
15
  const { option, selected = false, onClick = () => {}, query = '' } = props;
16
16
  const { label, note, secondary } = option;
17
17
 
18
18
  return (
19
19
  <li
20
- ref={ref}
21
20
  role="option"
22
- aria-selected={selected ? 'true' : 'false'}
23
- id={`option-${props.option.label}`}
21
+ aria-selected="false"
24
22
  className={clsx('typeahead__option tw-dropdown-item clickable', {
25
23
  'tw-dropdown-item--focused': selected,
26
24
  })}
27
25
  >
28
- <a className="dropdown-item" href="#" tabIndex={0} onClick={onClick}>
26
+ <a className="dropdown-item" href="#" tabIndex={-1} onClick={onClick}>
29
27
  <span>{highlight(label, query)}</span>
30
28
  {note && <span className="np-text-body-default m-l-1">{note}</span>}
31
29
  {secondary && (
@@ -34,6 +32,6 @@ const Option = forwardRef<HTMLLIElement, TypeaheadOptionProps<any>>((props, ref)
34
32
  </a>
35
33
  </li>
36
34
  );
37
- });
35
+ };
38
36
 
39
37
  export default Option;