@transferwise/components 0.0.0-experimental-bcfa03a → 0.0.0-experimental-5cd0315

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 (180) hide show
  1. package/build/dateInput/DateInput.js +3 -6
  2. package/build/dateInput/DateInput.js.map +1 -1
  3. package/build/dateInput/DateInput.mjs +2 -5
  4. package/build/dateInput/DateInput.mjs.map +1 -1
  5. package/build/expressiveMoneyInput/currencySelector/CurrencySelector.js +3 -5
  6. package/build/expressiveMoneyInput/currencySelector/CurrencySelector.js.map +1 -1
  7. package/build/expressiveMoneyInput/currencySelector/CurrencySelector.mjs +1 -3
  8. package/build/expressiveMoneyInput/currencySelector/CurrencySelector.mjs.map +1 -1
  9. package/build/index.js +3 -5
  10. package/build/index.js.map +1 -1
  11. package/build/index.mjs +1 -3
  12. package/build/index.mjs.map +1 -1
  13. package/build/inputs/SelectInput.js +840 -0
  14. package/build/inputs/SelectInput.js.map +1 -0
  15. package/build/inputs/SelectInput.messages.js.map +1 -0
  16. package/build/inputs/SelectInput.messages.mjs.map +1 -0
  17. package/build/inputs/SelectInput.mjs +832 -0
  18. package/build/inputs/SelectInput.mjs.map +1 -0
  19. package/build/main.css +70 -65
  20. package/build/moneyInput/MoneyInput.js +2 -5
  21. package/build/moneyInput/MoneyInput.js.map +1 -1
  22. package/build/moneyInput/MoneyInput.mjs +1 -4
  23. package/build/moneyInput/MoneyInput.mjs.map +1 -1
  24. package/build/phoneNumberInput/PhoneNumberInput.js +2 -5
  25. package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
  26. package/build/phoneNumberInput/PhoneNumberInput.mjs +1 -4
  27. package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
  28. package/build/styles/inputs/{SelectInput/SelectInput.css → SelectInput.css} +70 -65
  29. package/build/styles/main.css +70 -65
  30. package/build/types/inputs/{SelectInput/SelectInput.types.d.ts → SelectInput.d.ts} +7 -10
  31. package/build/types/inputs/SelectInput.d.ts.map +1 -0
  32. package/build/types/inputs/SelectInput.messages.d.ts.map +1 -0
  33. package/package.json +2 -2
  34. package/src/inputs/{SelectInput/SelectInput.css → SelectInput.css} +70 -65
  35. package/src/inputs/{SelectInput/SelectInput.docs.mdx → SelectInput.docs.mdx} +1 -0
  36. package/src/inputs/SelectInput.less +219 -0
  37. package/src/inputs/{SelectInput/SelectInput.story.tsx → SelectInput.story.tsx} +7 -7
  38. package/src/inputs/SelectInput.tsx +1209 -0
  39. package/src/main.css +70 -65
  40. package/src/main.less +1 -1
  41. package/build/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.js +0 -26
  42. package/build/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.js.map +0 -1
  43. package/build/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.mjs +0 -24
  44. package/build/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.mjs.map +0 -1
  45. package/build/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.js +0 -54
  46. package/build/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.js.map +0 -1
  47. package/build/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.mjs +0 -52
  48. package/build/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.mjs.map +0 -1
  49. package/build/inputs/SelectInput/OptionContent/SelectInputOptionContent.js +0 -41
  50. package/build/inputs/SelectInput/OptionContent/SelectInputOptionContent.js.map +0 -1
  51. package/build/inputs/SelectInput/OptionContent/SelectInputOptionContent.mjs +0 -38
  52. package/build/inputs/SelectInput/OptionContent/SelectInputOptionContent.mjs.map +0 -1
  53. package/build/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.js +0 -50
  54. package/build/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.js.map +0 -1
  55. package/build/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.mjs +0 -48
  56. package/build/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.mjs.map +0 -1
  57. package/build/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.js +0 -45
  58. package/build/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.js.map +0 -1
  59. package/build/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.mjs +0 -41
  60. package/build/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.mjs.map +0 -1
  61. package/build/inputs/SelectInput/Options/ItemView/SelectInputItemView.js +0 -50
  62. package/build/inputs/SelectInput/Options/ItemView/SelectInputItemView.js.map +0 -1
  63. package/build/inputs/SelectInput/Options/ItemView/SelectInputItemView.mjs +0 -48
  64. package/build/inputs/SelectInput/Options/ItemView/SelectInputItemView.mjs.map +0 -1
  65. package/build/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.js +0 -48
  66. package/build/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.js.map +0 -1
  67. package/build/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.mjs +0 -46
  68. package/build/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.mjs.map +0 -1
  69. package/build/inputs/SelectInput/Options/SelectInputOptions.js +0 -270
  70. package/build/inputs/SelectInput/Options/SelectInputOptions.js.map +0 -1
  71. package/build/inputs/SelectInput/Options/SelectInputOptions.mjs +0 -268
  72. package/build/inputs/SelectInput/Options/SelectInputOptions.mjs.map +0 -1
  73. package/build/inputs/SelectInput/SelectInput.constants.js +0 -6
  74. package/build/inputs/SelectInput/SelectInput.constants.js.map +0 -1
  75. package/build/inputs/SelectInput/SelectInput.constants.mjs +0 -4
  76. package/build/inputs/SelectInput/SelectInput.constants.mjs.map +0 -1
  77. package/build/inputs/SelectInput/SelectInput.helpers.js +0 -115
  78. package/build/inputs/SelectInput/SelectInput.helpers.js.map +0 -1
  79. package/build/inputs/SelectInput/SelectInput.helpers.mjs +0 -109
  80. package/build/inputs/SelectInput/SelectInput.helpers.mjs.map +0 -1
  81. package/build/inputs/SelectInput/SelectInput.js +0 -216
  82. package/build/inputs/SelectInput/SelectInput.js.map +0 -1
  83. package/build/inputs/SelectInput/SelectInput.messages.js.map +0 -1
  84. package/build/inputs/SelectInput/SelectInput.messages.mjs.map +0 -1
  85. package/build/inputs/SelectInput/SelectInput.mjs +0 -210
  86. package/build/inputs/SelectInput/SelectInput.mjs.map +0 -1
  87. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js +0 -41
  88. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +0 -1
  89. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs +0 -34
  90. package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +0 -1
  91. package/build/styles/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.css +0 -17
  92. package/build/styles/inputs/SelectInput/OptionContent/SelectInputOptionContent.css +0 -37
  93. package/build/styles/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.css +0 -33
  94. package/build/styles/inputs/SelectInput/Options/ItemView/SelectInputItemView.css +0 -44
  95. package/build/styles/inputs/SelectInput/Options/SelectInputOptions.css +0 -125
  96. package/build/types/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.d.ts +0 -5
  97. package/build/types/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.d.ts.map +0 -1
  98. package/build/types/inputs/SelectInput/DefaultTrigger/ClearButton/index.d.ts +0 -2
  99. package/build/types/inputs/SelectInput/DefaultTrigger/ClearButton/index.d.ts.map +0 -1
  100. package/build/types/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.d.ts +0 -9
  101. package/build/types/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.d.ts.map +0 -1
  102. package/build/types/inputs/SelectInput/DefaultTrigger/index.d.ts +0 -2
  103. package/build/types/inputs/SelectInput/DefaultTrigger/index.d.ts.map +0 -1
  104. package/build/types/inputs/SelectInput/OptionContent/SelectInputOptionContent.d.ts +0 -9
  105. package/build/types/inputs/SelectInput/OptionContent/SelectInputOptionContent.d.ts.map +0 -1
  106. package/build/types/inputs/SelectInput/OptionContent/index.d.ts +0 -3
  107. package/build/types/inputs/SelectInput/OptionContent/index.d.ts.map +0 -1
  108. package/build/types/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.d.ts +0 -3
  109. package/build/types/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.d.ts.map +0 -1
  110. package/build/types/inputs/SelectInput/Options/GroupItemView/index.d.ts +0 -2
  111. package/build/types/inputs/SelectInput/Options/GroupItemView/index.d.ts.map +0 -1
  112. package/build/types/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.d.ts +0 -10
  113. package/build/types/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.d.ts.map +0 -1
  114. package/build/types/inputs/SelectInput/Options/ItemView/Option/index.d.ts +0 -2
  115. package/build/types/inputs/SelectInput/Options/ItemView/Option/index.d.ts.map +0 -1
  116. package/build/types/inputs/SelectInput/Options/ItemView/SelectInputItemView.d.ts +0 -3
  117. package/build/types/inputs/SelectInput/Options/ItemView/SelectInputItemView.d.ts.map +0 -1
  118. package/build/types/inputs/SelectInput/Options/ItemView/index.d.ts +0 -2
  119. package/build/types/inputs/SelectInput/Options/ItemView/index.d.ts.map +0 -1
  120. package/build/types/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.d.ts +0 -6
  121. package/build/types/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.d.ts.map +0 -1
  122. package/build/types/inputs/SelectInput/Options/OptionsContainer/index.d.ts +0 -2
  123. package/build/types/inputs/SelectInput/Options/OptionsContainer/index.d.ts.map +0 -1
  124. package/build/types/inputs/SelectInput/Options/SelectInputOptions.d.ts +0 -15
  125. package/build/types/inputs/SelectInput/Options/SelectInputOptions.d.ts.map +0 -1
  126. package/build/types/inputs/SelectInput/Options/index.d.ts +0 -2
  127. package/build/types/inputs/SelectInput/Options/index.d.ts.map +0 -1
  128. package/build/types/inputs/SelectInput/SelectInput.constants.d.ts +0 -2
  129. package/build/types/inputs/SelectInput/SelectInput.constants.d.ts.map +0 -1
  130. package/build/types/inputs/SelectInput/SelectInput.d.ts +0 -3
  131. package/build/types/inputs/SelectInput/SelectInput.d.ts.map +0 -1
  132. package/build/types/inputs/SelectInput/SelectInput.helpers.d.ts +0 -28
  133. package/build/types/inputs/SelectInput/SelectInput.helpers.d.ts.map +0 -1
  134. package/build/types/inputs/SelectInput/SelectInput.messages.d.ts.map +0 -1
  135. package/build/types/inputs/SelectInput/SelectInput.types.d.ts.map +0 -1
  136. package/build/types/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts +0 -15
  137. package/build/types/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.d.ts.map +0 -1
  138. package/build/types/inputs/SelectInput/TriggerButton/index.d.ts +0 -3
  139. package/build/types/inputs/SelectInput/TriggerButton/index.d.ts.map +0 -1
  140. package/build/types/inputs/SelectInput/index.d.ts +0 -5
  141. package/build/types/inputs/SelectInput/index.d.ts.map +0 -1
  142. package/src/inputs/SelectInput/DefaultTrigger/ClearButton/SelectInputClearButton.tsx +0 -25
  143. package/src/inputs/SelectInput/DefaultTrigger/ClearButton/index.ts +0 -1
  144. package/src/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.css +0 -17
  145. package/src/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.less +0 -15
  146. package/src/inputs/SelectInput/DefaultTrigger/SelectInputDefaultTrigger.tsx +0 -56
  147. package/src/inputs/SelectInput/DefaultTrigger/index.ts +0 -1
  148. package/src/inputs/SelectInput/OptionContent/SelectInputOptionContent.css +0 -37
  149. package/src/inputs/SelectInput/OptionContent/SelectInputOptionContent.less +0 -38
  150. package/src/inputs/SelectInput/OptionContent/SelectInputOptionContent.tsx +0 -67
  151. package/src/inputs/SelectInput/OptionContent/index.ts +0 -5
  152. package/src/inputs/SelectInput/Options/GroupItemView/SelectInputGroupItemView.tsx +0 -53
  153. package/src/inputs/SelectInput/Options/GroupItemView/index.ts +0 -1
  154. package/src/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.css +0 -33
  155. package/src/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.less +0 -32
  156. package/src/inputs/SelectInput/Options/ItemView/Option/SelectInputOption.tsx +0 -51
  157. package/src/inputs/SelectInput/Options/ItemView/Option/index.ts +0 -5
  158. package/src/inputs/SelectInput/Options/ItemView/SelectInputItemView.css +0 -44
  159. package/src/inputs/SelectInput/Options/ItemView/SelectInputItemView.less +0 -14
  160. package/src/inputs/SelectInput/Options/ItemView/SelectInputItemView.tsx +0 -37
  161. package/src/inputs/SelectInput/Options/ItemView/index.ts +0 -1
  162. package/src/inputs/SelectInput/Options/OptionsContainer/SelectInputOptionsContainer.tsx +0 -55
  163. package/src/inputs/SelectInput/Options/OptionsContainer/index.ts +0 -1
  164. package/src/inputs/SelectInput/Options/SelectInputOptions.css +0 -125
  165. package/src/inputs/SelectInput/Options/SelectInputOptions.less +0 -78
  166. package/src/inputs/SelectInput/Options/SelectInputOptions.tsx +0 -372
  167. package/src/inputs/SelectInput/Options/index.ts +0 -1
  168. package/src/inputs/SelectInput/SelectInput.constants.ts +0 -1
  169. package/src/inputs/SelectInput/SelectInput.helpers.ts +0 -152
  170. package/src/inputs/SelectInput/SelectInput.less +0 -40
  171. package/src/inputs/SelectInput/SelectInput.test.tsx +0 -606
  172. package/src/inputs/SelectInput/SelectInput.tsx +0 -247
  173. package/src/inputs/SelectInput/SelectInput.types.ts +0 -127
  174. package/src/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +0 -39
  175. package/src/inputs/SelectInput/TriggerButton/index.ts +0 -5
  176. package/src/inputs/SelectInput/index.ts +0 -13
  177. /package/build/inputs/{SelectInput/SelectInput.messages.js → SelectInput.messages.js} +0 -0
  178. /package/build/inputs/{SelectInput/SelectInput.messages.mjs → SelectInput.messages.mjs} +0 -0
  179. /package/build/types/inputs/{SelectInput/SelectInput.messages.d.ts → SelectInput.messages.d.ts} +0 -0
  180. /package/src/inputs/{SelectInput/SelectInput.messages.ts → SelectInput.messages.ts} +0 -0
@@ -0,0 +1,1209 @@
1
+ import {
2
+ Listbox as ListboxBase,
3
+ ListboxButton,
4
+ ListboxOption,
5
+ ListboxOptions,
6
+ } from '@headlessui/react';
7
+ import { Check, ChevronDown, Cross, CrossCircle } from '@transferwise/icons';
8
+ import { clsx } from 'clsx';
9
+ import mergeProps from 'merge-props';
10
+ import {
11
+ createContext,
12
+ forwardRef,
13
+ ReactNode,
14
+ useContext,
15
+ useDeferredValue,
16
+ useEffect,
17
+ useId,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ } from 'react';
22
+ import { useIntl } from 'react-intl';
23
+ import { Virtualizer, type VirtualizerHandle } from 'virtua';
24
+
25
+ import { useEffectEvent } from '../common/hooks/useEffectEvent';
26
+ import { useScreenSize } from '../common/hooks/useScreenSize';
27
+ import { PolymorphicWithOverrides } from '../common/polymorphicWithOverrides/PolymorphicWithOverrides';
28
+ import { Breakpoint } from '../common/propsValues/breakpoint';
29
+ import dateTriggerMessages from '../dateLookup/dateTrigger/DateTrigger.messages';
30
+ import { Merge } from '../utils';
31
+
32
+ import { BottomSheet } from './_BottomSheet';
33
+ import { ButtonInput } from './_ButtonInput';
34
+ import { Popover } from './_Popover';
35
+ import { useInputAttributes, WithInputAttributesProps } from './contexts';
36
+ import { InputGroup } from './InputGroup';
37
+ import { SearchInput } from './SearchInput';
38
+ import messages from './SelectInput.messages';
39
+ import Header from '../header';
40
+ import Section from '../section';
41
+ import { ButtonProps } from '../button/Button.types';
42
+
43
+ const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
44
+
45
+ function searchableString(value: string) {
46
+ return (
47
+ value
48
+ .trim()
49
+ .replace(/\s+/gu, ' ')
50
+ // NFD converts an Å to A + ̊ (and other special characters)
51
+ .normalize('NFD')
52
+ // and then this replaces the ̊ with nothing (and other special characters)
53
+ .replace(/[\u0300-\u036f]/g, '')
54
+ .toLowerCase()
55
+ );
56
+ }
57
+
58
+ function inferSearchableStrings(value: unknown) {
59
+ if (typeof value === 'string') {
60
+ return [searchableString(value)];
61
+ }
62
+
63
+ if (typeof value === 'object' && value != null) {
64
+ return Object.values(value)
65
+ .filter((innerValue) => typeof innerValue === 'string')
66
+ .map((innerValue) => searchableString(innerValue));
67
+ }
68
+
69
+ return [];
70
+ }
71
+
72
+ export interface SelectInputOptionItem<T = string> {
73
+ type: 'option';
74
+ value: T;
75
+ filterMatchers?: readonly string[];
76
+ disabled?: boolean;
77
+ }
78
+
79
+ export interface SelectInputGroupItem<T = string> {
80
+ type: 'group';
81
+ label: ReactNode;
82
+ options: readonly SelectInputOptionItem<T>[];
83
+ action?: {
84
+ label: string;
85
+ onClick: ButtonProps['onClick'];
86
+ };
87
+ }
88
+
89
+ export interface SelectInputSeparatorItem {
90
+ type: 'separator';
91
+ }
92
+
93
+ export type SelectInputItem<T = string> =
94
+ | SelectInputOptionItem<T>
95
+ | SelectInputGroupItem<T>
96
+ | SelectInputSeparatorItem;
97
+
98
+ function dedupeSelectInputOptionItem<T>(
99
+ item: SelectInputOptionItem<T>,
100
+ existingValues: Set<T>,
101
+ compareValues?: (a: T, b: T) => boolean,
102
+ ): SelectInputOptionItem<T | undefined> {
103
+ const isDuplicate = compareValues
104
+ ? Array.from(existingValues).some((existingValue) => compareValues(item.value, existingValue))
105
+ : existingValues.has(item.value);
106
+
107
+ if (!isDuplicate) {
108
+ existingValues.add(item.value);
109
+ return item;
110
+ }
111
+ return { ...item, value: undefined };
112
+ }
113
+
114
+ /**
115
+ * Sets the `value` of duplicate option items to `undefined`, hiding them when
116
+ * rendered. Indexes are kept intact within groups to preserve the active item
117
+ * between filter changes when possible.
118
+ */
119
+ function dedupeSelectInputItems<T>(
120
+ items: readonly SelectInputItem<T>[],
121
+ compareValues?: (a: T, b: T) => boolean,
122
+ ): SelectInputItem<T | undefined>[] {
123
+ const existingValues = new Set<T>();
124
+
125
+ return items.map((item) => {
126
+ switch (item.type) {
127
+ case 'option': {
128
+ return dedupeSelectInputOptionItem(item, existingValues, compareValues);
129
+ }
130
+ case 'group': {
131
+ return {
132
+ ...item,
133
+ options: item.options.map((option) =>
134
+ dedupeSelectInputOptionItem(option, existingValues, compareValues),
135
+ ),
136
+ };
137
+ }
138
+ default:
139
+ }
140
+ return item;
141
+ });
142
+ }
143
+
144
+ function selectInputOptionItemIncludesNeedle<T>(item: SelectInputOptionItem<T>, needle: string) {
145
+ return inferSearchableStrings(item.filterMatchers ?? item.value).some((haystack) =>
146
+ haystack.includes(needle),
147
+ );
148
+ }
149
+
150
+ function filterSelectInputItems<T>(
151
+ items: readonly SelectInputItem<T>[],
152
+ predicate: (item: SelectInputOptionItem<T>) => boolean,
153
+ ) {
154
+ return items.filter((item) => {
155
+ switch (item.type) {
156
+ case 'option': {
157
+ return predicate(item);
158
+ }
159
+ case 'group': {
160
+ return item.options.some((option) => predicate(option));
161
+ }
162
+ default:
163
+ }
164
+ return false;
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Flattens and sorts filtered options using the provided comparator.
170
+ * Extracts all options from groups, filters out undefined values (deduplicated items),
171
+ * sorts them, and returns as a flat list of option items.
172
+ */
173
+ function sortSelectInputItems<T>(
174
+ items: readonly SelectInputItem<T | undefined>[],
175
+ compareFn: (
176
+ a: SelectInputOptionItem<NonNullable<T>>,
177
+ b: SelectInputOptionItem<NonNullable<T>>,
178
+ searchQuery: string,
179
+ ) => number,
180
+ searchQuery: string,
181
+ ): SelectInputItem<NonNullable<T>>[] {
182
+ const flattenedOption = items.flatMap((item) => {
183
+ if (item.type === 'option') {
184
+ return item.value !== undefined ? [item as SelectInputOptionItem<NonNullable<T>>] : [];
185
+ }
186
+
187
+ if (item.type === 'group') {
188
+ return item.options.filter(
189
+ (option): option is SelectInputOptionItem<NonNullable<T>> => option.value !== undefined,
190
+ );
191
+ }
192
+
193
+ return [];
194
+ });
195
+
196
+ // eslint-disable-next-line functional/immutable-data
197
+ return flattenedOption.sort((a, b) => compareFn(a, b, searchQuery));
198
+ }
199
+
200
+ export interface SelectInputProps<T = string, M extends boolean = false> {
201
+ id?: string;
202
+ /**
203
+ * Sets the `data-wds-parent` attribute on the listbox container, which is needed for complex components like DateInput to correctly manage event handling.
204
+ * @internal
205
+ */
206
+ parentId?: string;
207
+ name?: string;
208
+ multiple?: M;
209
+ placeholder?: string;
210
+ items: readonly SelectInputItem<NonNullable<T>>[];
211
+ /**
212
+ * Enables browser autocomplete integration through the search input.
213
+ * Accepts standard HTML autocomplete values (e.g., "country-name", "address-level1").
214
+ *
215
+ * Requires `filterable={true}` to enable the search input.
216
+ *
217
+ * @example
218
+ * <SelectInput
219
+ * name="country"
220
+ * autocomplete="country-name"
221
+ * filterable={true}
222
+ * items={[{
223
+ * type: 'option',
224
+ * value: 'GB',
225
+ * filterMatchers: ['United Kingdom', 'UK']
226
+ * }]}
227
+ * />
228
+ */
229
+ autocomplete?: string;
230
+ defaultValue?: M extends true ? readonly T[] : T;
231
+ value?: M extends true ? readonly T[] : T;
232
+ compareValues?:
233
+ | (keyof NonNullable<T> & string)
234
+ | ((a: T | undefined, b: T | undefined) => boolean);
235
+ renderValue?: (value: NonNullable<T>, withinTrigger: boolean) => React.ReactNode;
236
+ renderFooter?: (args: {
237
+ resultsEmpty: boolean;
238
+ queryNormalized: string | null | undefined;
239
+ }) => React.ReactNode;
240
+ renderTrigger?: (args: {
241
+ content: React.ReactNode;
242
+ placeholderShown: boolean;
243
+ clear: (() => void) | undefined;
244
+ disabled: boolean;
245
+ size: 'sm' | 'md' | 'lg';
246
+ className: string | undefined;
247
+ }) => React.ReactNode;
248
+ filterable?: boolean;
249
+ filterPlaceholder?: string;
250
+ sortFilteredOptions?: (
251
+ a: SelectInputOptionItem<NonNullable<T>>,
252
+ b: SelectInputOptionItem<NonNullable<T>>,
253
+ searchQuery: string,
254
+ ) => number;
255
+ disabled?: boolean;
256
+ size?: 'sm' | 'md' | 'lg';
257
+ className?: string;
258
+ UNSAFE_triggerButtonProps?: WithInputAttributesProps['inputAttributes'] & {
259
+ 'aria-label'?: string;
260
+ };
261
+ /** Ref to the select trigger button element. */
262
+ triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
263
+ onFilterChange?: (args: { query: string; queryNormalized: string | null }) => void;
264
+ onChange?: (value: M extends true ? T[] : T) => void;
265
+ onOpen?: () => void;
266
+ onClose?: () => void;
267
+ onClear?: () => void;
268
+ }
269
+
270
+ const defaultRenderTrigger = (({ content, placeholderShown, clear, disabled, size, className }) => (
271
+ <InputGroup
272
+ addonEnd={{
273
+ content: (
274
+ <span className={clsx('np-select-input-addon-container', disabled && 'disabled')}>
275
+ {clear != null && !placeholderShown ? (
276
+ <>
277
+ <SelectInputClearButton
278
+ onClick={(event) => {
279
+ event.preventDefault();
280
+ clear();
281
+ }}
282
+ />
283
+ <span className="np-select-input-addon-separator" />
284
+ </>
285
+ ) : null}
286
+
287
+ <span className="np-select-input-addon">
288
+ <ChevronDown size={16} />
289
+ </span>
290
+ </span>
291
+ ),
292
+ initialContentWidth: 24 + 4,
293
+ padding: 'sm',
294
+ }}
295
+ disabled={disabled}
296
+ className={className}
297
+ >
298
+ <SelectInputTriggerButton as={ButtonInput} size={size}>
299
+ <span
300
+ className={clsx(
301
+ 'np-select-input-content',
302
+ placeholderShown && 'np-select-input-placeholder',
303
+ )}
304
+ >
305
+ {content}
306
+ </span>
307
+ </SelectInputTriggerButton>
308
+ </InputGroup>
309
+ )) satisfies SelectInputProps['renderTrigger'];
310
+
311
+ interface SelectInputClearButtonProps extends Pick<
312
+ React.ComponentPropsWithoutRef<'button'>,
313
+ 'className' | 'onClick'
314
+ > {}
315
+
316
+ function SelectInputClearButton({ className, onClick }: SelectInputClearButtonProps) {
317
+ const intl = useIntl();
318
+
319
+ return (
320
+ <button
321
+ type="button"
322
+ aria-label={intl.formatMessage(dateTriggerMessages.ariaLabel)}
323
+ className={clsx(className, 'np-select-input-addon np-select-input-addon--interactive')}
324
+ onClick={onClick}
325
+ >
326
+ <Cross size={16} />
327
+ </button>
328
+ );
329
+ }
330
+
331
+ const noop = () => {};
332
+
333
+ export function SelectInput<T = string, M extends boolean = false>({
334
+ id: idProp,
335
+ parentId,
336
+ name,
337
+ multiple,
338
+ placeholder,
339
+ autocomplete,
340
+ items,
341
+ defaultValue,
342
+ value: controlledValue,
343
+ compareValues,
344
+ renderValue = String,
345
+ renderFooter,
346
+ renderTrigger = defaultRenderTrigger,
347
+ filterable,
348
+ filterPlaceholder,
349
+ sortFilteredOptions,
350
+ disabled,
351
+ size = 'md',
352
+ className,
353
+ UNSAFE_triggerButtonProps,
354
+ triggerRef: externalTriggerRef,
355
+ onFilterChange = noop,
356
+ onChange,
357
+ onOpen,
358
+ onClose,
359
+ onClear,
360
+ }: SelectInputProps<T, M>) {
361
+ const inputAttributes = useInputAttributes({ nonLabelable: true });
362
+ const id = idProp ?? inputAttributes.id;
363
+
364
+ const [open, setOpen] = useState(false);
365
+
366
+ const initialized = useRef(false);
367
+ const handleClose = useEffectEvent(onClose ?? (() => {}));
368
+ const handleOpen = useEffectEvent(onOpen ?? (() => {}));
369
+ useEffect(() => {
370
+ if (initialized.current) {
371
+ if (open) {
372
+ handleOpen?.();
373
+ } else {
374
+ handleClose?.();
375
+ }
376
+ } else {
377
+ initialized.current = true;
378
+ }
379
+ }, [handleClose, handleOpen, open]);
380
+
381
+ const [filterQuery, _setFilterQuery] = useState('');
382
+ const deferredFilterQuery = useDeferredValue(filterQuery);
383
+ const setFilterQuery = useEffectEvent((query: string) => {
384
+ _setFilterQuery(query);
385
+ if (query !== filterQuery) {
386
+ onFilterChange({
387
+ query,
388
+ queryNormalized: query ? searchableString(query) : null,
389
+ });
390
+ }
391
+ });
392
+
393
+ const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
394
+
395
+ const screenSm = useScreenSize(Breakpoint.SMALL);
396
+ const OptionsOverlay = screenSm ? Popover : BottomSheet;
397
+
398
+ const searchInputRef = useRef<HTMLInputElement>(null);
399
+ const listboxRef = useRef<HTMLDivElement>(null);
400
+ const controllerRef = filterable ? searchInputRef : listboxRef;
401
+
402
+ /**
403
+ * Attempts to resolve the `listbox` label
404
+ * @see https://storybook.wise.design/?path=/docs/forms-selectinput-accessibility--docs#labelling
405
+ */
406
+ const getListBoxLabelProps = (): {
407
+ listBoxLabel?: string;
408
+ listBoxLabelledBy?: string;
409
+ } => {
410
+ if (UNSAFE_triggerButtonProps?.['aria-label']) {
411
+ return {
412
+ listBoxLabel: UNSAFE_triggerButtonProps['aria-label'],
413
+ };
414
+ }
415
+
416
+ if (UNSAFE_triggerButtonProps?.['aria-labelledby']) {
417
+ return {
418
+ listBoxLabelledBy: UNSAFE_triggerButtonProps['aria-labelledby'],
419
+ };
420
+ }
421
+
422
+ if (inputAttributes['aria-labelledby']) {
423
+ return {
424
+ listBoxLabelledBy: inputAttributes['aria-labelledby'],
425
+ };
426
+ }
427
+
428
+ return {};
429
+ };
430
+
431
+ return (
432
+ <ListboxBase
433
+ name={name}
434
+ multiple={multiple}
435
+ defaultValue={defaultValue as M extends true ? T[] : T}
436
+ value={controlledValue as M extends true ? T[] : T}
437
+ by={compareValues}
438
+ disabled={disabled}
439
+ onChange={
440
+ ((value) => {
441
+ if (!multiple) {
442
+ setOpen(false);
443
+ }
444
+ onChange?.(value);
445
+ }) satisfies SelectInputProps<T, M>['onChange']
446
+ }
447
+ >
448
+ {({ disabled: uiDisabled, value }) => {
449
+ const placeholderShown =
450
+ multiple && Array.isArray(value) ? value.length === 0 : value == null;
451
+ return (
452
+ <OptionsOverlay
453
+ placement="bottom-start"
454
+ open={open}
455
+ renderTrigger={({ ref, getInteractionProps }) => (
456
+ <SelectInputTriggerButtonPropsContext.Provider
457
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
458
+ value={{
459
+ ref: (node) => {
460
+ ref(node);
461
+ if (externalTriggerRef) {
462
+ // eslint-disable-next-line no-param-reassign
463
+ externalTriggerRef.current = node;
464
+ } else {
465
+ internalTriggerRef.current = node;
466
+ }
467
+ },
468
+ ...inputAttributes,
469
+ ...UNSAFE_triggerButtonProps,
470
+ id,
471
+ ...mergeProps(
472
+ {
473
+ onClick: () => {
474
+ setOpen((prev) => !prev);
475
+ },
476
+ onKeyDown: (event: React.KeyboardEvent) => {
477
+ if (
478
+ event.key === ' ' ||
479
+ event.key === 'Enter' ||
480
+ event.key === 'ArrowDown' ||
481
+ event.key === 'ArrowUp'
482
+ ) {
483
+ setOpen((prev) => !prev);
484
+ }
485
+ },
486
+ },
487
+ getInteractionProps(),
488
+ ),
489
+ }}
490
+ >
491
+ {renderTrigger({
492
+ content: !placeholderShown ? (
493
+ <SelectInputOptionContentWithinTriggerContext.Provider value>
494
+ {multiple && Array.isArray(value)
495
+ ? (value as readonly NonNullable<T>[])
496
+ .map((option) => renderValue(option, true))
497
+ .filter((node) => node != null)
498
+ .join(', ')
499
+ : renderValue(value as NonNullable<T>, true)}
500
+ </SelectInputOptionContentWithinTriggerContext.Provider>
501
+ ) : (
502
+ placeholder
503
+ ),
504
+ placeholderShown,
505
+ clear:
506
+ onClear != null
507
+ ? () => {
508
+ onClear();
509
+ (externalTriggerRef?.current ?? internalTriggerRef.current)?.focus({
510
+ preventScroll: true,
511
+ });
512
+ }
513
+ : undefined,
514
+ disabled: uiDisabled,
515
+ size,
516
+ className,
517
+ })}
518
+ </SelectInputTriggerButtonPropsContext.Provider>
519
+ )}
520
+ initialFocusRef={controllerRef}
521
+ size={filterable ? 'lg' : 'md'}
522
+ padding="none"
523
+ onClose={() => {
524
+ setOpen(false);
525
+ }}
526
+ onCloseEnd={() => {
527
+ setFilterQuery('');
528
+ }}
529
+ >
530
+ <SelectInputOptions
531
+ id={id ? `${id}Search` : undefined}
532
+ parentId={parentId}
533
+ items={items}
534
+ compareValues={compareValues}
535
+ renderValue={renderValue}
536
+ renderFooter={renderFooter}
537
+ filterable={filterable}
538
+ filterPlaceholder={filterPlaceholder}
539
+ sortFilteredOptions={sortFilteredOptions}
540
+ searchInputRef={searchInputRef}
541
+ listboxRef={listboxRef}
542
+ filterQuery={deferredFilterQuery}
543
+ autocomplete={autocomplete}
544
+ name={name}
545
+ onFilterChange={setFilterQuery}
546
+ onAutocompleteSelect={(matchedValue) => {
547
+ onChange?.(matchedValue as M extends true ? T[] : T);
548
+ if (!multiple) {
549
+ setOpen(false);
550
+ }
551
+ }}
552
+ {...getListBoxLabelProps()}
553
+ />
554
+ </OptionsOverlay>
555
+ );
556
+ }}
557
+ </ListboxBase>
558
+ );
559
+ }
560
+
561
+ const SelectInputTriggerButtonPropsContext = createContext<{
562
+ ref?: React.ForwardedRef<HTMLButtonElement | null>;
563
+ id?: string;
564
+ onClick?: (event: React.MouseEvent) => void;
565
+ onKeyDown?: (event: React.KeyboardEvent) => void;
566
+ [key: string]: unknown;
567
+ }>({});
568
+
569
+ type SelectInputTriggerButtonElementType = 'button' | React.ComponentType;
570
+
571
+ export type SelectInputTriggerButtonProps<
572
+ T extends SelectInputTriggerButtonElementType = 'button',
573
+ > = Merge<React.ComponentPropsWithoutRef<T>, { as?: T }>;
574
+
575
+ export function SelectInputTriggerButton<T extends SelectInputTriggerButtonElementType = 'button'>({
576
+ as = 'button' as T,
577
+ ...restProps
578
+ }: SelectInputTriggerButtonProps<T>) {
579
+ const { ref, onClick, onKeyDown, ...interactionProps } = useContext(
580
+ SelectInputTriggerButtonPropsContext,
581
+ );
582
+
583
+ return (
584
+ <ListboxButton
585
+ ref={ref}
586
+ as={PolymorphicWithOverrides}
587
+ role="combobox"
588
+ __overrides={{ as, ...interactionProps }}
589
+ {...mergeProps({ onClick, onKeyDown }, restProps)}
590
+ />
591
+ );
592
+ }
593
+
594
+ interface SelectInputOptionsContainerProps extends React.ComponentPropsWithRef<'div'> {
595
+ onAriaActiveDescendantChange: (value: React.AriaAttributes['aria-activedescendant']) => void;
596
+ }
597
+
598
+ const SelectInputOptionsContainer = forwardRef(function SelectInputOptionsContainer(
599
+ {
600
+ 'aria-orientation': ariaOrientation,
601
+ 'aria-activedescendant': ariaActiveDescendant,
602
+ role,
603
+ tabIndex,
604
+ onAriaActiveDescendantChange,
605
+ onKeyDown,
606
+ ...restProps
607
+ }: SelectInputOptionsContainerProps,
608
+ ref: React.ForwardedRef<HTMLDivElement | null>,
609
+ ) {
610
+ const handleAriaActiveDescendantChange = useEffectEvent(onAriaActiveDescendantChange);
611
+ useEffect(() => {
612
+ handleAriaActiveDescendantChange(ariaActiveDescendant);
613
+ }, [ariaActiveDescendant, handleAriaActiveDescendantChange]);
614
+
615
+ return (
616
+ <div
617
+ ref={ref}
618
+ role="none"
619
+ onKeyDown={(event) => {
620
+ // Prevent confirmation close without an active item
621
+ if (event.key === 'Enter' && ariaActiveDescendant == null) {
622
+ return;
623
+ }
624
+
625
+ // Required to make ListBox focusable
626
+ if (event.key === 'Tab') {
627
+ return;
628
+ }
629
+
630
+ // Prevent absorbing Escape early
631
+ if (event.key === 'Escape') {
632
+ onKeyDown?.({
633
+ ...event,
634
+ preventDefault: () => {},
635
+ stopPropagation: () => {},
636
+ });
637
+ return;
638
+ }
639
+
640
+ onKeyDown?.(event);
641
+ }}
642
+ {...restProps}
643
+ />
644
+ );
645
+ });
646
+
647
+ interface SelectInputOptionsProps<T = string> extends Pick<
648
+ SelectInputProps<T>,
649
+ | 'items'
650
+ | 'renderValue'
651
+ | 'renderFooter'
652
+ | 'filterable'
653
+ | 'filterPlaceholder'
654
+ | 'id'
655
+ | 'parentId'
656
+ | 'compareValues'
657
+ | 'sortFilteredOptions'
658
+ > {
659
+ searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
660
+ listboxRef: React.MutableRefObject<HTMLDivElement | null>;
661
+ filterQuery: string;
662
+ onFilterChange: (query: string) => void;
663
+ listBoxLabel?: string;
664
+ listBoxLabelledBy?: string;
665
+ autocomplete?: string;
666
+ name?: string;
667
+ onAutocompleteSelect?: (value: T) => void;
668
+ }
669
+
670
+ function SelectInputOptions<T = string>({
671
+ id,
672
+ parentId,
673
+ items,
674
+ compareValues: compareValuesProp,
675
+ renderValue = String,
676
+ renderFooter,
677
+ filterable = false,
678
+ filterPlaceholder,
679
+ sortFilteredOptions,
680
+ searchInputRef,
681
+ listboxRef,
682
+ filterQuery,
683
+ onFilterChange,
684
+ listBoxLabel,
685
+ listBoxLabelledBy,
686
+ autocomplete,
687
+ name,
688
+ onAutocompleteSelect,
689
+ }: SelectInputOptionsProps<T>) {
690
+ const intl = useIntl();
691
+ const virtualiserHandlerRef = useRef<VirtualizerHandle>(null);
692
+ const controllerRef = filterable ? searchInputRef : listboxRef;
693
+ const [initialRender, setInitialRender] = useState(true);
694
+
695
+ const needle = useMemo(() => {
696
+ if (filterable) {
697
+ return filterQuery ? searchableString(filterQuery) : null;
698
+ }
699
+ return undefined;
700
+ }, [filterQuery, filterable]);
701
+ useEffect(() => {
702
+ if (needle) {
703
+ // Ensure having an active option while filtering.
704
+ // Without `requestAnimationFrame` upon which React depends for scheduling
705
+ // updates, the active status would only show for a split second and then
706
+ // disappear inadvertently.
707
+ requestAnimationFrame(() => {
708
+ if (
709
+ controllerRef.current != null &&
710
+ !controllerRef.current.hasAttribute('aria-activedescendant')
711
+ ) {
712
+ // Activate first option via synthetic key press
713
+ controllerRef.current.dispatchEvent(
714
+ new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
715
+ );
716
+ }
717
+ });
718
+ }
719
+ }, [controllerRef, needle]);
720
+
721
+ const compareValues = useMemo(() => {
722
+ if (!compareValuesProp) {
723
+ return undefined;
724
+ }
725
+
726
+ if (typeof compareValuesProp === 'function') {
727
+ return (a: NonNullable<T>, b: NonNullable<T>) => compareValuesProp(a, b);
728
+ }
729
+
730
+ const key = compareValuesProp;
731
+ return (a: NonNullable<T>, b: NonNullable<T>) => {
732
+ if (typeof a === 'object' && a != null && typeof b === 'object' && b != null) {
733
+ return (a as Record<string, unknown>)[key] === (b as Record<string, unknown>)[key];
734
+ }
735
+ return a === b;
736
+ };
737
+ }, [compareValuesProp]);
738
+
739
+ const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] = useMemo(() => {
740
+ if (needle == null) {
741
+ return items;
742
+ }
743
+
744
+ const dedupedItems = dedupeSelectInputItems(items, compareValues);
745
+
746
+ if (sortFilteredOptions) {
747
+ // When sorting, filter out non-matching items completely to avoid ghost items
748
+ const filtered = dedupedItems.map((item) => {
749
+ if (item.type === 'option') {
750
+ return selectInputOptionItemIncludesNeedle(item, needle)
751
+ ? item
752
+ : { ...item, value: undefined };
753
+ }
754
+ if (item.type === 'group') {
755
+ return {
756
+ ...item,
757
+ options: item.options.map((option) =>
758
+ selectInputOptionItemIncludesNeedle(option, needle)
759
+ ? option
760
+ : { ...option, value: undefined },
761
+ ),
762
+ };
763
+ }
764
+ return item;
765
+ });
766
+
767
+ return sortSelectInputItems(filtered, sortFilteredOptions, filterQuery);
768
+ }
769
+
770
+ return filterSelectInputItems(dedupedItems, (item) =>
771
+ selectInputOptionItemIncludesNeedle(item, needle),
772
+ );
773
+ // eslint-disable-next-line react-hooks/exhaustive-deps
774
+ }, [needle, items, compareValues]);
775
+ const resultsEmpty = needle != null && filteredItems.length === 0;
776
+
777
+ const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION;
778
+
779
+ // Items shown once shall be kept mounted until the needle changes, otherwise
780
+ // the scroll position may jump around inadvertently. Pattern adopted from:
781
+ // https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
782
+ const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
783
+ useEffect(() => {
784
+ // Ensure the 'End' key works as intended by keeping the last item mounted
785
+ setMountedIndexes((prevMountedIndexes) => {
786
+ const indexes = new Set(prevMountedIndexes);
787
+ indexes.add(filteredItems.length - 1);
788
+ return [...indexes]; // Sorting is redundant by nature here
789
+ });
790
+ }, [
791
+ needle, // Needed as `filteredItems.length` may be equal between two updates
792
+ filteredItems.length,
793
+ ]);
794
+
795
+ const listboxContainerRef = useRef<HTMLDivElement>(null);
796
+ useEffect(() => {
797
+ if (listboxContainerRef.current != null) {
798
+ listboxContainerRef.current.style.setProperty(
799
+ '--initial-height',
800
+ `${listboxContainerRef.current.offsetHeight}px`,
801
+ );
802
+ }
803
+ }, []);
804
+
805
+ useEffect(() => {
806
+ setInitialRender(false);
807
+ }, []);
808
+
809
+ const showStatus = resultsEmpty;
810
+ const statusId = useId();
811
+ const listboxId = useId();
812
+
813
+ const getItemNode = (index: number) => {
814
+ const item = filteredItems[index];
815
+ return (
816
+ <SelectInputItemView key={index} item={item} renderValue={renderValue} needle={needle} />
817
+ );
818
+ };
819
+
820
+ const findMatchingItem = (autocompleteValue: string): T | null => {
821
+ const flatOptions = items
822
+ .flatMap((item) =>
823
+ item.type === 'group' ? item.options : item.type === 'option' ? [item] : [],
824
+ )
825
+ .filter(
826
+ (item): item is SelectInputOptionItem<NonNullable<T>> =>
827
+ item.type === 'option' && item.value != null,
828
+ );
829
+
830
+ const exactMatch = flatOptions.find(
831
+ (option) =>
832
+ String(option.value) === autocompleteValue ||
833
+ option.filterMatchers?.some((matcher) => matcher === autocompleteValue),
834
+ );
835
+
836
+ if (exactMatch) {
837
+ return exactMatch.value;
838
+ }
839
+
840
+ const fuzzyMatch = flatOptions.find((option) =>
841
+ option.filterMatchers?.some((matcher) =>
842
+ matcher.toLowerCase().includes(autocompleteValue.toLowerCase()),
843
+ ),
844
+ );
845
+
846
+ return fuzzyMatch ? fuzzyMatch.value : null;
847
+ };
848
+
849
+ return (
850
+ <ListboxOptions
851
+ modal
852
+ as={SelectInputOptionsContainer}
853
+ static
854
+ className="np-select-input-options-container"
855
+ onAriaActiveDescendantChange={(value: React.AriaAttributes['aria-activedescendant']) => {
856
+ if (controllerRef.current != null) {
857
+ if (!initialRender && value != null) {
858
+ controllerRef.current.setAttribute('aria-activedescendant', value);
859
+ } else {
860
+ controllerRef.current.removeAttribute('aria-activedescendant');
861
+ }
862
+ }
863
+ }}
864
+ >
865
+ {filterable ? (
866
+ <div className="np-select-input-query-container">
867
+ <SearchInput
868
+ ref={searchInputRef}
869
+ id={id}
870
+ name={name}
871
+ autoComplete={autocomplete}
872
+ role="combobox"
873
+ shape="rectangle"
874
+ placeholder={filterPlaceholder}
875
+ aria-label={filterPlaceholder}
876
+ defaultValue={filterQuery}
877
+ aria-autocomplete="list"
878
+ aria-expanded
879
+ aria-controls={listboxId}
880
+ aria-describedby={showStatus ? statusId : undefined}
881
+ onKeyDown={(event) => {
882
+ // Prevent interfering with the matcher of Headless UI
883
+ // https://mathiasbynens.be/notes/javascript-unicode#regex
884
+ if (/^.$/u.test(event.key)) {
885
+ event.stopPropagation();
886
+ }
887
+ }}
888
+ onChange={(event) => {
889
+ // Free up resources and ensure not to go out of bounds when the
890
+ // resulting item count is less than before
891
+ const inputValue = event.currentTarget.value;
892
+
893
+ // Free up resources and ensure not to go out of bounds
894
+ setMountedIndexes([]);
895
+ onFilterChange(inputValue);
896
+ }}
897
+ onInput={(event) => {
898
+ const inputValue = event.currentTarget.value;
899
+ const inputElement = event.currentTarget;
900
+
901
+ if (autocomplete && onAutocompleteSelect && inputValue) {
902
+ setTimeout(() => {
903
+ if (inputElement.value === inputValue && inputValue.length > 2) {
904
+ const matchedValue = findMatchingItem(inputValue);
905
+ if (matchedValue !== null) {
906
+ onAutocompleteSelect(matchedValue);
907
+ }
908
+ }
909
+ }, 50);
910
+ }
911
+ }}
912
+ />
913
+ </div>
914
+ ) : null}
915
+
916
+ <section
917
+ ref={listboxContainerRef}
918
+ tabIndex={-1}
919
+ className={clsx(
920
+ 'np-select-input-listbox-container',
921
+ virtualized && 'np-select-input-listbox-container--virtualized',
922
+ needle == null && // Groups aren't shown when filtering
923
+ items.some((item) => item.type === 'group') &&
924
+ 'np-select-input-listbox-container--has-group',
925
+ )}
926
+ data-wds-parent={parentId ?? undefined}
927
+ >
928
+ {resultsEmpty ? (
929
+ <div id={statusId} className="np-select-input-options-status">
930
+ <CrossCircle size={16} className="np-select-input-options-status-icon" />
931
+ {intl.formatMessage(messages.noResultsFound)}
932
+ </div>
933
+ ) : null}
934
+
935
+ <div
936
+ ref={listboxRef}
937
+ id={listboxId}
938
+ role="listbox"
939
+ aria-orientation="vertical"
940
+ aria-label={listBoxLabel}
941
+ aria-labelledby={listBoxLabelledBy}
942
+ tabIndex={0}
943
+ className="np-select-input-listbox"
944
+ >
945
+ {!virtualized ? (
946
+ filteredItems.map((_, index) => getItemNode(index))
947
+ ) : (
948
+ <Virtualizer
949
+ ref={virtualiserHandlerRef}
950
+ data={filteredItems}
951
+ keepMounted={mountedIndexes}
952
+ scrollRef={listboxRef} // `VList` doesn't expose this
953
+ onScroll={async () => {
954
+ if (!virtualiserHandlerRef.current) return;
955
+
956
+ const startIndex = virtualiserHandlerRef.current.findItemIndex(
957
+ virtualiserHandlerRef.current.scrollOffset,
958
+ );
959
+ const endIndex = virtualiserHandlerRef.current.findItemIndex(
960
+ virtualiserHandlerRef.current.scrollOffset +
961
+ virtualiserHandlerRef.current.viewportSize,
962
+ );
963
+
964
+ setMountedIndexes((prevMountedIndexes) => {
965
+ const indexes = new Set(prevMountedIndexes);
966
+
967
+ for (let index = startIndex; index <= endIndex; index += 1) {
968
+ indexes.add(index);
969
+ }
970
+
971
+ return [...indexes].sort((a, b) => a - b);
972
+ });
973
+ }}
974
+ >
975
+ {(item, index) => (
976
+ // The position of each item can't be inferred by browsers when
977
+ // virtualizing, as some of the items may not be in the DOM
978
+ <SelectInputItemsCountContext.Provider value={filteredItems.length}>
979
+ <SelectInputItemPositionContext.Provider value={index + 1}>
980
+ {getItemNode(index)}
981
+ </SelectInputItemPositionContext.Provider>
982
+ </SelectInputItemsCountContext.Provider>
983
+ )}
984
+ </Virtualizer>
985
+ )}
986
+ </div>
987
+
988
+ {renderFooter != null ? (
989
+ <footer className="np-select-input-footer">
990
+ <div
991
+ role="none"
992
+ onKeyDown={(event) => {
993
+ // Prevent interfering with Headless UI
994
+ if (event.key !== 'Escape') {
995
+ event.stopPropagation();
996
+ }
997
+ }}
998
+ >
999
+ {renderFooter({
1000
+ resultsEmpty,
1001
+ queryNormalized: needle,
1002
+ })}
1003
+ </div>
1004
+ </footer>
1005
+ ) : null}
1006
+ </section>
1007
+ </ListboxOptions>
1008
+ );
1009
+ }
1010
+
1011
+ interface SelectInputItemViewProps<
1012
+ T = string,
1013
+ I extends SelectInputItem<T | undefined> = SelectInputItem<T | undefined>,
1014
+ > extends Required<Pick<SelectInputProps<T>, 'renderValue'>> {
1015
+ item: I;
1016
+ needle: string | null | undefined;
1017
+ }
1018
+
1019
+ function SelectInputItemView<T = string>({
1020
+ item,
1021
+ renderValue,
1022
+ needle,
1023
+ }: SelectInputItemViewProps<T>) {
1024
+ switch (item.type) {
1025
+ case 'option': {
1026
+ if (
1027
+ item.value != null &&
1028
+ (needle == null || selectInputOptionItemIncludesNeedle(item, needle))
1029
+ ) {
1030
+ return (
1031
+ <SelectInputOption value={item.value} disabled={item.disabled}>
1032
+ {renderValue(item.value, false)}
1033
+ </SelectInputOption>
1034
+ );
1035
+ }
1036
+ break;
1037
+ }
1038
+ case 'group': {
1039
+ return <SelectInputGroupItemView item={item} renderValue={renderValue} needle={needle} />;
1040
+ }
1041
+ case 'separator': {
1042
+ if (needle == null) {
1043
+ return <hr className="np-select-input-separator-item" />;
1044
+ }
1045
+ break;
1046
+ }
1047
+ }
1048
+ return null;
1049
+ }
1050
+
1051
+ interface SelectInputGroupItemViewProps<T = string> extends SelectInputItemViewProps<
1052
+ T,
1053
+ SelectInputGroupItem<T | undefined>
1054
+ > {}
1055
+
1056
+ function SelectInputGroupItemView<T = string>({
1057
+ item,
1058
+ renderValue,
1059
+ needle,
1060
+ }: SelectInputGroupItemViewProps<T>) {
1061
+ const headerId = useId();
1062
+
1063
+ const header = (
1064
+ <Header
1065
+ as="header"
1066
+ role="none"
1067
+ id={headerId}
1068
+ title={item.label}
1069
+ // @ts-expect-error when we migrate ActionButton to new Button this should be sorted
1070
+ action={
1071
+ item.action && {
1072
+ text: item.action.label,
1073
+ onClick: item.action.onClick,
1074
+ }
1075
+ }
1076
+ className="np-select-input-group-item-header p-x-1"
1077
+ />
1078
+ );
1079
+
1080
+ return (
1081
+ // An empty container may be rendered when no options match `needle`
1082
+ // However, pre-filtering would result in worse performance overall
1083
+ <Section
1084
+ as="section"
1085
+ role="group"
1086
+ aria-labelledby={headerId}
1087
+ className={clsx('m-y-0', needle === null && 'np-select-input-group-item--without-needle')}
1088
+ >
1089
+ {needle == null ? header : null}
1090
+ {item.options.map((option, index) => (
1091
+ <SelectInputItemView
1092
+ // eslint-disable-next-line react/no-array-index-key
1093
+ key={index}
1094
+ item={option}
1095
+ renderValue={renderValue}
1096
+ needle={needle}
1097
+ />
1098
+ ))}
1099
+ </Section>
1100
+ );
1101
+ }
1102
+
1103
+ const SelectInputItemsCountContext = createContext<number | undefined>(undefined);
1104
+ const SelectInputItemPositionContext = createContext<number | undefined>(undefined);
1105
+
1106
+ interface SelectInputOptionProps<T = string> {
1107
+ value: T;
1108
+ disabled?: boolean;
1109
+ children?: React.ReactNode;
1110
+ }
1111
+
1112
+ function SelectInputOption<T = string>({ value, disabled, children }: SelectInputOptionProps<T>) {
1113
+ const itemsCount = useContext(SelectInputItemsCountContext);
1114
+ const itemPosition = useContext(SelectInputItemPositionContext);
1115
+ return (
1116
+ <ListboxOption
1117
+ as="div"
1118
+ value={value}
1119
+ aria-setsize={itemsCount}
1120
+ aria-posinset={itemPosition}
1121
+ disabled={disabled}
1122
+ className={({ active, disabled: uiDisabled }) =>
1123
+ clsx(
1124
+ 'np-select-input-option-container np-text-body-large',
1125
+ active && 'np-select-input-option-container--active',
1126
+ uiDisabled && 'np-select-input-option-container--disabled',
1127
+ )
1128
+ }
1129
+ >
1130
+ {({ selected }) => (
1131
+ <>
1132
+ <div className="np-select-input-option">{children}</div>
1133
+ <Check
1134
+ size={16}
1135
+ className={clsx(
1136
+ 'np-select-input-option-check',
1137
+ !selected && 'np-select-input-option-check--not-selected',
1138
+ )}
1139
+ />
1140
+ </>
1141
+ )}
1142
+ </ListboxOption>
1143
+ );
1144
+ }
1145
+
1146
+ const SelectInputOptionContentWithinTriggerContext = createContext(false);
1147
+
1148
+ export interface SelectInputOptionContentProps {
1149
+ title: string;
1150
+ note?: string;
1151
+ description?: string;
1152
+ icon?: React.ReactNode;
1153
+ }
1154
+
1155
+ export function SelectInputOptionContent({
1156
+ title,
1157
+ note,
1158
+ description,
1159
+ icon,
1160
+ }: SelectInputOptionContentProps) {
1161
+ const withinTrigger = useContext(SelectInputOptionContentWithinTriggerContext);
1162
+
1163
+ return (
1164
+ <div
1165
+ className={clsx(
1166
+ 'np-select-input-option-content-container',
1167
+ (note || description) && 'np-text-body-large',
1168
+ )}
1169
+ >
1170
+ {icon ? (
1171
+ <div
1172
+ className={clsx(
1173
+ 'np-select-input-option-content-icon',
1174
+ !withinTrigger && 'np-select-input-option-content-icon--not-within-trigger',
1175
+ )}
1176
+ >
1177
+ {icon}
1178
+ </div>
1179
+ ) : null}
1180
+
1181
+ <div className="np-select-input-option-content-text">
1182
+ <div
1183
+ className={clsx(
1184
+ 'np-select-input-option-content-text-line-1',
1185
+ withinTrigger && 'np-select-input-option-content-text-within-trigger',
1186
+ )}
1187
+ >
1188
+ <div className="d-inline">{title}</div>
1189
+ {note ? (
1190
+ <span className="np-select-input-option-content-text-secondary np-text-body-default">
1191
+ {note}
1192
+ </span>
1193
+ ) : null}
1194
+ </div>
1195
+
1196
+ {description ? (
1197
+ <div
1198
+ className={clsx(
1199
+ 'np-select-input-option-content-text-secondary np-text-body-default',
1200
+ withinTrigger && 'np-select-input-option-content-text-within-trigger',
1201
+ )}
1202
+ >
1203
+ {description}
1204
+ </div>
1205
+ ) : null}
1206
+ </div>
1207
+ </div>
1208
+ );
1209
+ }