@transferwise/components 46.160.1 → 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 (114) hide show
  1. package/build/Calendar/Calendar.js +9 -3
  2. package/build/Calendar/Calendar.js.map +1 -1
  3. package/build/Calendar/Calendar.mjs +9 -3
  4. package/build/Calendar/Calendar.mjs.map +1 -1
  5. package/build/Carousel/Carousel.js +17 -10
  6. package/build/Carousel/Carousel.js.map +1 -1
  7. package/build/Carousel/Carousel.mjs +17 -10
  8. package/build/Carousel/Carousel.mjs.map +1 -1
  9. package/build/Inputs/SelectInput/Options/SelectInputOptions.js +33 -5
  10. package/build/Inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
  11. package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs +34 -6
  12. package/build/Inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
  13. package/build/Inputs/SelectInput/SelectInput.js +28 -4
  14. package/build/Inputs/SelectInput/SelectInput.js.map +1 -1
  15. package/build/Inputs/SelectInput/SelectInput.mjs +29 -5
  16. package/build/Inputs/SelectInput/SelectInput.mjs.map +1 -1
  17. package/build/Inputs/SelectInput/SelectInput.utils.js +0 -2
  18. package/build/Inputs/SelectInput/SelectInput.utils.js.map +1 -1
  19. package/build/Inputs/SelectInput/SelectInput.utils.mjs +1 -2
  20. package/build/Inputs/SelectInput/SelectInput.utils.mjs.map +1 -1
  21. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js +5 -1
  22. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
  23. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs +5 -1
  24. package/build/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
  25. package/build/Inputs/SelectInput/constants.js +8 -0
  26. package/build/Inputs/SelectInput/constants.js.map +1 -0
  27. package/build/Inputs/SelectInput/constants.mjs +5 -0
  28. package/build/Inputs/SelectInput/constants.mjs.map +1 -0
  29. package/build/Inputs/SelectInput/hooks/useTypeahead.js +57 -0
  30. package/build/Inputs/SelectInput/hooks/useTypeahead.js.map +1 -0
  31. package/build/Inputs/SelectInput/hooks/useTypeahead.mjs +54 -0
  32. package/build/Inputs/SelectInput/hooks/useTypeahead.mjs.map +1 -0
  33. package/build/SegmentedControl/SegmentedControl.js +9 -2
  34. package/build/SegmentedControl/SegmentedControl.js.map +1 -1
  35. package/build/SegmentedControl/SegmentedControl.mjs +9 -2
  36. package/build/SegmentedControl/SegmentedControl.mjs.map +1 -1
  37. package/build/Tooltip/Tooltip.js +6 -1
  38. package/build/Tooltip/Tooltip.js.map +1 -1
  39. package/build/Tooltip/Tooltip.mjs +6 -1
  40. package/build/Tooltip/Tooltip.mjs.map +1 -1
  41. package/build/i18n/ja.json +1 -1
  42. package/build/i18n/ja.json.js +1 -1
  43. package/build/i18n/ja.json.mjs +1 -1
  44. package/build/i18n/tr.json +5 -0
  45. package/build/i18n/tr.json.js +5 -0
  46. package/build/i18n/tr.json.js.map +1 -1
  47. package/build/i18n/tr.json.mjs +5 -0
  48. package/build/i18n/tr.json.mjs.map +1 -1
  49. package/build/i18n/zh-CN.json +5 -0
  50. package/build/i18n/zh-CN.json.js +5 -0
  51. package/build/i18n/zh-CN.json.js.map +1 -1
  52. package/build/i18n/zh-CN.json.mjs +5 -0
  53. package/build/i18n/zh-CN.json.mjs.map +1 -1
  54. package/build/main.css +34 -22
  55. package/build/styles/AvatarLayout/AvatarLayout.css +26 -9
  56. package/build/styles/SegmentedControl/SegmentedControl.css +2 -1
  57. package/build/styles/Tooltip/Tooltip.css +0 -6
  58. package/build/styles/common/Panel/Panel.css +6 -6
  59. package/build/styles/main.css +34 -22
  60. package/build/types/Calendar/Calendar.d.ts.map +1 -1
  61. package/build/types/Carousel/Carousel.d.ts.map +1 -1
  62. package/build/types/Carousel/_stories/constants.d.ts +3 -0
  63. package/build/types/Carousel/_stories/constants.d.ts.map +1 -0
  64. package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts +3 -1
  65. package/build/types/Inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
  66. package/build/types/Inputs/SelectInput/SelectInput.d.ts.map +1 -1
  67. package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts +0 -1
  68. package/build/types/Inputs/SelectInput/SelectInput.utils.d.ts.map +1 -1
  69. package/build/types/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +1 -1
  70. package/build/types/Inputs/SelectInput/constants.d.ts +3 -0
  71. package/build/types/Inputs/SelectInput/constants.d.ts.map +1 -0
  72. package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts +8 -0
  73. package/build/types/Inputs/SelectInput/hooks/useTypeahead.d.ts.map +1 -0
  74. package/build/types/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  75. package/build/types/Tooltip/Tooltip.d.ts.map +1 -1
  76. package/package.json +4 -4
  77. package/src/AvatarLayout/AvatarLayout.css +26 -9
  78. package/src/AvatarLayout/AvatarLayout.less +46 -22
  79. package/src/AvatarLayout/_stories/AvatarLayout.test.story.tsx +17 -42
  80. package/src/Calendar/Calendar.test.tsx +82 -0
  81. package/src/Calendar/Calendar.tsx +6 -3
  82. package/src/Carousel/Carousel.test.tsx +50 -2
  83. package/src/Carousel/Carousel.tsx +13 -10
  84. package/src/Carousel/_stories/Carousel.story.tsx +12 -52
  85. package/src/Carousel/_stories/Carousel.test.story.tsx +7 -43
  86. package/src/Carousel/_stories/constants.tsx +46 -0
  87. package/src/DateLookup/_stories/DateLookup.rtl.test.story.tsx +59 -48
  88. package/src/Field/_stories/Field.test.story.tsx +48 -37
  89. package/src/FlowNavigation/_stories/FlowNavigation.rtl.test.story.tsx +78 -0
  90. package/src/FlowNavigation/_stories/FlowNavigation.test.story.tsx +0 -44
  91. package/src/Inputs/SelectInput/Options/SelectInputOptions.tsx +36 -6
  92. package/src/Inputs/SelectInput/SelectInput.test.tsx +143 -0
  93. package/src/Inputs/SelectInput/SelectInput.tsx +31 -5
  94. package/src/Inputs/SelectInput/SelectInput.utils.ts +0 -2
  95. package/src/Inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +3 -0
  96. package/src/Inputs/SelectInput/constants.ts +3 -0
  97. package/src/Inputs/SelectInput/hooks/useTypeahead.ts +82 -0
  98. package/src/Label/_stories/Label.test.story.tsx +35 -22
  99. package/src/Popover/_stories/Popover.rtl.test.story.tsx +40 -50
  100. package/src/SegmentedControl/SegmentedControl.css +2 -1
  101. package/src/SegmentedControl/SegmentedControl.less +4 -2
  102. package/src/SegmentedControl/SegmentedControl.tsx +8 -2
  103. package/src/SegmentedControl/_stories/SegmentedControl.test.story.tsx +16 -8
  104. package/src/Tooltip/Tooltip.css +0 -6
  105. package/src/Tooltip/Tooltip.less +0 -12
  106. package/src/Tooltip/Tooltip.test.tsx +51 -0
  107. package/src/Tooltip/Tooltip.tsx +11 -1
  108. package/src/Tooltip/_stories/Tooltip.rtl.test.story.tsx +16 -34
  109. package/src/common/Panel/Panel.css +6 -6
  110. package/src/common/Panel/Panel.less +6 -6
  111. package/src/i18n/ja.json +1 -1
  112. package/src/i18n/tr.json +5 -0
  113. package/src/i18n/zh-CN.json +5 -0
  114. package/src/main.css +34 -22
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-webpack5';
2
2
  import { userEvent, within } from 'storybook/test';
3
3
 
4
- import { withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
4
+ import { useRtlDiffI18n, withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
5
5
  import DateLookup from '..';
6
6
 
7
7
  export default {
@@ -14,39 +14,47 @@ type Story = StoryObj<typeof DateLookup>;
14
14
 
15
15
  const selectedDate = new Date(2024, 5, 14);
16
16
 
17
+ const i18nConfig = {
18
+ label: { ltr: 'Select date', rtl: 'اختر التاريخ' },
19
+ placeholder: { ltr: 'Choose a date', rtl: 'اختر تاريخًا' },
20
+ };
21
+
17
22
  export const TriggerVariants: Story = {
18
- render: () => (
19
- <>
20
- <DateLookup
21
- label="Select date"
22
- placeholder="Choose a date"
23
- value={selectedDate}
24
- onChange={() => {}}
25
- />
26
- <DateLookup
27
- label="Select date"
28
- placeholder="Choose a date"
29
- value={selectedDate}
30
- clearable
31
- onChange={() => {}}
32
- />
33
- <DateLookup
34
- label="Select date"
35
- placeholder="Choose a date"
36
- size="sm"
37
- value={selectedDate}
38
- onChange={() => {}}
39
- />
40
- <DateLookup
41
- label="Select date"
42
- placeholder="Choose a date"
43
- size="lg"
44
- value={selectedDate}
45
- onChange={() => {}}
46
- />
47
- </>
48
- ),
49
- ...withRTLDiff(),
23
+ render: () => {
24
+ const i18n = useRtlDiffI18n<{ label: string; placeholder: string }>();
25
+ return (
26
+ <>
27
+ <DateLookup
28
+ label={i18n.label}
29
+ placeholder={i18n.placeholder}
30
+ value={selectedDate}
31
+ onChange={() => {}}
32
+ />
33
+ <DateLookup
34
+ label={i18n.label}
35
+ placeholder={i18n.placeholder}
36
+ value={selectedDate}
37
+ clearable
38
+ onChange={() => {}}
39
+ />
40
+ <DateLookup
41
+ label={i18n.label}
42
+ placeholder={i18n.placeholder}
43
+ size="sm"
44
+ value={selectedDate}
45
+ onChange={() => {}}
46
+ />
47
+ <DateLookup
48
+ label={i18n.label}
49
+ placeholder={i18n.placeholder}
50
+ size="lg"
51
+ value={selectedDate}
52
+ onChange={() => {}}
53
+ />
54
+ </>
55
+ );
56
+ },
57
+ ...withRTLDiff({ renderAs: 'iframe', i18n: i18nConfig }),
50
58
  };
51
59
 
52
60
  const containerStyle: React.CSSProperties = {
@@ -59,16 +67,19 @@ const containerStyle: React.CSSProperties = {
59
67
  };
60
68
 
61
69
  export const OpenCalendarPanel: Story = {
62
- render: () => (
63
- <div style={containerStyle}>
64
- <DateLookup
65
- label="Select date"
66
- placeholder="Choose a date"
67
- value={selectedDate}
68
- onChange={() => {}}
69
- />
70
- </div>
71
- ),
70
+ render: () => {
71
+ const i18n = useRtlDiffI18n<{ label: string; placeholder: string }>();
72
+ return (
73
+ <div style={containerStyle}>
74
+ <DateLookup
75
+ label={i18n.label}
76
+ placeholder={i18n.placeholder}
77
+ value={selectedDate}
78
+ onChange={() => {}}
79
+ />
80
+ </div>
81
+ );
82
+ },
72
83
  play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
73
84
  const canvas = within(canvasElement);
74
85
  const trigger = canvas.queryByRole('button');
@@ -77,12 +88,12 @@ export const OpenCalendarPanel: Story = {
77
88
  // Suppress focus ring on calendar cells — FocusScope traps focus inside the panel
78
89
  // and the autofocus cycle causes the selected date's ring to flicker between iframes.
79
90
  const style = canvasElement.ownerDocument.createElement('style');
80
- style.textContent = `
81
- .np-panel__content *:focus {
82
- outline: none !important;
83
- box-shadow: none !important;
91
+ style.textContent = `
92
+ .np-panel__content *:focus {
93
+ outline: none !important;
94
+ box-shadow: none !important;
84
95
  }`;
85
96
  canvasElement.ownerDocument.head.appendChild(style);
86
97
  },
87
- ...withRTLDiff({ renderAs: 'iframe' }),
98
+ ...withRTLDiff({ renderAs: 'iframe', i18n: i18nConfig }),
88
99
  };
@@ -1,9 +1,8 @@
1
1
  import { useState } from 'react';
2
2
  import { Meta, StoryObj } from '@storybook/react-webpack5';
3
- import { withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
3
+ import { useRtlDiffI18n, withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
4
4
  import { Input } from '../../Inputs/Input/Input';
5
5
  import { TextArea } from '../../Inputs/TextArea/TextArea';
6
- import { lorem10 } from '../../test-utils';
7
6
  import Field, { FieldProps } from '..';
8
7
  import { Sentiment } from '../../common';
9
8
 
@@ -59,44 +58,56 @@ export const DeprecatedProps: Story = {
59
58
  };
60
59
 
61
60
  export const RTLDiff: Story = {
62
- render: () => (
63
- <>
64
- {/* Required label (default) — label text aligns to start */}
65
- <Field label="Required field">
66
- <Input defaultValue="Input value" />
67
- </Field>
61
+ render: () => {
62
+ const i18n = useRtlDiffI18n();
63
+ return (
64
+ <>
65
+ <Field label={i18n.requiredLabel}>
66
+ <Input defaultValue={i18n.inputValue} />
67
+ </Field>
68
68
 
69
- {/* Optional label — "(optional)" suffix uses directional margin-left */}
70
- <Field label="Optional field" required={false}>
71
- <Input defaultValue="Input value" />
72
- </Field>
69
+ <Field label={i18n.optionalLabel} required={false}>
70
+ <Input defaultValue={i18n.inputValue} />
71
+ </Field>
73
72
 
74
- {/* Label + description + message — full vertical layout with directional icon */}
75
- <Field
76
- label="Field with description and message"
77
- required={false}
78
- description={lorem10}
79
- message="This is a validation message"
80
- sentiment="negative"
81
- >
82
- <Input defaultValue="Input value" />
83
- </Field>
73
+ <Field
74
+ label={i18n.descriptionLabel}
75
+ required={false}
76
+ description={i18n.description}
77
+ message={i18n.validationMessage}
78
+ sentiment="negative"
79
+ >
80
+ <Input defaultValue={i18n.inputValue} />
81
+ </Field>
84
82
 
85
- {/* Message only (no label) — InlinePrompt icon+text layout visible */}
86
- <Field message="A helpful message" sentiment="positive">
87
- <Input defaultValue="Input value" />
88
- </Field>
83
+ <Field message={i18n.helpfulMessage} sentiment="positive">
84
+ <Input defaultValue={i18n.inputValue} />
85
+ </Field>
89
86
 
90
- {/* Loading message — spinner replaces icon but layout structure is identical */}
91
- <Field label="Field with loading message" message="Saving…" messageLoading>
92
- <Input defaultValue="Input value" />
93
- </Field>
87
+ <Field label={i18n.loadingLabel} message={i18n.savingMessage} messageLoading>
88
+ <Input defaultValue={i18n.inputValue} />
89
+ </Field>
94
90
 
95
- {/* Textarea with char counter — counter uses text-align:right + margin-left:auto */}
96
- <Field label="Textarea with char counter" required={false} description="Max 100 characters">
97
- <TextArea maxLength={100} defaultValue="Textarea value" />
98
- </Field>
99
- </>
100
- ),
101
- ...withRTLDiff(),
91
+ <Field label={i18n.textareaLabel} required={false} description={i18n.maxChars}>
92
+ <TextArea maxLength={100} defaultValue={i18n.textareaValue} />
93
+ </Field>
94
+ </>
95
+ );
96
+ },
97
+ ...withRTLDiff({
98
+ i18n: {
99
+ requiredLabel: { ltr: 'Required field', rtl: 'حقل مطلوب' },
100
+ optionalLabel: { ltr: 'Optional field', rtl: 'حقل اختياري' },
101
+ descriptionLabel: { ltr: 'Field with description and message', rtl: 'حقل مع وصف ورسالة' },
102
+ description: { ltr: 'A short description of the field', rtl: 'وصف قصير للحقل' },
103
+ validationMessage: { ltr: 'This is a validation message', rtl: 'هذه رسالة تحقق' },
104
+ helpfulMessage: { ltr: 'A helpful message', rtl: 'رسالة مفيدة' },
105
+ loadingLabel: { ltr: 'Field with loading message', rtl: 'حقل مع رسالة تحميل' },
106
+ savingMessage: { ltr: 'Saving…', rtl: 'جارٍ الحفظ…' },
107
+ textareaLabel: { ltr: 'Textarea with char counter', rtl: 'حقل نص مع عداد الأحرف' },
108
+ maxChars: { ltr: 'Max 100 characters', rtl: 'الحد الأقصى 100 حرف' },
109
+ inputValue: { ltr: 'Input value', rtl: 'قيمة الإدخال' },
110
+ textareaValue: { ltr: 'Textarea value', rtl: 'قيمة حقل النص' },
111
+ },
112
+ }),
102
113
  };
@@ -0,0 +1,78 @@
1
+ import { Meta, StoryObj } from '@storybook/react-webpack5';
2
+
3
+ import { withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
4
+ import AvatarView from '../../AvatarView';
5
+ import { ProfileType } from '../../common';
6
+ import Logo from '../../Logo';
7
+
8
+ import FlowNavigation from '../FlowNavigation';
9
+
10
+ const noop = () => {};
11
+
12
+ const steps = [
13
+ { label: 'Amount', hoverLabel: '100 GBP', onClick: noop },
14
+ { label: 'You', hoverLabel: 'Elena Durante', onClick: noop },
15
+ { label: 'Review', onClick: noop },
16
+ { label: 'Pay', onClick: noop },
17
+ ];
18
+
19
+ const meta: Meta<typeof FlowNavigation> = {
20
+ component: FlowNavigation,
21
+ title: 'Navigation/FlowNavigation/Tests/RTL Diff',
22
+ tags: ['!autodocs', '!manifest'],
23
+ parameters: {
24
+ padding: '0',
25
+ },
26
+ };
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof FlowNavigation>;
30
+
31
+ export const Desktop: Story = {
32
+ render: () => (
33
+ <>
34
+ <FlowNavigation
35
+ activeStep={2}
36
+ avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
37
+ logo={<Logo />}
38
+ steps={steps}
39
+ onClose={noop}
40
+ onGoBack={noop}
41
+ />
42
+ <FlowNavigation
43
+ avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
44
+ logo={<Logo />}
45
+ onClose={noop}
46
+ />
47
+ <FlowNavigation
48
+ done
49
+ avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
50
+ logo={<Logo />}
51
+ steps={steps}
52
+ onClose={noop}
53
+ />
54
+ </>
55
+ ),
56
+ ...withRTLDiff(),
57
+ };
58
+
59
+ export const Mobile: Story = {
60
+ render: () => (
61
+ <>
62
+ <FlowNavigation
63
+ activeStep={2}
64
+ avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
65
+ logo={<Logo />}
66
+ steps={steps}
67
+ onClose={noop}
68
+ onGoBack={noop}
69
+ />
70
+ <FlowNavigation
71
+ avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
72
+ logo={<Logo />}
73
+ onClose={noop}
74
+ />
75
+ </>
76
+ ),
77
+ ...withRTLDiff({ viewport: 'largeMobile' }),
78
+ };
@@ -1,6 +1,5 @@
1
1
  import { Meta, StoryObj } from '@storybook/react-webpack5';
2
2
 
3
- import { withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
4
3
  import AvatarView from '../../AvatarView';
5
4
  import { ProfileType } from '../../common';
6
5
  import Logo from '../../Logo';
@@ -77,46 +76,3 @@ export const LongLabel: Story = {
77
76
  />
78
77
  ),
79
78
  };
80
-
81
- const rtlSteps = [
82
- { label: 'Amount', hoverLabel: '100 GBP', onClick: noop },
83
- { label: 'You', hoverLabel: 'Elena Durante', onClick: noop },
84
- { label: 'Review', onClick: noop },
85
- { label: 'Pay', onClick: noop },
86
- ];
87
-
88
- /**
89
- * FlowNavigation has directional layout: back button on inline-start,
90
- * close button and avatar on inline-end, and a stepper progress bar that
91
- * flows in the reading direction. All of these flip in RTL.
92
- */
93
- export const RTLDiff: Story = {
94
- render: () => (
95
- <>
96
- {/* With stepper, back button, close button, and avatar */}
97
- <FlowNavigation
98
- activeStep={2}
99
- avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
100
- logo={<Logo />}
101
- steps={rtlSteps}
102
- onClose={noop}
103
- onGoBack={noop}
104
- />
105
- {/* Without stepper — logo left, close button right */}
106
- <FlowNavigation
107
- avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
108
- logo={<Logo />}
109
- onClose={noop}
110
- />
111
- {/* Done state — no stepper, logo and close button only */}
112
- <FlowNavigation
113
- done
114
- avatar={<AvatarView profileType={ProfileType.PERSONAL} />}
115
- logo={<Logo />}
116
- steps={rtlSteps}
117
- onClose={noop}
118
- />
119
- </>
120
- ),
121
- ...withRTLDiff(),
122
- };
@@ -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