@transferwise/components 46.160.2 → 46.160.4
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.
- package/build/FlowNavigation/FlowNavigation.js +6 -4
- package/build/FlowNavigation/FlowNavigation.js.map +1 -1
- package/build/FlowNavigation/FlowNavigation.mjs +6 -4
- package/build/FlowNavigation/FlowNavigation.mjs.map +1 -1
- package/build/Inputs/SelectInput/Options/SelectInputOptions.js +33 -5
- package/build/Inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
- package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs +34 -6
- package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
- package/build/Inputs/SelectInput/SelectInput.js +28 -4
- package/build/Inputs/SelectInput/SelectInput.js.map +1 -1
- package/build/Inputs/SelectInput/SelectInput.mjs +29 -5
- package/build/Inputs/SelectInput/SelectInput.mjs.map +1 -1
- package/build/Inputs/SelectInput/SelectInput.utils.js +0 -2
- package/build/Inputs/SelectInput/SelectInput.utils.js.map +1 -1
- package/build/Inputs/SelectInput/SelectInput.utils.mjs +1 -2
- package/build/Inputs/SelectInput/SelectInput.utils.mjs.map +1 -1
- package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js +5 -1
- package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
- package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs +5 -1
- package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
- package/build/Inputs/SelectInput/constants.js +8 -0
- package/build/Inputs/SelectInput/constants.js.map +1 -0
- package/build/Inputs/SelectInput/constants.mjs +5 -0
- package/build/Inputs/SelectInput/constants.mjs.map +1 -0
- package/build/Inputs/SelectInput/hooks/useTypeahead.js +57 -0
- package/build/Inputs/SelectInput/hooks/useTypeahead.js.map +1 -0
- package/build/Inputs/SelectInput/hooks/useTypeahead.mjs +54 -0
- package/build/Inputs/SelectInput/hooks/useTypeahead.mjs.map +1 -0
- package/build/i18n/ja.json +1 -1
- package/build/i18n/ja.json.js +1 -1
- package/build/i18n/ja.json.mjs +1 -1
- package/build/main.css +10 -0
- package/build/styles/FlowNavigation/FlowNavigation.css +10 -0
- package/build/styles/main.css +10 -0
- package/build/types/FlowNavigation/FlowNavigation.d.ts.map +1 -1
- package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts +3 -1
- package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
- package/build/types/Inputs/SelectInput/SelectInput.d.ts.map +1 -1
- package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts +0 -1
- package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts.map +1 -1
- package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +1 -1
- package/build/types/Inputs/SelectInput/constants.d.ts +3 -0
- package/build/types/Inputs/SelectInput/constants.d.ts.map +1 -0
- package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts +8 -0
- package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/FlowNavigation/FlowNavigation.css +10 -0
- package/src/FlowNavigation/FlowNavigation.less +11 -0
- package/src/FlowNavigation/FlowNavigation.tsx +6 -4
- package/src/Inputs/SelectInput/Options/SelectInputOptions.tsx +36 -6
- package/src/Inputs/SelectInput/SelectInput.test.tsx +143 -0
- package/src/Inputs/SelectInput/SelectInput.tsx +31 -5
- package/src/Inputs/SelectInput/SelectInput.utils.ts +0 -2
- package/src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +3 -0
- package/src/Inputs/SelectInput/constants.ts +3 -0
- package/src/Inputs/SelectInput/hooks/useTypeahead.ts +82 -0
- package/src/i18n/ja.json +1 -1
- package/src/main.css +10 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { CrossCircle } from '@transferwise/icons';
|
|
2
2
|
import { ListboxOptions } from '@headlessui/react';
|
|
3
3
|
import { clsx } from 'clsx';
|
|
4
|
-
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useDeferredValue, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import { useIntl } from 'react-intl';
|
|
6
6
|
import { Virtualizer, type VirtualizerHandle } from 'virtua';
|
|
7
7
|
|
|
8
8
|
import { SearchInput } from '../../SearchInput/SearchInput';
|
|
9
|
+
import { MAX_ITEMS_WITHOUT_VIRTUALIZATION } from '../constants';
|
|
10
|
+
import { useTypeahead } from '../hooks/useTypeahead';
|
|
9
11
|
import {
|
|
10
12
|
SelectInputItemsCountContext,
|
|
11
13
|
SelectInputItemPositionContext,
|
|
@@ -13,7 +15,6 @@ import {
|
|
|
13
15
|
import {
|
|
14
16
|
dedupeSelectInputItems,
|
|
15
17
|
filterSelectInputItems,
|
|
16
|
-
MAX_ITEMS_WITHOUT_VIRTUALIZATION,
|
|
17
18
|
searchableString,
|
|
18
19
|
selectInputOptionItemIncludesNeedle,
|
|
19
20
|
sortSelectInputItems,
|
|
@@ -42,7 +43,9 @@ export interface SelectInputOptionsProps<T = string> extends Pick<
|
|
|
42
43
|
searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
|
43
44
|
listboxRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
44
45
|
filterQuery: string;
|
|
46
|
+
initialTypeaheadKey?: string | null;
|
|
45
47
|
onFilterChange: (query: string) => void;
|
|
48
|
+
onInitialTypeaheadHandled?: () => void;
|
|
46
49
|
listBoxLabel?: string;
|
|
47
50
|
listBoxLabelledBy?: string;
|
|
48
51
|
autocomplete?: string;
|
|
@@ -67,7 +70,9 @@ export function SelectInputOptions<T = string>({
|
|
|
67
70
|
searchInputRef,
|
|
68
71
|
listboxRef,
|
|
69
72
|
filterQuery,
|
|
73
|
+
initialTypeaheadKey,
|
|
70
74
|
onFilterChange,
|
|
75
|
+
onInitialTypeaheadHandled,
|
|
71
76
|
listBoxLabel,
|
|
72
77
|
listBoxLabelledBy,
|
|
73
78
|
autocomplete,
|
|
@@ -78,13 +83,37 @@ export function SelectInputOptions<T = string>({
|
|
|
78
83
|
const virtualiserHandlerRef = useRef<VirtualizerHandle>(null);
|
|
79
84
|
const controllerRef = filterable ? searchInputRef : listboxRef;
|
|
80
85
|
const initialRenderRef = useRef(true);
|
|
86
|
+
const deferredFilterQuery = useDeferredValue(filterQuery);
|
|
87
|
+
const handleTypeahead = useTypeahead({
|
|
88
|
+
disabled: filterable,
|
|
89
|
+
onMatch: onInitialTypeaheadHandled,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (filterable || initialTypeaheadKey == null) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const animationFrame = requestAnimationFrame(() => {
|
|
98
|
+
if (listboxRef.current == null) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
listboxRef.current.focus({ preventScroll: true });
|
|
103
|
+
listboxRef.current.dispatchEvent(
|
|
104
|
+
new KeyboardEvent('keydown', { key: initialTypeaheadKey, bubbles: true }),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return () => cancelAnimationFrame(animationFrame);
|
|
109
|
+
}, [filterable, initialTypeaheadKey, listboxRef]);
|
|
81
110
|
|
|
82
111
|
const needle = useMemo(() => {
|
|
83
112
|
if (filterable) {
|
|
84
|
-
return
|
|
113
|
+
return deferredFilterQuery ? searchableString(deferredFilterQuery) : null;
|
|
85
114
|
}
|
|
86
115
|
return undefined;
|
|
87
|
-
}, [
|
|
116
|
+
}, [deferredFilterQuery, filterable]);
|
|
88
117
|
useEffect(() => {
|
|
89
118
|
if (needle) {
|
|
90
119
|
// Ensure having an active option while filtering.
|
|
@@ -151,7 +180,7 @@ export function SelectInputOptions<T = string>({
|
|
|
151
180
|
return item;
|
|
152
181
|
});
|
|
153
182
|
|
|
154
|
-
return sortSelectInputItems(filtered, sortFilteredOptions,
|
|
183
|
+
return sortSelectInputItems(filtered, sortFilteredOptions, deferredFilterQuery);
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
return filterSelectInputItems(dedupedItems, (item) =>
|
|
@@ -284,7 +313,7 @@ export function SelectInputOptions<T = string>({
|
|
|
284
313
|
shape="rectangle"
|
|
285
314
|
placeholder={filterPlaceholder}
|
|
286
315
|
aria-label={filterPlaceholder}
|
|
287
|
-
|
|
316
|
+
value={filterQuery}
|
|
288
317
|
aria-autocomplete="list"
|
|
289
318
|
aria-expanded
|
|
290
319
|
aria-controls={listboxId}
|
|
@@ -352,6 +381,7 @@ export function SelectInputOptions<T = string>({
|
|
|
352
381
|
aria-labelledby={listBoxLabelledBy}
|
|
353
382
|
tabIndex={0}
|
|
354
383
|
className="np-select-input-listbox"
|
|
384
|
+
onKeyDownCapture={handleTypeahead}
|
|
355
385
|
>
|
|
356
386
|
{!virtualized ? (
|
|
357
387
|
filteredItems.map((_, index) => getItemNode(index))
|
|
@@ -34,6 +34,20 @@ describe('SelectInput', () => {
|
|
|
34
34
|
expect(screen.getByText('Currency')).toBeInTheDocument();
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it('explicitly includes the trigger in the sequential keyboard focus order', () => {
|
|
38
|
+
render(
|
|
39
|
+
<SelectInput
|
|
40
|
+
placeholder="Currency"
|
|
41
|
+
items={[
|
|
42
|
+
{ type: 'option', value: 'USD' },
|
|
43
|
+
{ type: 'option', value: 'EUR' },
|
|
44
|
+
]}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '0');
|
|
49
|
+
});
|
|
50
|
+
|
|
37
51
|
it('renders footer', async () => {
|
|
38
52
|
render(
|
|
39
53
|
<SelectInput
|
|
@@ -221,6 +235,135 @@ describe('SelectInput', () => {
|
|
|
221
235
|
expect(trigger).toHaveTextContent('EUR');
|
|
222
236
|
});
|
|
223
237
|
|
|
238
|
+
it('starts filtering when typing on the focused trigger', async () => {
|
|
239
|
+
const handleFilterChange = jest.fn();
|
|
240
|
+
|
|
241
|
+
render(
|
|
242
|
+
<SelectInput
|
|
243
|
+
items={[
|
|
244
|
+
{ type: 'option', value: 'January' },
|
|
245
|
+
{ type: 'option', value: 'February' },
|
|
246
|
+
{ type: 'option', value: 'March' },
|
|
247
|
+
]}
|
|
248
|
+
filterable
|
|
249
|
+
onFilterChange={handleFilterChange}
|
|
250
|
+
/>,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const trigger = screen.getByRole('combobox');
|
|
254
|
+
await userEvent.tab();
|
|
255
|
+
expect(trigger).toHaveFocus();
|
|
256
|
+
await userEvent.keyboard('f');
|
|
257
|
+
|
|
258
|
+
const searchInput = screen.getByRole('combobox', { expanded: true });
|
|
259
|
+
expect(searchInput).toHaveFocus();
|
|
260
|
+
expect(searchInput).toHaveValue('f');
|
|
261
|
+
expect(handleFilterChange).toHaveBeenLastCalledWith({
|
|
262
|
+
query: 'f',
|
|
263
|
+
queryNormalized: 'f',
|
|
264
|
+
});
|
|
265
|
+
expect(screen.getByRole('option', { name: 'February' })).toBeInTheDocument();
|
|
266
|
+
expect(screen.queryByRole('option', { name: 'January' })).not.toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('starts typeahead when typing on the focused trigger', async () => {
|
|
270
|
+
render(
|
|
271
|
+
<SelectInput
|
|
272
|
+
items={[
|
|
273
|
+
{ type: 'option', value: 'Frost' },
|
|
274
|
+
{ type: 'option', value: 'February' },
|
|
275
|
+
{ type: 'option', value: 'March' },
|
|
276
|
+
]}
|
|
277
|
+
/>,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const trigger = screen.getByRole('combobox');
|
|
281
|
+
await userEvent.tab();
|
|
282
|
+
expect(trigger).toHaveFocus();
|
|
283
|
+
await userEvent.keyboard('f');
|
|
284
|
+
|
|
285
|
+
const listbox = screen.getByRole('listbox');
|
|
286
|
+
const frost = within(listbox).getByRole('option', { name: 'Frost' });
|
|
287
|
+
const february = within(listbox).getByRole('option', { name: 'February' });
|
|
288
|
+
await waitFor(() => {
|
|
289
|
+
expect(listbox).toHaveFocus();
|
|
290
|
+
});
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
expect(frost).toHaveClass('np-select-input-option-container--active');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await userEvent.keyboard('e');
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
expect(february).toHaveClass('np-select-input-option-container--active');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
301
|
+
expect(within(listbox).getByRole('option', { name: 'March' })).toHaveClass(
|
|
302
|
+
'np-select-input-option-container--active',
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('continues typeahead across spaces after the query starts', async () => {
|
|
307
|
+
render(
|
|
308
|
+
<SelectInput
|
|
309
|
+
items={[
|
|
310
|
+
{ type: 'option', value: 'New Zealand' },
|
|
311
|
+
{ type: 'option', value: 'New York' },
|
|
312
|
+
{ type: 'option', value: 'Yorkshire' },
|
|
313
|
+
]}
|
|
314
|
+
/>,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await userEvent.tab();
|
|
318
|
+
await userEvent.keyboard('n');
|
|
319
|
+
|
|
320
|
+
const listbox = screen.getByRole('listbox');
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(listbox).toHaveFocus();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await userEvent.keyboard('ew y');
|
|
326
|
+
|
|
327
|
+
await waitFor(() => {
|
|
328
|
+
expect(within(listbox).getByRole('option', { name: 'New York' })).toHaveClass(
|
|
329
|
+
'np-select-input-option-container--active',
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it.each([
|
|
335
|
+
['filterable', true],
|
|
336
|
+
['non-filterable', false],
|
|
337
|
+
])('supports arrow navigation after opening a %s select with Enter', async (_, filterable) => {
|
|
338
|
+
render(
|
|
339
|
+
<SelectInput
|
|
340
|
+
items={[
|
|
341
|
+
{ type: 'option', value: 'GBP' },
|
|
342
|
+
{ type: 'option', value: 'EUR' },
|
|
343
|
+
{ type: 'option', value: 'USD' },
|
|
344
|
+
]}
|
|
345
|
+
filterable={filterable}
|
|
346
|
+
/>,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const trigger = screen.getByRole('combobox');
|
|
350
|
+
await userEvent.tab();
|
|
351
|
+
expect(trigger).toHaveFocus();
|
|
352
|
+
await userEvent.keyboard('{Enter}');
|
|
353
|
+
|
|
354
|
+
const controller = filterable
|
|
355
|
+
? screen.getByRole('combobox', { expanded: true })
|
|
356
|
+
: screen.getByRole('listbox');
|
|
357
|
+
await waitFor(() => {
|
|
358
|
+
expect(controller).toHaveFocus();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
362
|
+
expect(screen.getByRole('option', { name: 'EUR' })).toHaveClass(
|
|
363
|
+
'np-select-input-option-container--active',
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
224
367
|
it('clears filter query on close', async () => {
|
|
225
368
|
const handleFilterChange = jest.fn();
|
|
226
369
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mergeProps from 'merge-props';
|
|
2
|
-
import { useCallback, useEffect, useRef, useState
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Listbox as ListboxBase } from '@headlessui/react';
|
|
4
4
|
import { Breakpoint } from '@transferwise/neptune-tokens';
|
|
5
5
|
import { useScreenSize } from '../../common/hooks/useScreenSize';
|
|
@@ -10,6 +10,7 @@ import { SelectInputBottomSheet } from './BottomSheet';
|
|
|
10
10
|
import { SelectInputPopover } from './Popover';
|
|
11
11
|
import { SelectInputOptions } from './Options';
|
|
12
12
|
import { DefaultRenderTrigger } from './DefaultRenderTrigger';
|
|
13
|
+
import { isTypingKey } from './hooks/useTypeahead';
|
|
13
14
|
|
|
14
15
|
import {
|
|
15
16
|
SelectInputOptionContentWithinTriggerContext,
|
|
@@ -74,7 +75,6 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
74
75
|
}, [open]);
|
|
75
76
|
|
|
76
77
|
const [filterQuery, _setFilterQuery] = useState('');
|
|
77
|
-
const deferredFilterQuery = useDeferredValue(filterQuery);
|
|
78
78
|
const previousFilterQueryRef = useRef(filterQuery);
|
|
79
79
|
|
|
80
80
|
const setFilterQuery = useCallback(
|
|
@@ -94,6 +94,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
94
94
|
const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
95
95
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
96
96
|
const listboxRef = useRef<HTMLDivElement>(null);
|
|
97
|
+
const [initialTypeaheadKey, setInitialTypeaheadKey] = useState<string | null>(null);
|
|
97
98
|
const controllerRef = filterable ? searchInputRef : listboxRef;
|
|
98
99
|
|
|
99
100
|
const screenSm = useScreenSize(Breakpoint.SMALL);
|
|
@@ -175,13 +176,33 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
175
176
|
setOpen((prev) => !prev);
|
|
176
177
|
},
|
|
177
178
|
onKeyDown: (event: React.KeyboardEvent) => {
|
|
179
|
+
if (event.key === 'Enter') {
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
event.currentTarget.dispatchEvent(
|
|
182
|
+
new KeyboardEvent('keydown', { key: ' ', bubbles: true }),
|
|
183
|
+
);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!open && isTypingKey(event)) {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
|
|
190
|
+
if (filterable) {
|
|
191
|
+
setFilterQuery(event.key);
|
|
192
|
+
} else {
|
|
193
|
+
setInitialTypeaheadKey(event.key);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
(event.currentTarget as HTMLButtonElement).click();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
178
200
|
if (
|
|
179
201
|
event.key === ' ' ||
|
|
180
|
-
event.key === 'Enter' ||
|
|
181
202
|
event.key === 'ArrowDown' ||
|
|
182
203
|
event.key === 'ArrowUp'
|
|
183
204
|
) {
|
|
184
|
-
setOpen(
|
|
205
|
+
setOpen(true);
|
|
185
206
|
}
|
|
186
207
|
},
|
|
187
208
|
},
|
|
@@ -225,6 +246,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
225
246
|
setOpen(false);
|
|
226
247
|
}}
|
|
227
248
|
onCloseEnd={() => {
|
|
249
|
+
setInitialTypeaheadKey(null);
|
|
228
250
|
setFilterQuery('');
|
|
229
251
|
}}
|
|
230
252
|
>
|
|
@@ -240,10 +262,14 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
240
262
|
sortFilteredOptions={sortFilteredOptions}
|
|
241
263
|
searchInputRef={searchInputRef}
|
|
242
264
|
listboxRef={listboxRef}
|
|
243
|
-
filterQuery={
|
|
265
|
+
filterQuery={filterQuery}
|
|
266
|
+
initialTypeaheadKey={initialTypeaheadKey}
|
|
244
267
|
autocomplete={autocomplete}
|
|
245
268
|
name={name}
|
|
246
269
|
onFilterChange={setFilterQuery}
|
|
270
|
+
onInitialTypeaheadHandled={() => {
|
|
271
|
+
setInitialTypeaheadKey(null);
|
|
272
|
+
}}
|
|
247
273
|
onAutocompleteSelect={(matchedValue) => {
|
|
248
274
|
onChange?.(matchedValue as M extends true ? T[] : T);
|
|
249
275
|
if (!multiple) {
|
|
@@ -29,6 +29,9 @@ export function SelectInputTriggerButton<T extends SelectInputTriggerButtonEleme
|
|
|
29
29
|
ref={ref}
|
|
30
30
|
as={PolymorphicWithOverrides}
|
|
31
31
|
role="combobox"
|
|
32
|
+
// Safari can omit buttons from sequential keyboard navigation unless their
|
|
33
|
+
// focus order is explicit. Zero keeps the trigger in the natural tab order.
|
|
34
|
+
tabIndex={0}
|
|
32
35
|
__overrides={{ as, size, ...interactionProps }}
|
|
33
36
|
{...mergeProps({ onClick, onKeyDown }, restProps)}
|
|
34
37
|
/>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { TYPEAHEAD_QUERY_RESET_TIMEOUT_MS } from '../constants';
|
|
4
|
+
import { searchableString } from '../SelectInput.utils';
|
|
5
|
+
|
|
6
|
+
const resetsTypeaheadQuery = (key: string) =>
|
|
7
|
+
key === 'ArrowDown' ||
|
|
8
|
+
key === 'ArrowUp' ||
|
|
9
|
+
key === 'Home' ||
|
|
10
|
+
key === 'End' ||
|
|
11
|
+
key === 'PageDown' ||
|
|
12
|
+
key === 'PageUp';
|
|
13
|
+
|
|
14
|
+
export const isTypingKey = (event: React.KeyboardEvent, query = '') =>
|
|
15
|
+
!event.metaKey &&
|
|
16
|
+
!event.ctrlKey &&
|
|
17
|
+
!event.nativeEvent.isComposing &&
|
|
18
|
+
/^.$/u.test(event.key) &&
|
|
19
|
+
(event.key !== ' ' || query !== '');
|
|
20
|
+
|
|
21
|
+
interface UseTypeaheadParams {
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
onMatch?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useTypeahead({ disabled = false, onMatch }: UseTypeaheadParams = {}) {
|
|
27
|
+
const queryRef = useRef('');
|
|
28
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
+
|
|
30
|
+
const resetQuery = useCallback(() => {
|
|
31
|
+
queryRef.current = '';
|
|
32
|
+
if (timeoutRef.current != null) {
|
|
33
|
+
clearTimeout(timeoutRef.current);
|
|
34
|
+
timeoutRef.current = null;
|
|
35
|
+
}
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => resetQuery, [resetQuery]);
|
|
39
|
+
|
|
40
|
+
return useCallback(
|
|
41
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
42
|
+
if (disabled || !isTypingKey(event, queryRef.current)) {
|
|
43
|
+
if (resetsTypeaheadQuery(event.key)) {
|
|
44
|
+
resetQuery();
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
event.stopPropagation();
|
|
51
|
+
|
|
52
|
+
queryRef.current += event.key;
|
|
53
|
+
const needle = searchableString(queryRef.current);
|
|
54
|
+
|
|
55
|
+
if (timeoutRef.current != null) {
|
|
56
|
+
clearTimeout(timeoutRef.current);
|
|
57
|
+
}
|
|
58
|
+
timeoutRef.current = setTimeout(resetQuery, TYPEAHEAD_QUERY_RESET_TIMEOUT_MS);
|
|
59
|
+
|
|
60
|
+
const options = Array.from(
|
|
61
|
+
event.currentTarget.querySelectorAll<HTMLElement>(
|
|
62
|
+
'[role="option"]:not([aria-disabled="true"])',
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
const optionLabels = options.map((option) => searchableString(option.textContent ?? ''));
|
|
66
|
+
const matchingIndex = optionLabels.findIndex((label) => label.startsWith(needle));
|
|
67
|
+
const closestIndex =
|
|
68
|
+
matchingIndex === -1
|
|
69
|
+
? optionLabels.findIndex((label) => label.includes(needle))
|
|
70
|
+
: matchingIndex;
|
|
71
|
+
|
|
72
|
+
if (closestIndex === -1) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
options[closestIndex].focus({ preventScroll: true });
|
|
77
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
78
|
+
onMatch?.();
|
|
79
|
+
},
|
|
80
|
+
[disabled, onMatch, resetQuery],
|
|
81
|
+
);
|
|
82
|
+
}
|
package/src/i18n/ja.json
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"neptune.Table.loaded": "テーブルデータが読み込まれました",
|
|
51
51
|
"neptune.Table.loading": "テーブルデータを読み込み中です",
|
|
52
52
|
"neptune.Table.refreshPage": "ページを更新する",
|
|
53
|
-
"neptune.Typeahead.suggestionsLabel": "
|
|
53
|
+
"neptune.Typeahead.suggestionsLabel": "提案",
|
|
54
54
|
"neptune.Upload.csButtonText": "別のファイルをアップロードしますか?",
|
|
55
55
|
"neptune.Upload.csFailureText": "アップロードに失敗しました。もう一度やり直してください。",
|
|
56
56
|
"neptune.Upload.csSuccessText": "アップロードが完了しました。",
|
package/src/main.css
CHANGED
|
@@ -22323,6 +22323,16 @@ button.np-option {
|
|
|
22323
22323
|
min-block-size: 128px;
|
|
22324
22324
|
}
|
|
22325
22325
|
}
|
|
22326
|
+
.np-flow-navigation--no-steps {
|
|
22327
|
+
block-size: 80px;
|
|
22328
|
+
min-block-size: 80px;
|
|
22329
|
+
}
|
|
22330
|
+
@media (min-width: 320.02px) {
|
|
22331
|
+
.np-flow-navigation--no-steps {
|
|
22332
|
+
block-size: 80px;
|
|
22333
|
+
min-block-size: 80px;
|
|
22334
|
+
}
|
|
22335
|
+
}
|
|
22326
22336
|
.np-flow-navigation--border-bottom {
|
|
22327
22337
|
border-block-end: 1px solid rgba(0,0,0,0.10196);
|
|
22328
22338
|
border-block-end: 1px solid #0000001a;
|