@transferwise/components 46.51.0 → 46.52.1

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 (43) hide show
  1. package/build/i18n/pt.json +2 -0
  2. package/build/i18n/pt.json.js +2 -0
  3. package/build/i18n/pt.json.js.map +1 -1
  4. package/build/i18n/pt.json.mjs +2 -0
  5. package/build/i18n/pt.json.mjs.map +1 -1
  6. package/build/i18n/zh-CN.json +2 -0
  7. package/build/i18n/zh-CN.json.js +2 -0
  8. package/build/i18n/zh-CN.json.js.map +1 -1
  9. package/build/i18n/zh-CN.json.mjs +2 -0
  10. package/build/i18n/zh-CN.json.mjs.map +1 -1
  11. package/build/inputs/SelectInput.js +107 -36
  12. package/build/inputs/SelectInput.js.map +1 -1
  13. package/build/inputs/SelectInput.mjs +109 -38
  14. package/build/inputs/SelectInput.mjs.map +1 -1
  15. package/build/main.css +10 -0
  16. package/build/styles/inputs/SelectInput.css +10 -0
  17. package/build/styles/main.css +10 -0
  18. package/build/typeahead/Typeahead.js +63 -59
  19. package/build/typeahead/Typeahead.js.map +1 -1
  20. package/build/typeahead/Typeahead.messages.js +12 -0
  21. package/build/typeahead/Typeahead.messages.js.map +1 -0
  22. package/build/typeahead/Typeahead.messages.mjs +10 -0
  23. package/build/typeahead/Typeahead.messages.mjs.map +1 -0
  24. package/build/typeahead/Typeahead.mjs +63 -59
  25. package/build/typeahead/Typeahead.mjs.map +1 -1
  26. package/build/types/inputs/SelectInput.d.ts.map +1 -1
  27. package/build/types/typeahead/Typeahead.d.ts +2 -1
  28. package/build/types/typeahead/Typeahead.d.ts.map +1 -1
  29. package/build/types/typeahead/Typeahead.messages.d.ts +9 -0
  30. package/build/types/typeahead/Typeahead.messages.d.ts.map +1 -0
  31. package/package.json +5 -4
  32. package/src/i18n/pt.json +2 -0
  33. package/src/i18n/zh-CN.json +2 -0
  34. package/src/inputs/SelectInput.css +10 -0
  35. package/src/inputs/SelectInput.less +12 -0
  36. package/src/inputs/SelectInput.story.tsx +20 -0
  37. package/src/inputs/SelectInput.tsx +144 -46
  38. package/src/main.css +10 -0
  39. package/src/typeahead/Typeahead.messages.ts +9 -0
  40. package/src/typeahead/Typeahead.rtl.spec.tsx +13 -1
  41. package/src/typeahead/Typeahead.spec.js +12 -10
  42. package/src/typeahead/Typeahead.story.tsx +194 -195
  43. package/src/typeahead/Typeahead.tsx +16 -9
@@ -1,125 +1,122 @@
1
- import { select, boolean } from '@storybook/addon-knobs';
2
- import { StoryContext } from '@storybook/react';
3
- import { userEvent, within } from '@storybook/test';
1
+ import { Meta, StoryObj } from '@storybook/react';
4
2
  import { Search as SearchIcon } from '@transferwise/icons';
5
- import { useState } from 'react';
3
+ import { userEvent, within, fn } from '@storybook/test';
6
4
 
7
- import { Sentiment } from '../common';
5
+ import Typeahead, { type TypeaheadOption } from './Typeahead';
6
+ import { Size } from '../common';
7
+ import { useState } from 'react';
8
8
  import { Input } from '../inputs/Input';
9
9
 
10
- import Typeahead, { type TypeaheadOption } from './Typeahead';
10
+ type Story = StoryObj<typeof Typeahead>;
11
+
12
+ /**
13
+ * Checks if provided TypeaheadOption contains an HTML5-compliant email address
14
+ * @see https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
15
+ */
16
+ const validateOptionAsEmail = (option: TypeaheadOption) => {
17
+ return /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(
18
+ option.label,
19
+ );
20
+ };
11
21
 
12
22
  export default {
13
23
  component: Typeahead,
14
24
  title: 'Forms/Typeahead',
25
+ args: {
26
+ allowNew: false,
27
+ autoFillOnBlur: true,
28
+ autoFocus: false,
29
+ chipSeparators: [',', ' '],
30
+ clearable: true,
31
+ inputAutoComplete: 'new-password',
32
+ minQueryLength: 3,
33
+ multiple: false,
34
+ searchDelay: 200,
35
+ showSuggestions: true,
36
+ showNewEntry: true,
37
+ size: Size.MEDIUM,
38
+ initialValue: [],
39
+ id: 'myTypeahead',
40
+ name: 'typeahead-input-name',
41
+ placeholder: 'placeholder',
42
+ onChange: fn(),
43
+ onBlur: fn(),
44
+ onFocus: fn(),
45
+ onInputChange: fn(),
46
+ onSearch: fn(),
47
+ },
48
+ argTypes: {
49
+ size: {
50
+ control: 'inline-radio',
51
+ options: [Size.MEDIUM, Size.LARGE],
52
+ },
53
+ },
54
+ } satisfies Meta<typeof Typeahead>;
55
+
56
+ export const Basic: Story = {
57
+ render: function Render(args) {
58
+ const [options, setOptions] = useState([
59
+ {
60
+ label: 'A thing',
61
+ note: 'with a note',
62
+ },
63
+ {
64
+ label: 'Another thing',
65
+ secondary: 'with secondary text this time',
66
+ },
67
+ {
68
+ label: 'Profile',
69
+ },
70
+ {
71
+ label: 'Globe',
72
+ },
73
+ {
74
+ label: 'British pound',
75
+ },
76
+ {
77
+ label: 'Euro',
78
+ },
79
+ {
80
+ label: 'Something else',
81
+ },
82
+ ]);
83
+
84
+ const validateChipWhenMultiple = () =>
85
+ args.multiple && args.allowNew
86
+ ? (option: TypeaheadOption) => validateOptionAsEmail(option)
87
+ : undefined;
88
+
89
+ return (
90
+ <Typeahead
91
+ {...args}
92
+ initialValue={[]}
93
+ validateChip={validateChipWhenMultiple()}
94
+ addon={<SearchIcon size={24} />}
95
+ options={options}
96
+ onSearch={() => {
97
+ setTimeout(() => setOptions(options), 1500);
98
+ }}
99
+ />
100
+ );
101
+ },
15
102
  };
16
103
 
17
- const validateChip = (option: TypeaheadOption) => {
18
- // eslint-disable-next-line unicorn/no-unsafe-regex
19
- return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
20
- option.label,
21
- );
22
- };
23
-
24
- export const createable = () => {
25
- return (
104
+ export const Creatable: Story = {
105
+ render: (args) => (
26
106
  <Typeahead
27
- id="typeahead"
28
- name="typeahead-input-name"
29
- size="md"
30
- maxHeight={100}
31
- footer={<div>Want a footer? Style it!</div>}
32
- multiple
33
- clearable
107
+ {...args}
34
108
  allowNew
35
- showSuggestions={false}
36
- showNewEntry={false}
37
- placeholder="placeholder"
38
- chipSeparators={[',', ' ']}
39
- validateChip={validateChip}
109
+ multiple
110
+ initialValue={[]}
111
+ validateChip={validateOptionAsEmail}
40
112
  addon={<SearchIcon size={24} />}
41
113
  options={[]}
42
- onChange={() => {}}
43
- onBlur={() => {}}
44
114
  />
45
- );
46
- };
47
-
48
- createable.play = async ({ canvasElement }: StoryContext) => {
49
- const canvas = within(canvasElement);
50
- await userEvent.type(canvas.getByRole('combobox'), 'chip{Enter}chip2{Enter}');
51
- };
52
-
53
- export const Basic = () => {
54
- const [options, setOptions] = useState([
55
- {
56
- label: 'A thing',
57
- note: 'with a note',
58
- },
59
- {
60
- label: 'Another thing',
61
- secondary: 'with secondary text this time',
62
- },
63
- {
64
- label: 'Profile',
65
- },
66
- {
67
- label: 'Globe',
68
- },
69
- {
70
- label: 'British pound',
71
- },
72
- {
73
- label: 'Euro',
74
- },
75
- {
76
- label: 'Something else',
77
- },
78
- ]);
79
-
80
- const validateChipWhenMultiple = () => {
81
- return multiple && allowNew ? (option: TypeaheadOption) => validateChip(option) : undefined;
82
- };
83
-
84
- const multiple = boolean('multiple', false);
85
- const clearable = boolean('clearable', false);
86
- const allowNew = boolean('allowNew', false);
87
- const showSuggestions = boolean('showSuggestions', true);
88
- const showNewEntry = boolean('showNewEntry', true);
89
- const showAlert = boolean('alert', false);
90
- const alertType = select('alert type', [Sentiment.ERROR, Sentiment.WARNING], Sentiment.ERROR);
91
-
92
- return (
93
- <Typeahead
94
- id="typeahead"
95
- name="typeahead-input-name"
96
- size="md"
97
- maxHeight={100}
98
- footer={<div>Want a footer? Style it!</div>}
99
- multiple={multiple}
100
- clearable={clearable}
101
- allowNew={allowNew}
102
- showSuggestions={showSuggestions}
103
- showNewEntry={showNewEntry}
104
- placeholder="placeholder"
105
- chipSeparators={[',', ' ']}
106
- validateChip={validateChipWhenMultiple()}
107
- alert={showAlert ? { message: `Couldn't add item`, type: alertType } : undefined}
108
- addon={<SearchIcon size={24} />}
109
- options={options}
110
- inputAutoComplete="off"
111
- onSearch={() => {
112
- setTimeout(() => setOptions(options), 1500);
113
- }}
114
- onChange={() => {}}
115
- onBlur={() => {}}
116
- />
117
- );
118
- };
119
-
120
- Basic.play = async ({ canvasElement }: StoryContext) => {
121
- const canvas = within(canvasElement);
122
- await userEvent.type(canvas.getByRole('combobox'), 'abc{ArrowDown}');
115
+ ),
116
+ play: async ({ canvasElement }) => {
117
+ const canvas = within(canvasElement);
118
+ await userEvent.type(canvas.getByRole('combobox'), 'chip{Enter}hello@wise.com{Enter}');
119
+ },
123
120
  };
124
121
 
125
122
  type Result =
@@ -134,98 +131,100 @@ type Result =
134
131
 
135
132
  type SearchState = 'success' | 'idle' | 'error' | 'loading';
136
133
 
137
- export const Search = () => {
138
- const [results, setResults] = useState<Result[]>([]);
139
- const [state, setState] = useState<SearchState>('idle');
140
- const [filledValue, setFilledValue] = useState<string | null>(null);
134
+ /**
135
+ * @FIXME This story feels incomplete. It ignores bunch of props
136
+ * and seems very opinionated. Surely we can do better?
137
+ */
138
+ export const Search: Story = {
139
+ render: function Render(args) {
140
+ const [results, setResults] = useState<Result[]>([]);
141
+ const [state, setState] = useState<SearchState>('idle');
142
+ const [filledValue, setFilledValue] = useState<string | null>(null);
143
+
144
+ const handleInputChange = (query: string) => {
145
+ args?.onInputChange?.(query);
146
+
147
+ if (query === 'loading' || query === 'error' || query === 'nothing') {
148
+ setState(query === 'nothing' ? 'success' : query);
149
+ setResults([]);
150
+ return;
151
+ }
141
152
 
142
- const onChange = (query: string) => {
143
- if (query === 'loading') {
144
- setState('loading');
145
- setResults([]);
146
- return;
147
- }
148
- if (query === 'error') {
149
- setState('error');
150
- setResults([]);
151
- return;
152
- }
153
- if (query === 'nothing') {
154
153
  setState('success');
155
- setResults([]);
156
- return;
157
- }
154
+ setResults(getResults(query));
155
+ };
158
156
 
159
- setState('success');
160
- setResults(getResults(query));
161
- };
162
-
163
- const onResultSelected = (option: Result) => {
164
- if (option.type === 'search') {
165
- setResults([
166
- { type: 'action', value: `${option.value} Result #1` },
167
- { type: 'action', value: `${option.value} Result #2` },
168
- { type: 'action', value: `${option.value} Result #3` },
169
- ]);
170
- }
171
- if (option.type === 'action') {
172
- setFilledValue(option.value);
173
- }
174
- };
175
-
176
- const getResults = (query: string): Result[] => {
177
- return [
178
- { type: 'action', value: `${query} Result #1` },
179
- { type: 'action', value: `${query} Result #2` },
180
- { type: 'action', value: `${query} Result #3` },
181
- { type: 'search', value: `Search for more: '${query}'` },
182
- ];
183
- };
184
-
185
- return (
186
- <>
187
- <Typeahead<Result>
188
- id="typeahead-input-id"
189
- name="typeahead-input-name"
190
- size="md"
191
- maxHeight={100}
192
- footer={<SearchFooter options={results} state={state} />}
193
- multiple={false}
194
- clearable={false}
195
- addon={<SearchIcon />}
196
- options={results.map((option) => ({
197
- value: option,
198
- label: option.value,
199
- keepFocusOnSelect: option.type === 'search',
200
- clearQueryOnSelect: option.type === 'action',
201
- }))}
202
- onChange={(values) => {
203
- if (values.length > 0) {
204
- const [updatedValue] = values;
205
- if (updatedValue.value) {
206
- onResultSelected(updatedValue.value);
207
- }
208
- }
209
- }}
210
- onInputChange={onChange}
211
- />
212
- {filledValue != null ? <Input value={filledValue} /> : null}
213
- </>
214
- );
215
- };
157
+ const handleResultSelected = (option: Result) => {
158
+ if (option.type === 'search') {
159
+ setResults([
160
+ { type: 'action', value: `${option.value} Result #1` },
161
+ { type: 'action', value: `${option.value} Result #2` },
162
+ { type: 'action', value: `${option.value} Result #3` },
163
+ ]);
164
+ }
165
+ if (option.type === 'action') {
166
+ setFilledValue(option.value);
167
+ }
168
+ };
169
+
170
+ const handleChange = (values: TypeaheadOption<Result>[]) => {
171
+ args?.onChange?.(values);
172
+
173
+ if (values.length > 0) {
174
+ const [updatedValue] = values;
175
+
176
+ if (updatedValue.value) {
177
+ handleResultSelected(updatedValue.value);
178
+ }
179
+ }
180
+ };
181
+
182
+ const getResults = (query: string): Result[] => {
183
+ return [
184
+ { type: 'action', value: `${query} Result #1` },
185
+ { type: 'action', value: `${query} Result #2` },
186
+ { type: 'action', value: `${query} Result #3` },
187
+ { type: 'search', value: `Search for more: '${query}'` },
188
+ ];
189
+ };
190
+
191
+ const renderFooter = (options: Result[]) => {
192
+ let output = null;
216
193
 
217
- function SearchFooter({ options, state }: { options: Result[]; state: SearchState }) {
218
- if (state === 'loading') {
219
- return <p className="m-y-2 m-x-2">Loading...</p>;
220
- }
194
+ if (state === 'loading') {
195
+ output = 'Loading…';
196
+ }
221
197
 
222
- if (state === 'success' && options.length === 0) {
223
- return <p className="m-y-2 m-x-2">No results found</p>;
224
- }
198
+ if (state === 'success' && options.length === 0) {
199
+ output = 'No results found';
200
+ }
225
201
 
226
- if (state === 'error' && options.length === 0) {
227
- return <div className="m-y-2 m-x-2">Something went wrong</div>;
228
- }
202
+ if (state === 'error' && options.length === 0) {
203
+ output = 'Something went wrong';
204
+ }
229
205
 
230
- return null;
231
- }
206
+ return <p className="m-y-2 m-x-2">{output}</p>;
207
+ };
208
+
209
+ return (
210
+ <>
211
+ <Typeahead<Result>
212
+ {...args}
213
+ initialValue={undefined}
214
+ footer={renderFooter(results)}
215
+ addon={<SearchIcon />}
216
+ options={results.map((option) => ({
217
+ value: option,
218
+ label: option.value,
219
+ keepFocusOnSelect: option.type === 'search',
220
+ clearQueryOnSelect: option.type === 'action',
221
+ }))}
222
+ onChange={handleChange}
223
+ onInputChange={handleInputChange}
224
+ />
225
+
226
+ {filledValue != null ? <Input value={filledValue} /> : null}
227
+ </>
228
+ );
229
+ },
230
+ };
@@ -1,27 +1,28 @@
1
- /* eslint-disable jsx-a11y/anchor-is-valid */
2
- /* eslint-disable jsx-a11y/click-events-have-key-events */
3
- /* eslint-disable jsx-a11y/no-static-element-interactions */
4
-
5
1
  import { Cross as CrossIcon } from '@transferwise/icons';
6
2
  import { clsx } from 'clsx';
7
3
  import { DebouncedFunc } from 'lodash';
8
4
  import clamp from 'lodash.clamp';
9
5
  import debounce from 'lodash.debounce';
10
6
  import { Component, ReactNode } from 'react';
7
+ import { injectIntl, WrappedComponentProps } from 'react-intl';
11
8
 
12
9
  import Chip from '../chips/Chip';
13
- import { Size, Sentiment, SizeMedium, SizeLarge } from '../common';
14
10
  import {
11
+ Size,
12
+ Sentiment,
13
+ SizeMedium,
14
+ SizeLarge,
15
15
  addClickClassToDocumentOnIos,
16
16
  removeClickClassFromDocumentOnIos,
17
17
  stopPropagation,
18
- } from '../common/domHelpers';
18
+ } from '../common';
19
19
  import InlineAlert from '../inlineAlert';
20
20
  import { InlineAlertProps } from '../inlineAlert/InlineAlert';
21
21
  import { withInputAttributes, WithInputAttributesProps } from '../inputs/contexts';
22
22
 
23
23
  import TypeaheadInput from './typeaheadInput/TypeaheadInput';
24
24
  import TypeaheadOption from './typeaheadOption/TypeaheadOption';
25
+ import messages from './Typeahead.messages';
25
26
 
26
27
  const DEFAULT_MIN_QUERY_LENGTH = 3;
27
28
  const SEARCH_DELAY = 200;
@@ -35,7 +36,7 @@ export type TypeaheadOption<T = string> = {
35
36
  keepFocusOnSelect?: boolean;
36
37
  };
37
38
 
38
- export interface TypeaheadProps<T> {
39
+ export interface TypeaheadProps<T> extends WrappedComponentProps {
39
40
  id: string;
40
41
  name: string;
41
42
  addon?: ReactNode;
@@ -464,6 +465,7 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
464
465
  const hasWarning = displayAlert && alertType === Sentiment.WARNING;
465
466
  const hasInfo = displayAlert && alertType === Sentiment.NEUTRAL;
466
467
  return (
468
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */
467
469
  <div
468
470
  role="group"
469
471
  {...inputAttributes}
@@ -513,7 +515,12 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
513
515
 
514
516
  {clearButton && (
515
517
  <div className="input-group-addon">
516
- <button type="button" className="btn-unstyled" onClick={this.clear}>
518
+ <button
519
+ type="button"
520
+ className="btn-unstyled"
521
+ aria-label={this.props.intl.formatMessage(messages.clearLabel)}
522
+ onClick={this.clear}
523
+ >
517
524
  <CrossIcon />
518
525
  </button>
519
526
  </div>
@@ -526,6 +533,6 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
526
533
  }
527
534
  }
528
535
 
529
- export default withInputAttributes(Typeahead, { nonLabelable: true }) as <T>(
536
+ export default injectIntl(withInputAttributes(Typeahead, { nonLabelable: true })) as <T>(
530
537
  props: TypeaheadProps<T>,
531
538
  ) => React.ReactElement;