@altruist03/ui 0.1.0 → 0.2.0

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 (207) hide show
  1. package/assets/images/noData.webp +0 -0
  2. package/dist/index.js +15237 -650
  3. package/dist/index.js.map +1 -1
  4. package/dist/noData-XPRHKMFR.webp +0 -0
  5. package/package.json +21 -7
  6. package/src/ThemeProvider/ThemeContext.tsx +75 -0
  7. package/src/ThemeProvider/index.ts +7 -0
  8. package/src/assets/images/noData.webp +0 -0
  9. package/src/assets.d.ts +10 -0
  10. package/src/components/Accordion/Accordion.stories.tsx +64 -0
  11. package/src/components/Accordion/Accordion.tsx +219 -0
  12. package/src/components/Accordion/Accordion.types.ts +16 -0
  13. package/src/components/Avatar/Avatar.stories.tsx +304 -0
  14. package/src/components/Avatar/Avatar.tsx +211 -0
  15. package/src/components/Avatar/Avatar.types.ts +33 -0
  16. package/src/components/Badge/Badge.stories.tsx +143 -0
  17. package/src/components/Badge/Badge.tsx +138 -0
  18. package/src/components/Badge/Badge.types.ts +24 -0
  19. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +114 -0
  20. package/src/components/Breadcrumb/Breadcrumb.tsx +120 -0
  21. package/src/components/Breadcrumb/Breadcrumb.types.ts +22 -0
  22. package/src/components/Button/Button.stories.tsx +457 -0
  23. package/src/components/Button/Button.tsx +202 -0
  24. package/src/components/Button/Button.types.ts +18 -0
  25. package/src/components/Checkbox/Checkbox.stories.tsx +254 -0
  26. package/src/components/Checkbox/Checkbox.tsx +352 -0
  27. package/src/components/Checkbox/Checkbox.types.ts +123 -0
  28. package/src/components/CloseButton/CloseButton.stories.tsx +100 -0
  29. package/src/components/CloseButton/CloseButton.tsx +117 -0
  30. package/src/components/CloseButton/CloseButton.types.ts +15 -0
  31. package/src/components/CollapsibleSearch/CollapsibleSearch.tsx +120 -0
  32. package/src/components/DataTable/DataTable.stories.tsx +233 -0
  33. package/src/components/DataTable/DataTable.tsx +968 -0
  34. package/src/components/DataTable/DataTable.types.ts +49 -0
  35. package/src/components/DatePicker/DatePicker.stories.tsx +341 -0
  36. package/src/components/DatePicker/DatePicker.tsx +669 -0
  37. package/src/components/DatePicker/DatePicker.types.ts +105 -0
  38. package/src/components/DatePicker/DateTimePicker.tsx +35 -0
  39. package/src/components/DateRangePicker/DateRangePicker.tsx +179 -0
  40. package/src/components/DateRangePicker/DateRangePicker.types.ts +34 -0
  41. package/src/components/Drawer/Drawer.stories.tsx +243 -0
  42. package/src/components/Drawer/Drawer.tsx +196 -0
  43. package/src/components/Drawer/Drawer.types.ts +60 -0
  44. package/src/components/FileUpload/FileUpload.stories.tsx +162 -0
  45. package/src/components/FileUpload/FileUpload.tsx +216 -0
  46. package/src/components/FileUpload/FileUpload.types.ts +80 -0
  47. package/src/components/FilterPopover/FilterPopover.stories.tsx +173 -0
  48. package/src/components/FilterPopover/FilterPopover.tsx +310 -0
  49. package/src/components/FilterPopover/FilterPopover.types.ts +26 -0
  50. package/src/components/ImageViewer/ImageViewerToolbar.jsx +81 -0
  51. package/src/components/ImageViewer/useImagePanRotate.js +102 -0
  52. package/src/components/IpWhitelistField/IpWhitelistField.stories.tsx +138 -0
  53. package/src/components/IpWhitelistField/IpWhitelistField.tsx +164 -0
  54. package/src/components/IpWhitelistField/IpWhitelistField.types.ts +42 -0
  55. package/src/components/Loader/Loader.stories.tsx +95 -0
  56. package/src/components/Loader/Loader.tsx +132 -0
  57. package/src/components/Loader/Loader.types.ts +6 -0
  58. package/src/components/Menu/Menu.tsx +143 -0
  59. package/src/components/Menu/Menu.types.ts +28 -0
  60. package/src/components/MenuButton/MenuButton.stories.tsx +140 -0
  61. package/src/components/MenuButton/MenuButton.tsx +45 -0
  62. package/src/components/MenuButton/MenuButton.types.ts +21 -0
  63. package/src/components/Modal/Modal.stories.tsx +266 -0
  64. package/src/components/Modal/Modal.tsx +399 -0
  65. package/src/components/Modal/index.ts +2 -0
  66. package/src/components/Modal/types.ts +126 -0
  67. package/src/components/Pagination/Pagination.stories.tsx +167 -0
  68. package/src/components/Pagination/Pagination.tsx +266 -0
  69. package/src/components/Pagination/Pagination.types.ts +20 -0
  70. package/src/components/PeriodSelector/PeriodSelector.stories.tsx +50 -0
  71. package/src/components/PeriodSelector/PeriodSelector.tsx +47 -0
  72. package/src/components/PeriodSelector/PeriodSelector.types.ts +10 -0
  73. package/src/components/PhoneField/PhoneField.stories.tsx +141 -0
  74. package/src/components/PhoneField/PhoneField.tsx +284 -0
  75. package/src/components/PhoneField/PhoneField.types.ts +89 -0
  76. package/src/components/PhoneField/countryCodes.ts +236 -0
  77. package/src/components/Progress/Progress.stories.tsx +83 -0
  78. package/src/components/Progress/Progress.tsx +91 -0
  79. package/src/components/Progress/Progress.types.ts +22 -0
  80. package/src/components/RadioButton/RadioButton.stories.tsx +332 -0
  81. package/src/components/RadioButton/RadioButton.tsx +427 -0
  82. package/src/components/RadioButton/RadioButton.types.ts +117 -0
  83. package/src/components/ReportCard/ReportCard.tsx +183 -0
  84. package/src/components/ReportCard/ReportCard.types.ts +17 -0
  85. package/src/components/SectionIcon/SectionIcon.tsx +87 -0
  86. package/src/components/SectionIcon/SectionIcon.types.ts +23 -0
  87. package/src/components/SegmentedInputField/SegmentedInputField.stories.tsx +165 -0
  88. package/src/components/SegmentedInputField/SegmentedInputField.tsx +174 -0
  89. package/src/components/SegmentedInputField/SegmentedInputField.types.ts +51 -0
  90. package/src/components/SelectScroll/SelectScroll.stories.tsx +336 -0
  91. package/src/components/SelectScroll/SelectScroll.tsx +1119 -0
  92. package/src/components/SelectScroll/SelectScroll.types.ts +60 -0
  93. package/src/components/Selector/Selector.stories.tsx +382 -0
  94. package/src/components/Selector/Selector.tsx +1310 -0
  95. package/src/components/Selector/Selector.types.ts +71 -0
  96. package/src/components/Skeleton/Skeleton.stories.tsx +133 -0
  97. package/src/components/Skeleton/Skeleton.tsx +20 -0
  98. package/src/components/Skeleton/Skeleton.types.ts +3 -0
  99. package/src/components/Spinner/Spinner.stories.tsx +84 -0
  100. package/src/components/Spinner/Spinner.tsx +79 -0
  101. package/src/components/Spinner/Spinner.types.ts +8 -0
  102. package/src/components/StatCard/StatCard.stories.tsx +125 -0
  103. package/src/components/StatCard/StatCard.tsx +167 -0
  104. package/src/components/StatCard/StatCard.types.ts +20 -0
  105. package/src/components/StatusBadge/StatusBadge.stories.tsx +179 -0
  106. package/src/components/StatusBadge/StatusBadge.tsx +74 -0
  107. package/src/components/StatusBadge/StatusBadge.types.ts +23 -0
  108. package/src/components/Stepper/Stepper.stories.tsx +85 -0
  109. package/src/components/Stepper/Stepper.tsx +138 -0
  110. package/src/components/Stepper/Stepper.types.ts +16 -0
  111. package/src/components/SubTabs/SubTabs.stories.tsx +128 -0
  112. package/src/components/SubTabs/SubTabs.tsx +279 -0
  113. package/src/components/SubTabs/SubTabs.types.ts +44 -0
  114. package/src/components/SummaryCard/SummaryCard.stories.tsx +100 -0
  115. package/src/components/SummaryCard/SummaryCard.tsx +141 -0
  116. package/src/components/SummaryCard/SummaryCard.types.ts +22 -0
  117. package/src/components/Switch/Switch.stories.tsx +59 -0
  118. package/src/components/Switch/Switch.tsx +217 -0
  119. package/src/components/Switch/Switch.types.ts +22 -0
  120. package/src/components/Tabs/Tabs.stories.tsx +178 -0
  121. package/src/components/Tabs/Tabs.tsx +197 -0
  122. package/src/components/Tabs/Tabs.types.ts +34 -0
  123. package/src/components/TextField/TextField.stories.tsx +249 -0
  124. package/src/components/TextField/TextField.tsx +462 -0
  125. package/src/components/TextField/TextField.types.ts +141 -0
  126. package/src/components/Toast/Toast.stories.tsx +237 -0
  127. package/src/components/Toast/Toast.tsx +246 -0
  128. package/src/components/Toast/Toast.types.ts +31 -0
  129. package/src/components/ToggleButton/ToggleButton.stories.tsx +105 -0
  130. package/src/components/ToggleButton/ToggleButton.tsx +267 -0
  131. package/src/components/ToggleButton/ToggleButton.types.ts +19 -0
  132. package/src/components/UploadDropzone/UploadDropzone.jsx +78 -0
  133. package/src/components/UploadDropzone2/UploadDropzone.stories.tsx +94 -0
  134. package/src/components/UploadDropzone2/UploadDropzone.tsx +222 -0
  135. package/src/components/UploadDropzone2/UploadDropzone.types.ts +54 -0
  136. package/src/components/ViewEditToggle/ViewEditToggle.stories.tsx +40 -0
  137. package/src/components/ViewEditToggle/ViewEditToggle.tsx +239 -0
  138. package/src/components/ViewEditToggle/ViewEditToggle.types.ts +27 -0
  139. package/src/components/index.ts +157 -0
  140. package/src/icons/ReportDollarIcon.tsx +71 -0
  141. package/src/icons/index.ts +27 -0
  142. package/src/index.ts +214 -0
  143. package/src/storybook/preview.tsx +64 -0
  144. package/src/theme/index.ts +4 -0
  145. package/src/theme/types.d.ts +1 -0
  146. package/src/themes/cpc.ts +4 -0
  147. package/src/themes/firebird.ts +4 -0
  148. package/src/tokens/brands/cpc/themes/dark.json +82 -0
  149. package/src/tokens/brands/cpc/themes/light.json +1 -0
  150. package/src/tokens/brands/default/brand.json +20 -0
  151. package/src/tokens/brands/firebird/themes/dark.json +28 -0
  152. package/src/tokens/brands/firebird/themes/light.json +20 -0
  153. package/src/tokens/button.ts +24 -0
  154. package/src/tokens/components/accordion.json +52 -0
  155. package/src/tokens/components/avatar.json +40 -0
  156. package/src/tokens/components/badge.json +41 -0
  157. package/src/tokens/components/breadcrumb.json +13 -0
  158. package/src/tokens/components/button.json +67 -0
  159. package/src/tokens/components/card.json +39 -0
  160. package/src/tokens/components/checkbox.json +64 -0
  161. package/src/tokens/components/closebutton.json +55 -0
  162. package/src/tokens/components/datatable.json +40 -0
  163. package/src/tokens/components/datepicker.json +99 -0
  164. package/src/tokens/components/drawer.json +23 -0
  165. package/src/tokens/components/field.json +29 -0
  166. package/src/tokens/components/fileupload.json +57 -0
  167. package/src/tokens/components/filterpopover.json +29 -0
  168. package/src/tokens/components/input.json +31 -0
  169. package/src/tokens/components/ipwhitelistfield.json +16 -0
  170. package/src/tokens/components/loader.json +15 -0
  171. package/src/tokens/components/menu.json +21 -0
  172. package/src/tokens/components/modal.json +25 -0
  173. package/src/tokens/components/pagination.json +45 -0
  174. package/src/tokens/components/phonefield.json +41 -0
  175. package/src/tokens/components/progress.json +12 -0
  176. package/src/tokens/components/radio.json +87 -0
  177. package/src/tokens/components/reportcard.json +17 -0
  178. package/src/tokens/components/selector.json +87 -0
  179. package/src/tokens/components/selectscroll.json +65 -0
  180. package/src/tokens/components/skeleton.json +6 -0
  181. package/src/tokens/components/stepper.json +38 -0
  182. package/src/tokens/components/subtabs.json +45 -0
  183. package/src/tokens/components/switch.json +55 -0
  184. package/src/tokens/components/tabs.json +38 -0
  185. package/src/tokens/components/textfield.json +59 -0
  186. package/src/tokens/components/togglebutton.json +48 -0
  187. package/src/tokens/components/uploaddropzone.json +61 -0
  188. package/src/tokens/drawer.ts +37 -0
  189. package/src/tokens/foundation/breakpoints.json +10 -0
  190. package/src/tokens/foundation/colors.json +122 -0
  191. package/src/tokens/foundation/opacity.json +16 -0
  192. package/src/tokens/foundation/radius.json +14 -0
  193. package/src/tokens/foundation/size.json +38 -0
  194. package/src/tokens/foundation/spacing.json +19 -0
  195. package/src/tokens/foundation/typography.json +43 -0
  196. package/src/tokens/index.ts +3665 -0
  197. package/src/tokens/modal.ts +42 -0
  198. package/src/tokens/semantic/color.json +148 -0
  199. package/src/tokens/semantic/field.json +28 -0
  200. package/src/tokens/semantic/opacity.json +5 -0
  201. package/src/tokens/semantic/size.json +722 -0
  202. package/src/tokens/semantic/spacing.json +27 -0
  203. package/src/tokens/semantic/typography.json +87 -0
  204. package/src/utils/scrollLock.ts +44 -0
  205. package/dist/index.css +0 -514
  206. package/dist/index.css.map +0 -1
  207. package/dist/index.d.ts +0 -289
@@ -0,0 +1,1310 @@
1
+ import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { getSelectorTokensFromResolved } from '../../tokens';
4
+ import { useTokens } from '../../ThemeProvider/ThemeContext';
5
+ import type { SelectorOption, SelectorProps } from './Selector.types';
6
+ import { Badge } from '../Badge/Badge';
7
+ import { Checkbox } from '../Checkbox/Checkbox';
8
+ import { Skeleton } from '../Skeleton/Skeleton';
9
+
10
+ const LIST_MAX_HEIGHT = 300;
11
+ const ESTIMATED_OPTION_ROW_HEIGHT = 40;
12
+ const ESTIMATED_GROUP_HEADING_HEIGHT = 32;
13
+ const ESTIMATED_EMPTY_STATE_HEIGHT = 48;
14
+
15
+ export type { SelectorOption, SelectorProps };
16
+
17
+ /** Minimum gap between input and listbox; also used if spacing token is missing. */
18
+ const LISTBOX_GAP_FALLBACK_PX = 8;
19
+ /** Keep the listbox inside the viewport with this margin from the nearest viewport edge. */
20
+ const VIEWPORT_EDGE_MARGIN_PX = 8;
21
+
22
+ /** Scroll selected option inside the list only — `scrollIntoView` scrolls the window and grows page scroll. */
23
+ function scrollListboxItemIntoView(list: HTMLUListElement, item: HTMLElement) {
24
+ const pad = 4;
25
+ const listRect = list.getBoundingClientRect();
26
+ const itemRect = item.getBoundingClientRect();
27
+ const itemTop = list.scrollTop + (itemRect.top - listRect.top);
28
+ const itemBottom = itemTop + itemRect.height;
29
+ const viewTop = list.scrollTop;
30
+ const viewBottom = viewTop + list.clientHeight;
31
+ if (itemTop < viewTop + pad) {
32
+ list.scrollTop = Math.max(0, itemTop - pad);
33
+ } else if (itemBottom > viewBottom - pad) {
34
+ list.scrollTop = Math.max(0, itemBottom - list.clientHeight + pad);
35
+ }
36
+ }
37
+
38
+ function getOptionLabelDefault(option: SelectorOption): string {
39
+ if (typeof option === 'string') return option;
40
+ return (option as { label?: string }).label ?? String(option);
41
+ }
42
+
43
+ function getOptionKey<T extends SelectorOption>(
44
+ option: T,
45
+ index: number,
46
+ getOptionLabel: (o: T) => string
47
+ ): string {
48
+ if (typeof option === 'object' && option !== null && 'id' in option) {
49
+ return String((option as { id?: string }).id);
50
+ }
51
+ return `${getOptionLabel(option)}-${index}`;
52
+ }
53
+
54
+ // ----- Option row (li): shows checkbox when multiple -----
55
+ interface OptionItemProps<T extends SelectorOption> {
56
+ option: T;
57
+ optionLabel: string;
58
+ disabled: boolean;
59
+ index: number;
60
+ selected: boolean;
61
+ multiple: boolean;
62
+ onSelect: (option: T) => void;
63
+ renderOption?: (option: T, state: { index: number }) => React.ReactNode;
64
+ selectorTokens: ReturnType<typeof getSelectorTokensFromResolved>;
65
+ showFlag?: boolean;
66
+ }
67
+
68
+ function getOptionIsoCode(option: SelectorOption): string | null {
69
+ if (typeof option !== 'object' || option == null) return null;
70
+ const iso = (option as { iso?: unknown }).iso;
71
+ return typeof iso === 'string' && iso.trim() ? iso.toLowerCase() : null;
72
+ }
73
+
74
+ function OptionItemInner<T extends SelectorOption>({
75
+ option,
76
+ optionLabel,
77
+ disabled,
78
+ index,
79
+ selected,
80
+ multiple,
81
+ onSelect,
82
+ renderOption,
83
+ selectorTokens: selTokens,
84
+ showFlag = false,
85
+ }: OptionItemProps<T>) {
86
+ const handleMouseDown = useCallback(
87
+ (e: React.MouseEvent) => {
88
+ e.preventDefault();
89
+ if (!disabled) onSelect(option);
90
+ },
91
+ [option, onSelect, disabled]
92
+ );
93
+
94
+ const handleMouseEnter = useCallback(
95
+ (e: React.MouseEvent<HTMLLIElement>) => {
96
+ if (!disabled) e.currentTarget.style.backgroundColor = selTokens.optionHoverBackground;
97
+ },
98
+ [disabled, selTokens.optionHoverBackground]
99
+ );
100
+
101
+ const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLLIElement>) => {
102
+ e.currentTarget.style.backgroundColor = 'transparent';
103
+ }, []);
104
+
105
+ const itemStyle = useMemo(
106
+ () => ({
107
+ padding: selTokens.optionPadding,
108
+ cursor: disabled ? 'not-allowed' : 'pointer',
109
+ opacity: disabled ? 0.6 : 1,
110
+ backgroundColor: 'transparent',
111
+ fontSize: selTokens.optionFontSize,
112
+ color: selTokens.optionColor,
113
+ listStyle: 'none' as const,
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ justifyContent: 'space-between',
117
+ }),
118
+ [disabled, selTokens.optionPadding, selTokens.optionFontSize, selTokens.optionColor]
119
+ );
120
+
121
+ return (
122
+ <li
123
+ role="option"
124
+ aria-selected={selected}
125
+ aria-disabled={disabled}
126
+ onMouseDown={handleMouseDown}
127
+ onMouseEnter={handleMouseEnter}
128
+ onMouseLeave={handleMouseLeave}
129
+ style={itemStyle}
130
+ >
131
+ <span style={{ display: 'flex', alignItems: 'center', minWidth: 0 }}>
132
+ {showFlag && getOptionIsoCode(option) && (
133
+ <img
134
+ loading="lazy"
135
+ width="24px"
136
+ style={{ marginRight: 8, flexShrink: 0 }}
137
+ srcSet={`https://flagcdn.com/w40/${getOptionIsoCode(option)}.png 2x`}
138
+ src={`https://flagcdn.com/w20/${getOptionIsoCode(option)}.png`}
139
+ alt={`${optionLabel} flag`}
140
+ />
141
+ )}
142
+ <span>{renderOption ? renderOption(option, { index }) : optionLabel}</span>
143
+ </span>
144
+ {multiple ?
145
+ <div style={{ marginLeft: 8, flexShrink: 0, pointerEvents: 'none' }}>
146
+ <Checkbox
147
+ label=""
148
+ checked={selected}
149
+ disabled={disabled}
150
+ id={`selector-checkbox-${index}`}
151
+ />
152
+ </div>
153
+ : selected ?
154
+ <div aria-hidden="true" style={{ marginLeft: 8, flexShrink: 0 }}>
155
+ {/* <FirebirdIcon name="dubble_check" size={16} /> */}
156
+ </div>
157
+ : null}
158
+ </li>
159
+ );
160
+ }
161
+
162
+ const OptionItem = memo(OptionItemInner) as typeof OptionItemInner;
163
+
164
+ function getListboxStyle(
165
+ tokens: ReturnType<typeof getSelectorTokensFromResolved>
166
+ ): React.CSSProperties {
167
+ return {
168
+ position: 'absolute',
169
+ top: 0,
170
+ left: 0,
171
+ right: 0,
172
+ background: tokens.listboxBackground,
173
+ border: tokens.listboxBorder,
174
+ borderRadius: tokens.listboxBorderRadius,
175
+ marginTop: 0,
176
+ padding: 0,
177
+ listStyle: 'none',
178
+ maxHeight: LIST_MAX_HEIGHT,
179
+ overflowY: 'auto',
180
+ // Prevent wheel/touch scroll from chaining to the page when reaching list edges,
181
+ // which would otherwise move the anchor input and force a position recompute.
182
+ overscrollBehavior: 'contain',
183
+ boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
184
+ zIndex: 1400,
185
+ };
186
+ }
187
+
188
+ const getGroupHeadingStyle = (
189
+ tokens: ReturnType<typeof getSelectorTokensFromResolved>
190
+ ): React.CSSProperties => ({
191
+ padding: tokens.optionPadding,
192
+ fontSize: tokens.optionFontSize,
193
+ fontWeight: 600,
194
+ color: tokens.optionColor,
195
+ background: tokens.listboxGroupHeadingBackground,
196
+ position: 'sticky',
197
+ top: 0,
198
+ zIndex: 1,
199
+ borderBottom: tokens.listboxGroupHeadingBorder,
200
+ });
201
+
202
+ const defaultGroupListStyle: React.CSSProperties = {
203
+ listStyle: 'none',
204
+ margin: 0,
205
+ padding: 0,
206
+ };
207
+
208
+ function SelectorInner<T extends SelectorOption>(props: SelectorProps<T>) {
209
+ const {
210
+ multiple = false,
211
+ options = [],
212
+ value,
213
+ onChange,
214
+ label,
215
+ placeholder = 'Select...',
216
+ required = false,
217
+ error,
218
+ helperText,
219
+ disabled = false,
220
+ size = 'md',
221
+ color = 'grey',
222
+ noOptionsText = 'No options',
223
+ loading = false,
224
+ loadingText = 'Loading…',
225
+ isOptionDisabled,
226
+ getOptionLabel = getOptionLabelDefault as (option: T) => string,
227
+ renderOption,
228
+ groupBy,
229
+ renderGroup,
230
+ id,
231
+ limitTags = 1,
232
+ clearable = true,
233
+ filterOptions: filterOptionsProp,
234
+ onListboxScroll,
235
+ onOpenChange,
236
+ open,
237
+ skeletonLoading = false,
238
+ onKeyDown,
239
+ onInput,
240
+ showFlag = false,
241
+ autoComplete = 'off',
242
+ listboxMinWidth,
243
+ isOptionEqualToValue,
244
+ } = props;
245
+
246
+ const valueArray = useMemo(
247
+ () =>
248
+ multiple ?
249
+ Array.isArray(value) ?
250
+ value
251
+ : []
252
+ : value != null ? [value as T]
253
+ : [],
254
+ [multiple, value]
255
+ );
256
+ const hasValueSingle = !multiple && value != null;
257
+ const hasValuesMulti = multiple && valueArray.length > 0;
258
+
259
+ const [inputValue, setInputValue] = useState('');
260
+ const [isTyping, setIsTyping] = useState(false);
261
+ const [isOpen, setIsOpen] = useState(false);
262
+ const [isHovering, setIsHovering] = useState(false);
263
+ const [isFocused, setIsFocused] = useState(false);
264
+ const [listboxPosition, setListboxPosition] = useState<{
265
+ top: number;
266
+ left: number;
267
+ width: number;
268
+ placement: 'up' | 'down';
269
+ maxHeight: number;
270
+ } | null>(null);
271
+ const containerRef = useRef<HTMLDivElement>(null);
272
+ /** Bounds of the combobox/input only (excludes label/helper); used so listbox gaps match the field edges. */
273
+ const inputAnchorRef = useRef<HTMLDivElement>(null);
274
+ const listboxRef = useRef<HTMLUListElement>(null);
275
+ const prevIsOpenForCallbackRef = useRef(isOpen);
276
+ const didSelectRef = useRef(false);
277
+ const multiWrapperRef = useRef<HTMLDivElement>(null);
278
+ const multiInputRef = useRef<HTMLInputElement>(null);
279
+ const mouseDownOnFieldRef = useRef(false);
280
+ const listboxPositionRafRef = useRef<number | null>(null);
281
+
282
+ const isEqual = useCallback(
283
+ (a: T, b: T) => (isOptionEqualToValue ? isOptionEqualToValue(a, b) : a === b),
284
+ [isOptionEqualToValue]
285
+ );
286
+
287
+ const valueSet = useMemo(() => new Set(valueArray), [valueArray]);
288
+
289
+ const isSelected = useCallback(
290
+ (option: T) => {
291
+ if (isOptionEqualToValue) return valueArray.some((v) => isOptionEqualToValue(v, option));
292
+ return valueSet.has(option);
293
+ },
294
+ [isOptionEqualToValue, valueArray, valueSet]
295
+ );
296
+
297
+ const searchString = isTyping || multiple ? inputValue : '';
298
+
299
+ const filterFn = useCallback(
300
+ (option: T) => {
301
+ const labelStr = getOptionLabel(option)?.toLowerCase();
302
+ return labelStr?.includes(searchString?.toLowerCase());
303
+ },
304
+ [searchString, getOptionLabel]
305
+ );
306
+
307
+ const filteredOptions = useMemo(
308
+ () =>
309
+ filterOptionsProp ?
310
+ filterOptionsProp(options, {
311
+ inputValue: searchString,
312
+ getOptionLabel,
313
+ })
314
+ : options.filter(filterFn),
315
+ [filterOptionsProp, options, searchString, getOptionLabel, filterFn]
316
+ );
317
+
318
+ const groupedFilteredOptions = useMemo(() => {
319
+ if (!groupBy) return null;
320
+ const map = new Map<string, T[]>();
321
+ for (const option of filteredOptions) {
322
+ const group = String(groupBy(option) ?? '');
323
+ const arr = map.get(group);
324
+ if (arr) arr.push(option);
325
+ else map.set(group, [option]);
326
+ }
327
+ return Array.from(map.entries()).map(([group, groupOptions]) => ({
328
+ group,
329
+ options: groupOptions,
330
+ }));
331
+ }, [filteredOptions, groupBy]);
332
+
333
+ const noDataFound = filteredOptions.length === 0 && !loading;
334
+ const resolvedTokens = useTokens();
335
+ const selectorTokens = getSelectorTokensFromResolved(resolvedTokens);
336
+ const listboxGapPx = useMemo(() => {
337
+ const raw = resolvedTokens?.foundation?.spacing?.['1'];
338
+ const n =
339
+ typeof raw === 'string' ? parseInt(raw.replace(/px$/i, ''), 10)
340
+ : typeof raw === 'number' ? raw
341
+ : NaN;
342
+ if (Number.isFinite(n) && n > 0) return n;
343
+ return LISTBOX_GAP_FALLBACK_PX;
344
+ }, [resolvedTokens]);
345
+ const badgeStyle: React.CSSProperties = {
346
+ backgroundColor: selectorTokens.chipBackground,
347
+ };
348
+ const inputHeight =
349
+ size === 'xs' ? parseInt(selectorTokens.inputHeightXs, 10)
350
+ : size === 'sm' ? parseInt(selectorTokens.inputHeightSm, 10)
351
+ : size === 'lg' ? parseInt(selectorTokens.inputHeightLg, 10)
352
+ : parseInt(selectorTokens.inputHeightMd, 10);
353
+ const inputPadding =
354
+ size === 'xs' ? selectorTokens.inputPaddingXs
355
+ : size === 'sm' ? selectorTokens.inputPaddingSm
356
+ : size === 'lg' ? selectorTokens.inputPaddingLg
357
+ : selectorTokens.inputPaddingMd;
358
+ const inputFontSize =
359
+ size === 'xs' ? selectorTokens.inputFontSizeXs
360
+ : size === 'sm' ? selectorTokens.inputFontSizeSm
361
+ : size === 'lg' ? selectorTokens.inputFontSizeLg
362
+ : selectorTokens.inputFontSizeMd;
363
+ const inputBorderRadius =
364
+ size === 'xs' ? selectorTokens.inputBorderRadiusXs
365
+ : size === 'sm' ? selectorTokens.inputBorderRadiusSm
366
+ : size === 'lg' ? selectorTokens.inputBorderRadiusLg
367
+ : selectorTokens.inputBorderRadiusMd;
368
+ const showClear =
369
+ clearable &&
370
+ (isOpen || (isHovering && !disabled)) &&
371
+ (multiple ? hasValuesMulti : hasValueSingle || inputValue !== '');
372
+ const selectedSingleIso =
373
+ !multiple && showFlag && value != null ? getOptionIsoCode(value as SelectorOption) : null;
374
+ const inputPaddingRight =
375
+ disabled ? 20
376
+ : showClear ? 56
377
+ : 36;
378
+ const inputPaddingLeft = selectedSingleIso ? '40px' : '20px';
379
+
380
+ const parsePaddingY = useCallback((padding: string) => {
381
+ // Expected: "6px 12px" or "6 12" etc; we only need the first value
382
+ const first =
383
+ String(padding ?? '')
384
+ .trim()
385
+ .split(/\s+/)[0] ?? '';
386
+ const n = parseInt(first, 10);
387
+ return Number.isFinite(n) ? n : 0;
388
+ }, []);
389
+ const wrapperPaddingY = parsePaddingY(inputPadding);
390
+ const visibleTags =
391
+ multiple && !isOpen && limitTags >= 0 ? valueArray.slice(0, limitTags) : valueArray;
392
+ const remainingCount =
393
+ multiple && !isOpen && limitTags >= 0 ? Math.max(0, valueArray.length - limitTags) : 0;
394
+
395
+ useEffect(() => {
396
+ if (multiple || isTyping) return;
397
+ if (value != null) {
398
+ setInputValue(getOptionLabel(value as T));
399
+ } else {
400
+ setInputValue('');
401
+ }
402
+ }, [multiple, value, getOptionLabel, isTyping]);
403
+
404
+ const updateListboxPosition = useCallback(
405
+ (useMeasuredHeight: boolean) => {
406
+ const anchor = containerRef.current;
407
+ if (!anchor) return;
408
+
409
+ const rect = anchor.getBoundingClientRect();
410
+ const inputRect = inputAnchorRef.current?.getBoundingClientRect();
411
+ const anchorTop = inputRect?.top ?? rect.top;
412
+ const anchorBottom = inputRect?.bottom ?? rect.bottom;
413
+
414
+ const viewportHeight = Math.max(
415
+ 0,
416
+ window.innerHeight || document.documentElement.clientHeight
417
+ );
418
+
419
+ const headingCount =
420
+ groupedFilteredOptions ?
421
+ groupedFilteredOptions.reduce((count, group) => {
422
+ return group.group ? count + 1 : count;
423
+ }, 0)
424
+ : 0;
425
+ const estimatedListHeight =
426
+ noDataFound ? ESTIMATED_EMPTY_STATE_HEIGHT
427
+ : loading && filteredOptions.length === 0 ? ESTIMATED_EMPTY_STATE_HEIGHT
428
+ : filteredOptions.length * ESTIMATED_OPTION_ROW_HEIGHT +
429
+ headingCount * ESTIMATED_GROUP_HEADING_HEIGHT;
430
+ // Estimate the actual dropdown height so we don't force "up" placement
431
+ // just because LIST_MAX_HEIGHT is large.
432
+ const neededHeight = Math.min(
433
+ LIST_MAX_HEIGHT,
434
+ Math.max(ESTIMATED_EMPTY_STATE_HEIGHT, estimatedListHeight)
435
+ );
436
+
437
+ // Use scrollHeight (natural content height) instead of bounding rect height.
438
+ // bounding rect returns the already-clamped (visible) height when maxHeight kicks in,
439
+ // which creates a feedback loop: each reposition shrinks maxHeight further while the
440
+ // user scrolls inside the list. scrollHeight is stable regardless of the clamp.
441
+ const measuredHeight =
442
+ useMeasuredHeight && listboxRef.current ?
443
+ Math.min(listboxRef.current.scrollHeight, LIST_MAX_HEIGHT)
444
+ : undefined;
445
+ // Use real height when available; otherwise estimate for clamp math — not LIST_MAX_HEIGHT,
446
+ // or short lists get clamped as if 300px tall and sit far below the input.
447
+ const heightForPosition =
448
+ typeof measuredHeight === 'number' && measuredHeight > 0 ? measuredHeight : neededHeight;
449
+
450
+ const gap = listboxGapPx;
451
+ const edge = VIEWPORT_EDGE_MARGIN_PX;
452
+ // List top when opening down: always below the input by `gap` (never overlap the field).
453
+ const topDown = anchorBottom + gap;
454
+ // List top when opening up: list bottom sits `gap` px above the input top.
455
+ const topUp = anchorTop - gap - heightForPosition;
456
+
457
+ // Open down only if the full list fits below with a viewport margin; otherwise prefer up.
458
+ const fitsFullyBelow = topDown + heightForPosition <= viewportHeight - edge;
459
+ const fitsFullyAbove = topUp >= 0;
460
+
461
+ let placement: 'up' | 'down';
462
+ let top: number;
463
+
464
+ if (fitsFullyBelow) {
465
+ placement = 'down';
466
+ top = topDown;
467
+ } else if (fitsFullyAbove) {
468
+ placement = 'up';
469
+ top = topUp;
470
+ } else {
471
+ const roomBelow = viewportHeight - topDown - edge;
472
+ const roomAbove = anchorTop - gap - edge;
473
+ if (roomAbove > roomBelow) {
474
+ placement = 'up';
475
+ top = Math.max(0, topUp);
476
+ } else {
477
+ placement = 'down';
478
+ top = topDown;
479
+ }
480
+ }
481
+
482
+ const availableDown = Math.max(0, viewportHeight - top - edge);
483
+ const availableUp = Math.max(0, anchorTop - gap - top - edge);
484
+ const available = placement === 'down' ? availableDown : availableUp;
485
+ const maxHeight =
486
+ available > 0 ?
487
+ Math.min(LIST_MAX_HEIGHT, available)
488
+ : Math.min(LIST_MAX_HEIGHT, ESTIMATED_EMPTY_STATE_HEIGHT);
489
+
490
+ setListboxPosition({
491
+ top,
492
+ left: inputRect?.left ?? rect.left,
493
+ width: inputRect?.width ?? rect.width,
494
+ placement,
495
+ maxHeight,
496
+ });
497
+ },
498
+ [filteredOptions.length, groupedFilteredOptions, listboxGapPx, loading, noDataFound]
499
+ );
500
+
501
+ const scheduleListboxPositionUpdate = useCallback(() => {
502
+ if (listboxPositionRafRef.current != null) {
503
+ cancelAnimationFrame(listboxPositionRafRef.current);
504
+ }
505
+ listboxPositionRafRef.current = requestAnimationFrame(() => {
506
+ listboxPositionRafRef.current = null;
507
+ updateListboxPosition(true);
508
+ });
509
+ }, [updateListboxPosition]);
510
+
511
+ useEffect(() => {
512
+ if (!isOpen) {
513
+ setIsTyping(false);
514
+ setListboxPosition(null);
515
+ return;
516
+ }
517
+
518
+ let active = true;
519
+ updateListboxPosition(false);
520
+
521
+ // Re-measure after mount (helps for the "up" case and shorter-than-max lists).
522
+ const measureTimeoutId = window.setTimeout(() => {
523
+ if (!active) return;
524
+ updateListboxPosition(true);
525
+ }, 0);
526
+
527
+ // Scroll selected option inside the listbox only (avoid window scroll / page height growth).
528
+ const scrollTimeoutId = window.setTimeout(() => {
529
+ if (!active) return;
530
+ const list = listboxRef.current;
531
+ if (!list) return;
532
+ const selectedItem = list.querySelector('[aria-selected="true"]');
533
+ if (selectedItem) {
534
+ scrollListboxItemIntoView(list, selectedItem as HTMLElement);
535
+ }
536
+ }, 50);
537
+
538
+ return () => {
539
+ active = false;
540
+ window.clearTimeout(measureTimeoutId);
541
+ window.clearTimeout(scrollTimeoutId);
542
+ };
543
+ }, [isOpen, updateListboxPosition]);
544
+
545
+ useEffect(() => {
546
+ if (!isOpen) return;
547
+
548
+ const handleResize = () => {
549
+ scheduleListboxPositionUpdate();
550
+ };
551
+
552
+ const handleScroll = (event: Event) => {
553
+ // Ignore the listbox's own internal scroll — it doesn't move the anchor input,
554
+ // and re-measuring on every option-list scroll causes the dropdown to squeeze.
555
+ const target = event.target as Node | null;
556
+ if (target && listboxRef.current?.contains(target)) return;
557
+ scheduleListboxPositionUpdate();
558
+ };
559
+
560
+ // `capture: true` ensures we react to scroll events from scrollable ancestors.
561
+ window.addEventListener('resize', handleResize);
562
+ window.addEventListener('scroll', handleScroll, true);
563
+
564
+ return () => {
565
+ window.removeEventListener('resize', handleResize);
566
+ window.removeEventListener('scroll', handleScroll, true);
567
+ };
568
+ }, [isOpen, scheduleListboxPositionUpdate]);
569
+
570
+ useEffect(() => {
571
+ return () => {
572
+ if (listboxPositionRafRef.current != null) {
573
+ cancelAnimationFrame(listboxPositionRafRef.current);
574
+ }
575
+ };
576
+ }, []);
577
+
578
+ useEffect(() => {
579
+ if (prevIsOpenForCallbackRef.current === isOpen) return;
580
+ prevIsOpenForCallbackRef.current = isOpen;
581
+ onOpenChange?.(isOpen);
582
+ }, [isOpen, onOpenChange]);
583
+
584
+ useEffect(() => {
585
+ if (typeof open !== 'boolean') return;
586
+ setIsOpen(open);
587
+ }, [open]);
588
+
589
+ useEffect(() => {
590
+ const handleClickOutside = (e: MouseEvent) => {
591
+ if (
592
+ !containerRef.current?.contains(e.target as Node) &&
593
+ !listboxRef.current?.contains(e.target as Node)
594
+ ) {
595
+ setIsOpen(false);
596
+ }
597
+ };
598
+ const handleMouseUp = () => {
599
+ mouseDownOnFieldRef.current = false;
600
+ };
601
+ document.addEventListener('mousedown', handleClickOutside);
602
+ document.addEventListener('mouseup', handleMouseUp);
603
+ return () => {
604
+ document.removeEventListener('mousedown', handleClickOutside);
605
+ document.removeEventListener('mouseup', handleMouseUp);
606
+ };
607
+ }, []);
608
+
609
+ const handleInputChange = useCallback(
610
+ (e: React.ChangeEvent<HTMLInputElement>) => {
611
+ setInputValue(e.target.value);
612
+ setIsTyping(true);
613
+ setIsOpen(true);
614
+ onInput?.(e);
615
+ },
616
+ [onInput]
617
+ );
618
+
619
+ const handleSelect = useCallback(
620
+ (option: T) => {
621
+ if (isOptionDisabled?.(option)) return;
622
+ if (multiple) {
623
+ const next =
624
+ valueArray.some((v) => isEqual(v, option)) ?
625
+ valueArray.filter((v) => !isEqual(v, option))
626
+ : [...valueArray, option];
627
+ (onChange as (v: T[]) => void)?.(next);
628
+ } else {
629
+ didSelectRef.current = true;
630
+ setInputValue(getOptionLabel(option));
631
+ setIsTyping(false);
632
+ setIsOpen(false);
633
+ (onChange as (v: T | null) => void)?.(option);
634
+ setTimeout(() => {
635
+ didSelectRef.current = false;
636
+ }, 0);
637
+ }
638
+ },
639
+ [multiple, valueArray, onChange, getOptionLabel, isOptionDisabled]
640
+ );
641
+
642
+ const handleClear = useCallback(
643
+ (e: React.MouseEvent) => {
644
+ e.preventDefault();
645
+ e.stopPropagation();
646
+ setInputValue('');
647
+ setIsOpen(false);
648
+ if (multiple) {
649
+ (onChange as (v: T[]) => void)?.([]);
650
+ } else {
651
+ (onChange as (v: T | null) => void)?.(null);
652
+ }
653
+ },
654
+ [multiple, onChange]
655
+ );
656
+
657
+ const handleRemoveChip = useCallback(
658
+ (e: React.MouseEvent, option: T) => {
659
+ e.preventDefault();
660
+ e.stopPropagation();
661
+ if (disabled) return;
662
+ const next = valueArray.filter((v) => !isEqual(v, option));
663
+ (onChange as (v: T[]) => void)?.(next);
664
+ },
665
+ [valueArray, onChange, disabled]
666
+ );
667
+
668
+ const handleFocus = useCallback(() => {
669
+ setIsFocused(true);
670
+ if (disabled) return;
671
+ if (mouseDownOnFieldRef.current) return;
672
+ setIsOpen(true);
673
+ }, [disabled]);
674
+
675
+ const handleFieldMouseDown = useCallback(() => {
676
+ mouseDownOnFieldRef.current = true;
677
+ }, []);
678
+
679
+ const handleFieldClick = useCallback(
680
+ (e: React.MouseEvent) => {
681
+ mouseDownOnFieldRef.current = false;
682
+ if (disabled) return;
683
+ if ((e.target as HTMLElement).closest('button')) return;
684
+ setIsOpen((prev) => !prev);
685
+ },
686
+ [disabled]
687
+ );
688
+
689
+ const handleBlur = useCallback(() => {
690
+ setIsFocused(false);
691
+ if (multiple) return;
692
+ if (didSelectRef.current) return;
693
+ const valueLabel = value != null ? getOptionLabel(value as T) : '';
694
+ if (inputValue !== valueLabel) {
695
+ (onChange as (v: T | null) => void)?.(null);
696
+ setInputValue('');
697
+ }
698
+ setIsTyping(false);
699
+ }, [multiple, value, inputValue, getOptionLabel, onChange]);
700
+
701
+ const handleArrowClick = useCallback(
702
+ (e: React.MouseEvent) => {
703
+ e.preventDefault();
704
+ e.stopPropagation();
705
+ if (!disabled) setIsOpen((prev) => !prev);
706
+ },
707
+ [disabled]
708
+ );
709
+
710
+ const preventDefault = useCallback((e: React.MouseEvent) => {
711
+ e.preventDefault();
712
+ }, []);
713
+
714
+ const handleMultiWrapperMouseDown = useCallback(() => {
715
+ mouseDownOnFieldRef.current = true;
716
+ }, []);
717
+
718
+ const handleMultiWrapperClick = useCallback(
719
+ (e: React.MouseEvent<HTMLDivElement>) => {
720
+ mouseDownOnFieldRef.current = false;
721
+ if (disabled) return;
722
+ if ((e.target as HTMLElement).closest('button')) return;
723
+ setIsOpen((prev) => !prev);
724
+ // Focus the input after a small delay to ensure it's rendered
725
+ setTimeout(() => {
726
+ multiInputRef.current?.focus();
727
+ }, 0);
728
+ },
729
+ [disabled]
730
+ );
731
+
732
+ const inputBackground =
733
+ color === 'white' ? selectorTokens.inputBackgroundWhite : selectorTokens.inputBackgroundWhite;
734
+ const wrapperBackground =
735
+ color === 'white' ? selectorTokens.wrapperBackgroundWhite : selectorTokens.wrapperBackground;
736
+
737
+ const inputStyle: React.CSSProperties = useMemo(
738
+ () => ({
739
+ width: '100%',
740
+ padding: inputPadding,
741
+ paddingLeft: inputPaddingLeft,
742
+ paddingRight: inputPaddingRight,
743
+ border:
744
+ error ? selectorTokens.inputBorderError
745
+ : isFocused ? selectorTokens.inputBorderActive
746
+ : '1px solid #c7ced6',
747
+ borderRadius: inputBorderRadius,
748
+ fontSize: inputFontSize,
749
+ minHeight: inputHeight,
750
+ maxHeight: inputHeight,
751
+ boxSizing: 'border-box',
752
+ backgroundColor: disabled ? selectorTokens.inputBackgroundDisabled : inputBackground,
753
+ outline: 'none',
754
+ boxShadow: 'none',
755
+ color: selectorTokens.inputColor,
756
+ opacity: disabled ? parseFloat(selectorTokens.inputDisabledOpacity) : 1,
757
+ cursor: disabled ? 'not-allowed' : undefined,
758
+ }),
759
+ [
760
+ inputPaddingRight,
761
+ inputPaddingLeft,
762
+ inputHeight,
763
+ inputPadding,
764
+ inputBorderRadius,
765
+ inputFontSize,
766
+ disabled,
767
+ isFocused,
768
+ error,
769
+ selectorTokens,
770
+ inputBackground,
771
+ ]
772
+ );
773
+
774
+ const wrapperStyle: React.CSSProperties = useMemo(
775
+ () => ({
776
+ display: 'flex',
777
+ flexWrap: 'wrap',
778
+ alignItems: 'center',
779
+ gap: 8,
780
+ minHeight: inputHeight,
781
+ paddingTop: wrapperPaddingY,
782
+ paddingBottom: wrapperPaddingY,
783
+ paddingLeft: valueArray.length === 0 ? 16 : 8,
784
+ paddingRight:
785
+ disabled ? 20
786
+ : showClear ? 56
787
+ : 36,
788
+ border:
789
+ error ? selectorTokens.inputBorderError
790
+ : isFocused ? selectorTokens.inputBorderActive
791
+ : '1px solid #c7ced6',
792
+ borderRadius: inputBorderRadius,
793
+ backgroundColor: disabled ? selectorTokens.wrapperBackgroundDisabled : wrapperBackground,
794
+ boxSizing: 'border-box',
795
+ boxShadow: 'none',
796
+ opacity: disabled ? parseFloat(selectorTokens.inputDisabledOpacity) : 1,
797
+ }),
798
+ [
799
+ inputHeight,
800
+ wrapperPaddingY,
801
+ showClear,
802
+ disabled,
803
+ valueArray.length,
804
+ selectorTokens,
805
+ wrapperBackground,
806
+ isFocused,
807
+ error,
808
+ inputBorderRadius,
809
+ ]
810
+ );
811
+
812
+ const chipStyle: React.CSSProperties = {
813
+ display: 'inline-flex',
814
+ alignItems: 'center',
815
+ gap: 6,
816
+ padding: '7px 12px 7px 8px',
817
+ fontSize: selectorTokens.chipFontSize,
818
+ backgroundColor: selectorTokens.chipBackground,
819
+ color: selectorTokens.chipColor,
820
+ borderRadius: selectorTokens.chipBorderRadius,
821
+ };
822
+
823
+ const handleClearButtonMouseEnter = useCallback(
824
+ (e: React.MouseEvent<HTMLButtonElement>) => {
825
+ if (!disabled) {
826
+ e.currentTarget.style.backgroundColor = selectorTokens.buttonHoverBackground;
827
+ e.currentTarget.style.color = selectorTokens.buttonHoverColor;
828
+ }
829
+ },
830
+ [disabled, selectorTokens.buttonHoverBackground, selectorTokens.buttonHoverColor]
831
+ );
832
+ const handleClearButtonMouseLeave = useCallback(
833
+ (e: React.MouseEvent<HTMLButtonElement>) => {
834
+ e.currentTarget.style.backgroundColor = 'transparent';
835
+ e.currentTarget.style.color = selectorTokens.buttonColor;
836
+ },
837
+ [selectorTokens.buttonColor]
838
+ );
839
+ const handleArrowButtonMouseEnter = useCallback(
840
+ (e: React.MouseEvent<HTMLButtonElement>) => {
841
+ if (!disabled) e.currentTarget.style.color = selectorTokens.buttonColor;
842
+ },
843
+ [disabled, selectorTokens.buttonColor]
844
+ );
845
+ const handleArrowButtonMouseLeave = useCallback(
846
+ (e: React.MouseEvent<HTMLButtonElement>) => {
847
+ e.currentTarget.style.color = selectorTokens.buttonColor;
848
+ },
849
+ [selectorTokens.buttonColor]
850
+ );
851
+
852
+ const iconButtonStyle: React.CSSProperties = {
853
+ display: 'flex',
854
+ alignItems: 'center',
855
+ justifyContent: 'center',
856
+ width: 24,
857
+ height: 24,
858
+ padding: 0,
859
+ border: 'none',
860
+ background: 'transparent',
861
+ cursor: disabled ? 'not-allowed' : 'pointer',
862
+ color: selectorTokens.buttonColor,
863
+ borderRadius: '50%',
864
+ };
865
+
866
+ if (skeletonLoading) {
867
+ return (
868
+ <div
869
+ style={{
870
+ width: '100%',
871
+ position: 'relative',
872
+ fontFamily: 'sans-serif',
873
+ }}
874
+ >
875
+ {label && (
876
+ <label
877
+ style={{
878
+ display: 'block',
879
+ marginBottom: parseInt(selectorTokens.labelMarginBottom, 10),
880
+ fontSize: selectorTokens.labelFontSize,
881
+ fontWeight: selectorTokens.labelFontWeight,
882
+ color: selectorTokens.labelColor,
883
+ }}
884
+ >
885
+ {required && (
886
+ <span
887
+ style={{
888
+ color: selectorTokens.labelRequiredColor,
889
+ marginRight: 4,
890
+ }}
891
+ >
892
+ *
893
+ </span>
894
+ )}
895
+ {label}
896
+ </label>
897
+ )}
898
+ <Skeleton height={inputHeight} borderRadius={8} />
899
+ </div>
900
+ );
901
+ }
902
+
903
+ const resolvedHelperText = helperText ?? (typeof error === 'string' ? error : undefined);
904
+
905
+ return (
906
+ <div
907
+ ref={containerRef}
908
+ style={{ width: '100%', position: 'relative', fontFamily: 'sans-serif' }}
909
+ >
910
+ <style>
911
+ {`.selector-input::placeholder {
912
+ color: ${selectorTokens.helperColor};
913
+ }
914
+ .selector-listbox {
915
+ scrollbar-width: thin;
916
+ scrollbar-color: #C8D0D5 transparent;
917
+ }
918
+ .selector-listbox::-webkit-scrollbar {
919
+ width: 8px;
920
+ }
921
+ .selector-listbox::-webkit-scrollbar-track {
922
+ background: transparent;
923
+ border-radius: 4px;
924
+ }
925
+ .selector-listbox::-webkit-scrollbar-thumb {
926
+ background-color: #C8D0D5;
927
+ border-radius: 4px;
928
+ }
929
+ .selector-listbox::-webkit-scrollbar-thumb:hover {
930
+ background-color: #B0B8BD;
931
+ }
932
+ .selector-listbox::-webkit-scrollbar-button {
933
+ display: none !important;
934
+ width: 0 !important;
935
+ height: 0 !important;
936
+ }
937
+ .selector-listbox::-webkit-scrollbar-button:start:decrement,
938
+ .selector-listbox::-webkit-scrollbar-button:end:increment {
939
+ display: none !important;
940
+ }
941
+ .selector-listbox::-webkit-scrollbar-corner {
942
+ background: transparent;
943
+ }
944
+ .selector-listbox .custom-checkbox-label:empty {
945
+ display: none;
946
+ }
947
+ .selector-listbox .custom-checkbox-wrapper {
948
+ margin: 0;
949
+ }
950
+ .selector-listbox .custom-checkbox-label {
951
+ margin-left: 0;
952
+ }`}
953
+ </style>
954
+ {label && (
955
+ <label
956
+ onClick={(e) => {
957
+ if (disabled) return;
958
+ if (isOpen) {
959
+ setIsOpen(false);
960
+ } else {
961
+ e.preventDefault();
962
+ }
963
+ }}
964
+ style={{
965
+ display: 'block',
966
+ marginBottom: parseInt(selectorTokens.labelMarginBottom, 10),
967
+ fontSize: selectorTokens.labelFontSize,
968
+ fontWeight: selectorTokens.labelFontWeight,
969
+ color: selectorTokens.labelColor,
970
+ // opacity: disabled ? parseFloat(selectorTokens.inputDisabledOpacity) : 1,
971
+ cursor: 'default',
972
+ }}
973
+ >
974
+ {label}
975
+ {required && (
976
+ <span
977
+ style={{
978
+ color: selectorTokens.labelRequiredColor,
979
+ marginRight: 4,
980
+ }}
981
+ >
982
+ *
983
+ </span>
984
+ )}
985
+ </label>
986
+ )}
987
+ <div
988
+ ref={inputAnchorRef}
989
+ style={{ position: 'relative' }}
990
+ onMouseEnter={() => setIsHovering(true)}
991
+ onMouseLeave={() => setIsHovering(false)}
992
+ >
993
+ {multiple ?
994
+ <div
995
+ ref={multiWrapperRef}
996
+ role="combobox"
997
+ aria-expanded={isOpen}
998
+ aria-haspopup="listbox"
999
+ style={{
1000
+ ...wrapperStyle,
1001
+ cursor: disabled ? 'not-allowed' : 'text',
1002
+ }}
1003
+ onMouseDown={handleMultiWrapperMouseDown}
1004
+ onClick={handleMultiWrapperClick}
1005
+ >
1006
+ {visibleTags.map((option) => {
1007
+ const optionLabel = getOptionLabel(option);
1008
+ const badgeSize =
1009
+ size === 'xs' || size === 'sm' ? 'sm'
1010
+ : size === 'lg' ? 'lg'
1011
+ : 'md';
1012
+ return (
1013
+ <Badge
1014
+ key={getOptionKey(option, valueArray.indexOf(option), getOptionLabel)}
1015
+ size={badgeSize}
1016
+ onRemove={(e) => handleRemoveChip(e, option)}
1017
+ disabled={disabled}
1018
+ removeLabel={`Remove ${optionLabel}`}
1019
+ style={badgeStyle}
1020
+ >
1021
+ {optionLabel}
1022
+ </Badge>
1023
+ );
1024
+ })}
1025
+ {remainingCount > 0 && (
1026
+ <span
1027
+ style={{
1028
+ ...chipStyle,
1029
+ backgroundColor: 'transparent',
1030
+ color: selectorTokens.chipColor,
1031
+ padding: '4px 0',
1032
+ fontSize: inputFontSize,
1033
+ fontWeight: 'normal',
1034
+ }}
1035
+ >
1036
+ +{remainingCount}
1037
+ </span>
1038
+ )}
1039
+ {(isOpen || valueArray.length === 0) && (
1040
+ <input
1041
+ ref={multiInputRef}
1042
+ id={id}
1043
+ required={required}
1044
+ type="text"
1045
+ autoComplete={autoComplete}
1046
+ placeholder={valueArray.length === 0 ? placeholder : ''}
1047
+ value={inputValue}
1048
+ onChange={handleInputChange}
1049
+ onKeyDown={(event) => {
1050
+ onKeyDown?.(event);
1051
+ }}
1052
+ onFocus={handleFocus}
1053
+ onBlur={handleBlur}
1054
+ onInvalid={(e) => {
1055
+ // Prevent browser-native validation bubble so custom helper text can be shown.
1056
+ e.preventDefault();
1057
+ }}
1058
+ disabled={disabled}
1059
+ aria-invalid={!!error}
1060
+ className="selector-input"
1061
+ style={{
1062
+ flex: 1,
1063
+ minWidth: 60,
1064
+ border: 'none',
1065
+ outline: 'none',
1066
+ fontSize: selectorTokens.chipFontSize,
1067
+ backgroundColor: 'transparent',
1068
+ padding: 0,
1069
+ color: selectorTokens.inputColor,
1070
+ }}
1071
+ />
1072
+ )}
1073
+ </div>
1074
+ : <input
1075
+ id={id}
1076
+ required={required}
1077
+ type="text"
1078
+ autoComplete={autoComplete}
1079
+ placeholder={placeholder}
1080
+ value={inputValue}
1081
+ onChange={handleInputChange}
1082
+ onFocus={handleFocus}
1083
+ onMouseDown={handleFieldMouseDown}
1084
+ onClick={handleFieldClick}
1085
+ onBlur={handleBlur}
1086
+ onInvalid={(e) => {
1087
+ // Prevent browser-native validation bubble so custom helper text can be shown.
1088
+ e.preventDefault();
1089
+ }}
1090
+ disabled={disabled}
1091
+ aria-invalid={!!error}
1092
+ className="selector-input"
1093
+ style={inputStyle}
1094
+ />
1095
+ }
1096
+ {!multiple && selectedSingleIso && (
1097
+ <img
1098
+ loading="lazy"
1099
+ width="24px"
1100
+ style={{
1101
+ position: 'absolute',
1102
+ left: 12,
1103
+ top: '50%',
1104
+ transform: 'translateY(-50%)',
1105
+ pointerEvents: 'none',
1106
+ }}
1107
+ srcSet={`https://flagcdn.com/w40/${selectedSingleIso}.png 2x`}
1108
+ src={`https://flagcdn.com/w20/${selectedSingleIso}.png`}
1109
+ alt={`${inputValue || 'Selected'} flag`}
1110
+ />
1111
+ )}
1112
+ {(showClear || !disabled) && (
1113
+ <div
1114
+ style={{
1115
+ position: 'absolute',
1116
+ right: 8,
1117
+ top: '50%',
1118
+ transform: 'translateY(-50%)',
1119
+ zIndex: 1,
1120
+ display: 'flex',
1121
+ alignItems: 'center',
1122
+ gap: 2,
1123
+ }}
1124
+ >
1125
+ {showClear && (
1126
+ <button
1127
+ type="button"
1128
+ onClick={handleClear}
1129
+ onMouseDown={preventDefault}
1130
+ disabled={disabled}
1131
+ aria-label={multiple ? 'Clear all' : 'Clear'}
1132
+ style={iconButtonStyle}
1133
+ onMouseEnter={handleClearButtonMouseEnter}
1134
+ onMouseLeave={handleClearButtonMouseLeave}
1135
+ >
1136
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
1137
+ <path d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41z" />
1138
+ </svg>
1139
+ </button>
1140
+ )}
1141
+ {!disabled && (
1142
+ <button
1143
+ type="button"
1144
+ onClick={handleArrowClick}
1145
+ onMouseDown={preventDefault}
1146
+ aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'}
1147
+ style={{
1148
+ ...iconButtonStyle,
1149
+ color: selectorTokens.buttonColor,
1150
+ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
1151
+ transition: 'transform 0.2s',
1152
+ }}
1153
+ onMouseEnter={handleArrowButtonMouseEnter}
1154
+ onMouseLeave={handleArrowButtonMouseLeave}
1155
+ >
1156
+ <svg
1157
+ width="16"
1158
+ height="16"
1159
+ viewBox="0 0 16 16"
1160
+ fill="none"
1161
+ xmlns="http://www.w3.org/2000/svg"
1162
+ >
1163
+ <path
1164
+ d="M4 6L8 10L12 6"
1165
+ stroke="currentColor"
1166
+ strokeWidth="2.5"
1167
+ strokeLinecap="round"
1168
+ strokeLinejoin="round"
1169
+ />
1170
+ </svg>
1171
+ </button>
1172
+ )}
1173
+ </div>
1174
+ )}
1175
+ </div>
1176
+ {(!!error || !!resolvedHelperText) && (
1177
+ <div
1178
+ style={{
1179
+ fontSize: selectorTokens.helperFontSize,
1180
+ marginTop: 4,
1181
+ color: error ? selectorTokens.helperErrorColor : selectorTokens.helperColor,
1182
+ }}
1183
+ role={error ? 'alert' : undefined}
1184
+ >
1185
+ {resolvedHelperText}
1186
+ </div>
1187
+ )}
1188
+ {isOpen &&
1189
+ listboxPosition &&
1190
+ createPortal(
1191
+ <ul
1192
+ ref={listboxRef}
1193
+ role="listbox"
1194
+ aria-label={label ?? placeholder}
1195
+ aria-multiselectable={multiple}
1196
+ style={{
1197
+ ...getListboxStyle(selectorTokens),
1198
+ position: 'fixed',
1199
+ top: listboxPosition.top,
1200
+ left: listboxPosition.left,
1201
+ width: Math.max(listboxPosition.width, listboxMinWidth ?? 0),
1202
+ maxHeight: listboxPosition.maxHeight,
1203
+ right: 'unset',
1204
+ }}
1205
+ className="selector-listbox"
1206
+ onScroll={onListboxScroll}
1207
+ >
1208
+ {noDataFound && (
1209
+ <li
1210
+ style={{
1211
+ padding: selectorTokens.optionPadding,
1212
+ textAlign: 'center',
1213
+ color: selectorTokens.optionColor,
1214
+ fontSize: selectorTokens.optionFontSize,
1215
+ }}
1216
+ >
1217
+ {noOptionsText}
1218
+ </li>
1219
+ )}
1220
+ {!noDataFound &&
1221
+ (groupedFilteredOptions ?
1222
+ (() => {
1223
+ let globalIndex = 0;
1224
+ return groupedFilteredOptions.map(({ group, options }, groupIdx) => {
1225
+ const children = options.map((option) => {
1226
+ const optionIndex = globalIndex++;
1227
+ const disabledOption = isOptionDisabled?.(option) ?? false;
1228
+ const optionLabel = getOptionLabel(option);
1229
+ const selected = isSelected(option);
1230
+ return (
1231
+ <OptionItem
1232
+ key={getOptionKey(option, optionIndex, getOptionLabel)}
1233
+ option={option}
1234
+ optionLabel={optionLabel}
1235
+ disabled={disabledOption}
1236
+ index={optionIndex}
1237
+ selected={selected}
1238
+ multiple={multiple}
1239
+ onSelect={handleSelect}
1240
+ renderOption={renderOption}
1241
+ selectorTokens={selectorTokens}
1242
+ showFlag={showFlag}
1243
+ />
1244
+ );
1245
+ });
1246
+
1247
+ const params = {
1248
+ key: `selector-group-${groupIdx}-${group}`,
1249
+ group,
1250
+ children,
1251
+ };
1252
+
1253
+ if (!group) {
1254
+ return <React.Fragment key={params.key}>{children}</React.Fragment>;
1255
+ }
1256
+
1257
+ return (
1258
+ <li key={params.key} style={{ listStyle: 'none' }}>
1259
+ <div style={getGroupHeadingStyle(selectorTokens)}>{group}</div>
1260
+ {renderGroup ?
1261
+ renderGroup(params)
1262
+ : <ul role="group" aria-label={group} style={defaultGroupListStyle}>
1263
+ {children}
1264
+ </ul>
1265
+ }
1266
+ </li>
1267
+ );
1268
+ });
1269
+ })()
1270
+ : filteredOptions.map((option, index) => {
1271
+ const disabledOption = isOptionDisabled?.(option) ?? false;
1272
+ const optionLabel = getOptionLabel(option);
1273
+ const selected = valueSet.has(option);
1274
+ return (
1275
+ <OptionItem
1276
+ key={getOptionKey(option, index, getOptionLabel)}
1277
+ option={option}
1278
+ optionLabel={optionLabel}
1279
+ disabled={disabledOption}
1280
+ index={index}
1281
+ selected={selected}
1282
+ multiple={multiple}
1283
+ onSelect={handleSelect}
1284
+ renderOption={renderOption}
1285
+ selectorTokens={selectorTokens}
1286
+ showFlag={showFlag}
1287
+ />
1288
+ );
1289
+ }))}
1290
+ {loading && !noDataFound && (
1291
+ <li
1292
+ style={{
1293
+ padding: selectorTokens.optionPadding,
1294
+ textAlign: 'center',
1295
+ color: selectorTokens.optionColor,
1296
+ fontSize: selectorTokens.optionFontSize,
1297
+ }}
1298
+ >
1299
+ {loadingText}
1300
+ </li>
1301
+ )}
1302
+ </ul>,
1303
+ document.body
1304
+ )}
1305
+ </div>
1306
+ );
1307
+ }
1308
+
1309
+ const Selector = memo(SelectorInner) as typeof SelectorInner;
1310
+ export default Selector;