@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.
Files changed (46) hide show
  1. package/build/Inputs/SelectInput/Options/SelectInputOptions.js +33 -5
  2. package/build/Inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
  3. package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs +34 -6
  4. package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
  5. package/build/Inputs/SelectInput/SelectInput.js +28 -4
  6. package/build/Inputs/SelectInput/SelectInput.js.map +1 -1
  7. package/build/Inputs/SelectInput/SelectInput.mjs +29 -5
  8. package/build/Inputs/SelectInput/SelectInput.mjs.map +1 -1
  9. package/build/Inputs/SelectInput/SelectInput.utils.js +0 -2
  10. package/build/Inputs/SelectInput/SelectInput.utils.js.map +1 -1
  11. package/build/Inputs/SelectInput/SelectInput.utils.mjs +1 -2
  12. package/build/Inputs/SelectInput/SelectInput.utils.mjs.map +1 -1
  13. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js +5 -1
  14. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  15. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs +5 -1
  16. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  17. package/build/Inputs/SelectInput/constants.js +8 -0
  18. package/build/Inputs/SelectInput/constants.js.map +1 -0
  19. package/build/Inputs/SelectInput/constants.mjs +5 -0
  20. package/build/Inputs/SelectInput/constants.mjs.map +1 -0
  21. package/build/Inputs/SelectInput/hooks/useTypeahead.js +57 -0
  22. package/build/Inputs/SelectInput/hooks/useTypeahead.js.map +1 -0
  23. package/build/Inputs/SelectInput/hooks/useTypeahead.mjs +54 -0
  24. package/build/Inputs/SelectInput/hooks/useTypeahead.mjs.map +1 -0
  25. package/build/i18n/ja.json +1 -1
  26. package/build/i18n/ja.json.js +1 -1
  27. package/build/i18n/ja.json.mjs +1 -1
  28. package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts +3 -1
  29. package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
  30. package/build/types/Inputs/SelectInput/SelectInput.d.ts.map +1 -1
  31. package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts +0 -1
  32. package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts.map +1 -1
  33. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +1 -1
  34. package/build/types/Inputs/SelectInput/constants.d.ts +3 -0
  35. package/build/types/Inputs/SelectInput/constants.d.ts.map +1 -0
  36. package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts +8 -0
  37. package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts.map +1 -0
  38. package/package.json +1 -1
  39. package/src/Inputs/SelectInput/Options/SelectInputOptions.tsx +36 -6
  40. package/src/Inputs/SelectInput/SelectInput.test.tsx +143 -0
  41. package/src/Inputs/SelectInput/SelectInput.tsx +31 -5
  42. package/src/Inputs/SelectInput/SelectInput.utils.ts +0 -2
  43. package/src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +3 -0
  44. package/src/Inputs/SelectInput/constants.ts +3 -0
  45. package/src/Inputs/SelectInput/hooks/useTypeahead.ts +82 -0
  46. 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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"}
@@ -50,7 +50,7 @@
50
50
  "neptune.Table.loaded": "テーブルデータが読み込まれました",
51
51
  "neptune.Table.loading": "テーブルデータを読み込み中です",
52
52
  "neptune.Table.refreshPage": "ページを更新する",
53
- "neptune.Typeahead.suggestionsLabel": "Suggestions",
53
+ "neptune.Typeahead.suggestionsLabel": "提案",
54
54
  "neptune.Upload.csButtonText": "別のファイルをアップロードしますか?",
55
55
  "neptune.Upload.csFailureText": "アップロードに失敗しました。もう一度やり直してください。",
56
56
  "neptune.Upload.csSuccessText": "アップロードが完了しました。",
@@ -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": "Suggestions",
57
+ "neptune.Typeahead.suggestionsLabel": "提案",
58
58
  "neptune.Upload.csButtonText": "別のファイルをアップロードしますか?",
59
59
  "neptune.Upload.csFailureText": "アップロードに失敗しました。もう一度やり直してください。",
60
60
  "neptune.Upload.csSuccessText": "アップロードが完了しました。",
@@ -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": "Suggestions",
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":"AAoBA,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,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,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,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,YAAY,EACZ,IAAI,EACJ,oBAAoB,GACrB,EAAE,uBAAuB,CAAC,CAAC,CAAC,+BA+V5B"}
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":"AAkBA,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,+BA8MxB;yBAzOe,WAAW"}
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,5 +1,4 @@
1
1
  import { SelectInputItem, SelectInputOptionItem } from './SelectInput.types';
2
- export declare const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
3
2
  /**
4
3
  * Converts a string to a normalized, searchable format by:
5
4
  * - Trimming whitespace
@@ -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,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAEnD;;;;;;;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
+ {"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,+BAclC"}
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,3 @@
1
+ export declare const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
2
+ export declare const TYPEAHEAD_QUERY_RESET_TIMEOUT_MS = 350;
3
+ //# sourceMappingURL=constants.d.ts.map
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@transferwise/components",
3
- "version": "46.160.2",
3
+ "version": "46.160.3",
4
4
  "description": "Neptune React components",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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 filterQuery ? searchableString(filterQuery) : null;
113
+ return deferredFilterQuery ? searchableString(deferredFilterQuery) : null;
85
114
  }
86
115
  return undefined;
87
- }, [filterQuery, filterable]);
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, filterQuery);
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
- defaultValue={filterQuery}
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, useDeferredValue } from 'react';
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((prev) => !prev);
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={deferredFilterQuery}
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) {
@@ -1,7 +1,5 @@
1
1
  import { SelectInputItem, SelectInputOptionItem } from './SelectInput.types';
2
2
 
3
- export const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
4
-
5
3
  /**
6
4
  * Converts a string to a normalized, searchable format by:
7
5
  * - Trimming whitespace
@@ -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,3 @@
1
+ export const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
2
+
3
+ export const TYPEAHEAD_QUERY_RESET_TIMEOUT_MS = 350;
@@ -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
+ }