@transferwise/components 46.160.2 → 46.160.3
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/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/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 +1 -1
- 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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { TYPEAHEAD_QUERY_RESET_TIMEOUT_MS } from '../constants.mjs';
|
|
3
|
+
import { searchableString } from '../SelectInput.utils.mjs';
|
|
4
|
+
|
|
5
|
+
const resetsTypeaheadQuery = key => key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End' || key === 'PageDown' || key === 'PageUp';
|
|
6
|
+
const isTypingKey = (event, query = '') => !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing && /^.$/u.test(event.key) && (event.key !== ' ' || query !== '');
|
|
7
|
+
function useTypeahead({
|
|
8
|
+
disabled = false,
|
|
9
|
+
onMatch
|
|
10
|
+
} = {}) {
|
|
11
|
+
const queryRef = useRef('');
|
|
12
|
+
const timeoutRef = useRef(null);
|
|
13
|
+
const resetQuery = useCallback(() => {
|
|
14
|
+
queryRef.current = '';
|
|
15
|
+
if (timeoutRef.current != null) {
|
|
16
|
+
clearTimeout(timeoutRef.current);
|
|
17
|
+
timeoutRef.current = null;
|
|
18
|
+
}
|
|
19
|
+
}, []);
|
|
20
|
+
useEffect(() => resetQuery, [resetQuery]);
|
|
21
|
+
return useCallback(event => {
|
|
22
|
+
if (disabled || !isTypingKey(event, queryRef.current)) {
|
|
23
|
+
if (resetsTypeaheadQuery(event.key)) {
|
|
24
|
+
resetQuery();
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
event.stopPropagation();
|
|
30
|
+
queryRef.current += event.key;
|
|
31
|
+
const needle = searchableString(queryRef.current);
|
|
32
|
+
if (timeoutRef.current != null) {
|
|
33
|
+
clearTimeout(timeoutRef.current);
|
|
34
|
+
}
|
|
35
|
+
timeoutRef.current = setTimeout(resetQuery, TYPEAHEAD_QUERY_RESET_TIMEOUT_MS);
|
|
36
|
+
const options = Array.from(event.currentTarget.querySelectorAll('[role="option"]:not([aria-disabled="true"])'));
|
|
37
|
+
const optionLabels = options.map(option => searchableString(option.textContent ?? ''));
|
|
38
|
+
const matchingIndex = optionLabels.findIndex(label => label.startsWith(needle));
|
|
39
|
+
const closestIndex = matchingIndex === -1 ? optionLabels.findIndex(label => label.includes(needle)) : matchingIndex;
|
|
40
|
+
if (closestIndex === -1) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
options[closestIndex].focus({
|
|
44
|
+
preventScroll: true
|
|
45
|
+
});
|
|
46
|
+
event.currentTarget.focus({
|
|
47
|
+
preventScroll: true
|
|
48
|
+
});
|
|
49
|
+
onMatch?.();
|
|
50
|
+
}, [disabled, onMatch, resetQuery]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { isTypingKey, useTypeahead };
|
|
54
|
+
//# sourceMappingURL=useTypeahead.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useTypeahead.mjs","sources":["../../../../src/Inputs/SelectInput/hooks/useTypeahead.ts"],"sourcesContent":["import { useCallback, useEffect, useRef } from 'react';\n\nimport { TYPEAHEAD_QUERY_RESET_TIMEOUT_MS } from '../constants';\nimport { searchableString } from '../SelectInput.utils';\n\nconst resetsTypeaheadQuery = (key: string) =>\n key === 'ArrowDown' ||\n key === 'ArrowUp' ||\n key === 'Home' ||\n key === 'End' ||\n key === 'PageDown' ||\n key === 'PageUp';\n\nexport const isTypingKey = (event: React.KeyboardEvent, query = '') =>\n !event.metaKey &&\n !event.ctrlKey &&\n !event.nativeEvent.isComposing &&\n /^.$/u.test(event.key) &&\n (event.key !== ' ' || query !== '');\n\ninterface UseTypeaheadParams {\n disabled?: boolean;\n onMatch?: () => void;\n}\n\nexport function useTypeahead({ disabled = false, onMatch }: UseTypeaheadParams = {}) {\n const queryRef = useRef('');\n const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const resetQuery = useCallback(() => {\n queryRef.current = '';\n if (timeoutRef.current != null) {\n clearTimeout(timeoutRef.current);\n timeoutRef.current = null;\n }\n }, []);\n\n useEffect(() => resetQuery, [resetQuery]);\n\n return useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (disabled || !isTypingKey(event, queryRef.current)) {\n if (resetsTypeaheadQuery(event.key)) {\n resetQuery();\n }\n return;\n }\n\n event.preventDefault();\n event.stopPropagation();\n\n queryRef.current += event.key;\n const needle = searchableString(queryRef.current);\n\n if (timeoutRef.current != null) {\n clearTimeout(timeoutRef.current);\n }\n timeoutRef.current = setTimeout(resetQuery, TYPEAHEAD_QUERY_RESET_TIMEOUT_MS);\n\n const options = Array.from(\n event.currentTarget.querySelectorAll<HTMLElement>(\n '[role=\"option\"]:not([aria-disabled=\"true\"])',\n ),\n );\n const optionLabels = options.map((option) => searchableString(option.textContent ?? ''));\n const matchingIndex = optionLabels.findIndex((label) => label.startsWith(needle));\n const closestIndex =\n matchingIndex === -1\n ? optionLabels.findIndex((label) => label.includes(needle))\n : matchingIndex;\n\n if (closestIndex === -1) {\n return;\n }\n\n options[closestIndex].focus({ preventScroll: true });\n event.currentTarget.focus({ preventScroll: true });\n onMatch?.();\n },\n [disabled, onMatch, resetQuery],\n );\n}\n"],"names":["resetsTypeaheadQuery","key","isTypingKey","event","query","metaKey","ctrlKey","nativeEvent","isComposing","test","useTypeahead","disabled","onMatch","queryRef","useRef","timeoutRef","resetQuery","useCallback","current","clearTimeout","useEffect","preventDefault","stopPropagation","needle","searchableString","setTimeout","TYPEAHEAD_QUERY_RESET_TIMEOUT_MS","options","Array","from","currentTarget","querySelectorAll","optionLabels","map","option","textContent","matchingIndex","findIndex","label","startsWith","closestIndex","includes","focus","preventScroll"],"mappings":"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"}
|
package/build/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/build/i18n/ja.json.js
CHANGED
|
@@ -54,7 +54,7 @@ var ja = {
|
|
|
54
54
|
"neptune.Table.loaded": "テーブルデータが読み込まれました",
|
|
55
55
|
"neptune.Table.loading": "テーブルデータを読み込み中です",
|
|
56
56
|
"neptune.Table.refreshPage": "ページを更新する",
|
|
57
|
-
"neptune.Typeahead.suggestionsLabel": "
|
|
57
|
+
"neptune.Typeahead.suggestionsLabel": "提案",
|
|
58
58
|
"neptune.Upload.csButtonText": "別のファイルをアップロードしますか?",
|
|
59
59
|
"neptune.Upload.csFailureText": "アップロードに失敗しました。もう一度やり直してください。",
|
|
60
60
|
"neptune.Upload.csSuccessText": "アップロードが完了しました。",
|
package/build/i18n/ja.json.mjs
CHANGED
|
@@ -50,7 +50,7 @@ var ja = {
|
|
|
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": "アップロードが完了しました。",
|
|
@@ -6,7 +6,9 @@ export interface SelectInputOptionsProps<T = string> extends Pick<SelectInputPro
|
|
|
6
6
|
searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
|
7
7
|
listboxRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
8
8
|
filterQuery: string;
|
|
9
|
+
initialTypeaheadKey?: string | null;
|
|
9
10
|
onFilterChange: (query: string) => void;
|
|
11
|
+
onInitialTypeaheadHandled?: () => void;
|
|
10
12
|
listBoxLabel?: string;
|
|
11
13
|
listBoxLabelledBy?: string;
|
|
12
14
|
autocomplete?: string;
|
|
@@ -17,5 +19,5 @@ export interface SelectInputOptionsProps<T = string> extends Pick<SelectInputPro
|
|
|
17
19
|
* The main options container component for SelectInput.
|
|
18
20
|
* Manages filtering, virtualisation, and rendering of options.
|
|
19
21
|
*/
|
|
20
|
-
export declare function SelectInputOptions<T = string>({ id, parentId, items, compareValues: compareValuesProp, renderValue, renderFooter, filterable, filterPlaceholder, sortFilteredOptions, searchInputRef, listboxRef, filterQuery, onFilterChange, listBoxLabel, listBoxLabelledBy, autocomplete, name, onAutocompleteSelect, }: SelectInputOptionsProps<T>): import("react").JSX.Element;
|
|
22
|
+
export declare function SelectInputOptions<T = string>({ id, parentId, items, compareValues: compareValuesProp, renderValue, renderFooter, filterable, filterPlaceholder, sortFilteredOptions, searchInputRef, listboxRef, filterQuery, initialTypeaheadKey, onFilterChange, onInitialTypeaheadHandled, listBoxLabel, listBoxLabelledBy, autocomplete, name, onAutocompleteSelect, }: SelectInputOptionsProps<T>): import("react").JSX.Element;
|
|
21
23
|
//# sourceMappingURL=SelectInputOptions.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SelectInputOptions.d.ts","sourceRoot":"","sources":["../../../../../src/Inputs/SelectInput/Options/SelectInputOptions.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SelectInputOptions.d.ts","sourceRoot":"","sources":["../../../../../src/Inputs/SelectInput/Options/SelectInputOptions.tsx"],"names":[],"mappings":"AAqBA,OAAO,EAAyB,gBAAgB,EAAmB,MAAM,sBAAsB,CAAC;AAMhG;;GAEG;AACH,MAAM,WAAW,uBAAuB,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,IAAI,CAC/D,gBAAgB,CAAC,CAAC,CAAC,EACjB,OAAO,GACP,aAAa,GACb,cAAc,GACd,YAAY,GACZ,mBAAmB,GACnB,IAAI,GACJ,UAAU,GACV,eAAe,GACf,qBAAqB,CACxB;IACC,cAAc,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAChE,UAAU,EAAE,KAAK,CAAC,gBAAgB,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,yBAAyB,CAAC,EAAE,MAAM,IAAI,CAAC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;CAC3C;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,GAAG,MAAM,EAAE,EAC7C,EAAE,EACF,QAAQ,EACR,KAAK,EACL,aAAa,EAAE,iBAAiB,EAChC,WAAoB,EACpB,YAAY,EACZ,UAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,UAAU,EACV,WAAW,EACX,mBAAmB,EACnB,cAAc,EACd,yBAAyB,EACzB,YAAY,EACZ,iBAAiB,EACjB,YAAY,EACZ,IAAI,EACJ,oBAAoB,GACrB,EAAE,uBAAuB,CAAC,CAAC,CAAC,+BAwX5B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SelectInput.d.ts","sourceRoot":"","sources":["../../../../src/Inputs/SelectInput/SelectInput.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SelectInput.d.ts","sourceRoot":"","sources":["../../../../src/Inputs/SelectInput/SelectInput.tsx"],"names":[],"mappings":"AAmBA,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAIvD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,GAAG,MAAM,EAAE,CAAC,SAAS,OAAO,GAAG,KAAK,EAAE,EACjE,EAAE,EAAE,MAAM,EACV,QAAQ,EACR,IAAI,EACJ,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,KAAK,EACL,YAAY,EACZ,KAAK,EAAE,eAAe,EACtB,aAAa,EACb,WAAoB,EACpB,YAAY,EACZ,aAAoC,EACpC,UAAU,EACV,iBAAiB,EACjB,mBAAmB,EACnB,QAAQ,EACR,IAAW,EACX,SAAS,EACT,yBAAyB,EACzB,UAAU,EAAE,kBAAkB,EAC9B,cAAqB,EACrB,QAAQ,EACR,MAAM,EACN,OAAO,EACP,OAAO,GACR,EAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,+BAuOxB;yBAlQe,WAAW"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SelectInput.utils.d.ts","sourceRoot":"","sources":["../../../../src/Inputs/SelectInput/SelectInput.utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE7E
|
|
1
|
+
{"version":3,"file":"SelectInput.utils.d.ts","sourceRoot":"","sources":["../../../../src/Inputs/SelectInput/SelectInput.utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE7E;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,UAW7C;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,YAYpD;AAED;;GAEG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAC3C,IAAI,EAAE,qBAAqB,CAAC,CAAC,CAAC,EAC9B,cAAc,EAAE,GAAG,CAAC,CAAC,CAAC,EACtB,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,OAAO,GACtC,qBAAqB,CAAC,CAAC,GAAG,SAAS,CAAC,CAUtC;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,CAAC,EACtC,KAAK,EAAE,SAAS,eAAe,CAAC,CAAC,CAAC,EAAE,EACpC,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,OAAO,GACtC,eAAe,CAAC,CAAC,GAAG,SAAS,CAAC,EAAE,CAoBlC;AAED;;GAEG;AACH,wBAAgB,mCAAmC,CAAC,CAAC,EACnD,IAAI,EAAE,qBAAqB,CAAC,CAAC,CAAC,EAC9B,MAAM,EAAE,MAAM,WAKf;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,CAAC,EACtC,KAAK,EAAE,SAAS,eAAe,CAAC,CAAC,CAAC,EAAE,EACpC,SAAS,EAAE,CAAC,IAAI,EAAE,qBAAqB,CAAC,CAAC,CAAC,KAAK,OAAO,wBAcvD;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,KAAK,EAAE,SAAS,eAAe,CAAC,CAAC,GAAG,SAAS,CAAC,EAAE,EAChD,SAAS,EAAE,CACT,CAAC,EAAE,qBAAqB,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EACxC,CAAC,EAAE,qBAAqB,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EACxC,WAAW,EAAE,MAAM,KAChB,MAAM,EACX,WAAW,EAAE,MAAM,GAClB,eAAe,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAiBnC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAC/B,QAAQ,GAAE,CAAC,KAAK,EAAE,CAAC,KAAK,MAAsD,GAC7E,CAAC,CAAC,EAAE,qBAAqB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,qBAAqB,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,KAAK,MAAM,CA2B3F"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SelectInputTriggerButton.d.ts","sourceRoot":"","sources":["../../../../../src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAGvC,MAAM,MAAM,mCAAmC,GAAG,KAAK,CAAC,WAAW,CAAC;AAEpE,MAAM,MAAM,6BAA6B,CACvC,CAAC,SAAS,mCAAmC,GAAG,QAAQ,IACtD,KAAK,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC,CAAC,EAAE;IAAE,EAAE,CAAC,EAAE,CAAC,CAAA;CAAE,CAAC,CAAC;AAEzD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,mCAAmC,GAAG,QAAQ,EAAE,EACjG,EAAkB,EAClB,GAAG,SAAS,EACb,EAAE,6BAA6B,CAAC,CAAC,CAAC,+
|
|
1
|
+
{"version":3,"file":"SelectInputTriggerButton.d.ts","sourceRoot":"","sources":["../../../../../src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAGvC,MAAM,MAAM,mCAAmC,GAAG,KAAK,CAAC,WAAW,CAAC;AAEpE,MAAM,MAAM,6BAA6B,CACvC,CAAC,SAAS,mCAAmC,GAAG,QAAQ,IACtD,KAAK,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC,CAAC,EAAE;IAAE,EAAE,CAAC,EAAE,CAAC,CAAA;CAAE,CAAC,CAAC;AAEzD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,mCAAmC,GAAG,QAAQ,EAAE,EACjG,EAAkB,EAClB,GAAG,SAAS,EACb,EAAE,6BAA6B,CAAC,CAAC,CAAC,+BAiBlC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/Inputs/SelectInput/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAEnD,eAAO,MAAM,gCAAgC,MAAM,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const isTypingKey: (event: React.KeyboardEvent, query?: string) => boolean;
|
|
2
|
+
interface UseTypeaheadParams {
|
|
3
|
+
disabled?: boolean;
|
|
4
|
+
onMatch?: () => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function useTypeahead({ disabled, onMatch }?: UseTypeaheadParams): (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=useTypeahead.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useTypeahead.d.ts","sourceRoot":"","sources":["../../../../../src/Inputs/SelectInput/hooks/useTypeahead.ts"],"names":[],"mappings":"AAaA,eAAO,MAAM,WAAW,GAAI,OAAO,KAAK,CAAC,aAAa,EAAE,cAAU,YAK7B,CAAC;AAEtC,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,YAAY,CAAC,EAAE,QAAgB,EAAE,OAAO,EAAE,GAAE,kBAAuB,WAevE,KAAK,CAAC,aAAa,CAAC,cAAc,CAAC,UAyC9C"}
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|