@thangnqdev/ui 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +144 -142
  3. package/bin/cli.js +125 -125
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/buttons/index.cjs.map +1 -1
  7. package/dist/buttons/index.js +3 -3
  8. package/dist/{chunk-EXFR2BFJ.js → chunk-2ANJFOIU.js} +38 -36
  9. package/dist/chunk-2ANJFOIU.js.map +1 -0
  10. package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
  11. package/dist/chunk-2OXZK6IP.js.map +1 -0
  12. package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
  13. package/dist/chunk-4GNFT2ZJ.js.map +1 -0
  14. package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
  15. package/dist/chunk-4S6L5V7X.js.map +1 -0
  16. package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
  17. package/dist/chunk-66VWUJEI.js.map +1 -0
  18. package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
  19. package/dist/chunk-7NMB2Y64.js.map +1 -0
  20. package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
  21. package/dist/chunk-A7P3O5Z5.js.map +1 -0
  22. package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
  23. package/dist/chunk-ATYGDTMK.js.map +1 -0
  24. package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
  25. package/dist/chunk-C4SUXMI3.js.map +1 -0
  26. package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
  27. package/dist/chunk-EU2LPUYP.js.map +1 -0
  28. package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
  29. package/dist/chunk-GXI2H5XA.js.map +1 -0
  30. package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
  31. package/dist/chunk-IABPUCAQ.js.map +1 -0
  32. package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
  33. package/dist/chunk-JOKEKOP3.js.map +1 -0
  34. package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
  35. package/dist/chunk-KUQWUTZA.js.map +1 -0
  36. package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
  37. package/dist/chunk-U3J4UPV3.js.map +1 -0
  38. package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
  39. package/dist/chunk-VE2PNSDQ.js.map +1 -0
  40. package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
  41. package/dist/chunk-WAFHC4DQ.js.map +1 -0
  42. package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
  43. package/dist/chunk-YJ67WYVW.js.map +1 -0
  44. package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
  45. package/dist/chunk-Z427NPMU.js.map +1 -0
  46. package/dist/core/index.cjs +34 -32
  47. package/dist/core/index.cjs.map +1 -1
  48. package/dist/core/index.js +2 -2
  49. package/dist/data-display/index.cjs.map +1 -1
  50. package/dist/data-display/index.js +5 -5
  51. package/dist/feedback/index.cjs.map +1 -1
  52. package/dist/feedback/index.js +2 -2
  53. package/dist/forms/index.cjs.map +1 -1
  54. package/dist/forms/index.js +8 -8
  55. package/dist/glass/index.cjs.map +1 -1
  56. package/dist/glass/index.js +2 -2
  57. package/dist/index.cjs +34 -32
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.js +19 -19
  60. package/dist/layout/index.cjs.map +1 -1
  61. package/dist/layout/index.js +2 -2
  62. package/dist/navigation/index.cjs.map +1 -1
  63. package/dist/navigation/index.js +6 -6
  64. package/dist/overlays/index.cjs.map +1 -1
  65. package/dist/overlays/index.js +7 -7
  66. package/dist/reset.css +45 -45
  67. package/dist/tokens.css +11 -11
  68. package/dist/typography/index.cjs.map +1 -1
  69. package/dist/typography/index.js +4 -4
  70. package/llms.txt +3 -3
  71. package/package.json +165 -165
  72. package/scripts/postinstall.js +125 -125
  73. package/src/bento/BentoCard.tsx +140 -140
  74. package/src/bento/BentoGrid.tsx +50 -50
  75. package/src/bento/index.ts +2 -2
  76. package/src/bento/tokens.css +46 -46
  77. package/src/buttons/Button.tsx +104 -104
  78. package/src/buttons/ButtonGroup.tsx +43 -43
  79. package/src/buttons/IconButton.tsx +79 -79
  80. package/src/buttons/SplitButton.tsx +186 -186
  81. package/src/buttons/ToggleButton.tsx +157 -157
  82. package/src/buttons/index.ts +5 -5
  83. package/src/buttons/tokens.css +6 -6
  84. package/src/core/ThemeProvider.tsx +124 -106
  85. package/src/core/index.ts +2 -2
  86. package/src/core/tokens.css +134 -134
  87. package/src/data-display/Accordion.tsx +147 -147
  88. package/src/data-display/Avatar.tsx +134 -134
  89. package/src/data-display/Card.tsx +112 -112
  90. package/src/data-display/Chip.tsx +74 -74
  91. package/src/data-display/DataTablePro.tsx +534 -534
  92. package/src/data-display/StatCard.tsx +60 -60
  93. package/src/data-display/Table.tsx +74 -74
  94. package/src/data-display/Timeline.tsx +62 -62
  95. package/src/data-display/TreeView.tsx +279 -279
  96. package/src/data-display/tokens.css +4 -4
  97. package/src/feedback/Alert.tsx +80 -80
  98. package/src/feedback/EmptyState.tsx +45 -45
  99. package/src/feedback/Skeleton.tsx +66 -66
  100. package/src/feedback/Spinner.tsx +65 -65
  101. package/src/feedback/index.ts +5 -5
  102. package/src/feedback/tokens.css +4 -4
  103. package/src/forms/Checkbox.tsx +149 -149
  104. package/src/forms/CheckboxGroup.tsx +76 -76
  105. package/src/forms/Combobox.tsx +512 -512
  106. package/src/forms/DatePicker.tsx +784 -784
  107. package/src/forms/FileUpload.tsx +266 -266
  108. package/src/forms/FormField.tsx +115 -115
  109. package/src/forms/Input.tsx +151 -151
  110. package/src/forms/NumberInput.tsx +262 -262
  111. package/src/forms/PasswordInput.tsx +35 -35
  112. package/src/forms/PinInput.tsx +139 -139
  113. package/src/forms/Radio.tsx +142 -142
  114. package/src/forms/RangeSlider.tsx +237 -237
  115. package/src/forms/SearchInput.tsx +34 -34
  116. package/src/forms/Select.tsx +103 -103
  117. package/src/forms/Slider.tsx +85 -85
  118. package/src/forms/Textarea.tsx +114 -114
  119. package/src/forms/tokens.css +6 -6
  120. package/src/glass/GlassButton.tsx +59 -59
  121. package/src/glass/GlassCard.tsx +71 -71
  122. package/src/glass/index.ts +3 -3
  123. package/src/glass/tokens.css +81 -81
  124. package/src/index.ts +26 -26
  125. package/src/layout/AspectRatio.tsx +39 -39
  126. package/src/layout/Container.tsx +53 -53
  127. package/src/layout/Divider.tsx +61 -61
  128. package/src/layout/Flex.tsx +79 -79
  129. package/src/layout/Grid.tsx +124 -124
  130. package/src/layout/PageLayout.tsx +59 -59
  131. package/src/layout/Section.tsx +47 -47
  132. package/src/layout/Spacer.tsx +30 -30
  133. package/src/layout/Stack.tsx +88 -88
  134. package/src/layout/tokens.css +8 -8
  135. package/src/navigation/Breadcrumb.tsx +73 -73
  136. package/src/navigation/CommandPalette.tsx +142 -142
  137. package/src/navigation/DropdownMenu.tsx +244 -244
  138. package/src/navigation/Pagination.tsx +114 -114
  139. package/src/navigation/Stepper.tsx +93 -93
  140. package/src/navigation/Tabs.tsx +265 -265
  141. package/src/navigation/index.ts +6 -6
  142. package/src/navigation/tokens.css +4 -4
  143. package/src/overlays/AlertDialog.tsx +63 -63
  144. package/src/overlays/BottomSheet.tsx +143 -143
  145. package/src/overlays/ContextMenu.tsx +161 -161
  146. package/src/overlays/Drawer.tsx +160 -160
  147. package/src/overlays/FocusTrap.tsx +122 -122
  148. package/src/overlays/Modal.tsx +296 -296
  149. package/src/overlays/Popover.tsx +156 -156
  150. package/src/overlays/Portal.tsx +23 -23
  151. package/src/overlays/Toast.tsx +175 -175
  152. package/src/overlays/Tooltip.tsx +125 -125
  153. package/src/overlays/tokens.css +8 -8
  154. package/src/registry/components.ts +1368 -1368
  155. package/src/styles/components.css +39 -39
  156. package/src/styles/index.css +5 -5
  157. package/src/styles/reset.css +45 -45
  158. package/src/styles/tokens.css +11 -11
  159. package/src/typography/Blockquote.tsx +32 -32
  160. package/src/typography/Code.tsx +89 -89
  161. package/src/typography/Heading.tsx +56 -56
  162. package/src/typography/Kbd.tsx +25 -25
  163. package/src/typography/Label.tsx +47 -47
  164. package/src/typography/Link.tsx +56 -56
  165. package/src/typography/Text.tsx +62 -62
  166. package/src/typography/index.ts +7 -7
  167. package/src/typography/tokens.css +5 -5
  168. package/src/utils/cn.ts +6 -6
  169. package/src/utils/scroll-lock.ts +19 -19
  170. package/src/utils/useControllableState.ts +54 -54
  171. package/ui-contract.json +1 -1
  172. package/dist/chunk-2YJBW4E2.js.map +0 -1
  173. package/dist/chunk-4CRFNALF.js.map +0 -1
  174. package/dist/chunk-4MS6ZHFQ.js.map +0 -1
  175. package/dist/chunk-77KTYOMQ.js.map +0 -1
  176. package/dist/chunk-ADO3SVUA.js.map +0 -1
  177. package/dist/chunk-D7FB3TCA.js.map +0 -1
  178. package/dist/chunk-DSEL7ELZ.js.map +0 -1
  179. package/dist/chunk-EXFR2BFJ.js.map +0 -1
  180. package/dist/chunk-GXWB6BSY.js.map +0 -1
  181. package/dist/chunk-I2FQBMVW.js.map +0 -1
  182. package/dist/chunk-I5O2WCGF.js.map +0 -1
  183. package/dist/chunk-ME4XILG5.js.map +0 -1
  184. package/dist/chunk-PC5SZFZC.js.map +0 -1
  185. package/dist/chunk-S6XJMYME.js.map +0 -1
  186. package/dist/chunk-V52JOSDF.js.map +0 -1
  187. package/dist/chunk-W5WNWTQC.js.map +0 -1
  188. package/dist/chunk-YJ64RI7J.js.map +0 -1
  189. package/dist/chunk-YOCGBORV.js.map +0 -1
  190. package/dist/chunk-ZEDXARXW.js.map +0 -1
@@ -1,512 +1,512 @@
1
- import React, { forwardRef, useState, useRef, useEffect, useId } from 'react';
2
- import { Check, ChevronsUpDown, Search, X } from 'lucide-react';
3
- import { cn } from '../utils/cn';
4
- import { Popover } from '../overlays/Popover';
5
- import { Chip } from '../data-display/Chip';
6
-
7
- export interface ComboboxOption {
8
- value: string;
9
- label: string;
10
- description?: string;
11
- icon?: React.ReactNode;
12
- disabled?: boolean;
13
- }
14
-
15
- export interface ComboboxProps {
16
- options: ComboboxOption[];
17
- value?: string;
18
- defaultValue?: string;
19
- onChange?: (val: string) => void;
20
- placeholder?: string;
21
- searchPlaceholder?: string;
22
- emptyText?: string;
23
- disabled?: boolean;
24
- clearable?: boolean;
25
- className?: string;
26
- name?: string;
27
- id?: string;
28
- 'aria-label'?: string;
29
- }
30
-
31
- export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
32
- (
33
- {
34
- options,
35
- value: controlledVal,
36
- defaultValue = '',
37
- onChange,
38
- placeholder = 'Select option...',
39
- searchPlaceholder = 'Search items...',
40
- emptyText = 'No items found.',
41
- disabled = false,
42
- clearable = true,
43
- className,
44
- name,
45
- id: customId,
46
- 'aria-label': ariaLabel = 'Select option',
47
- },
48
- ref
49
- ) => {
50
- const baseId = useId();
51
- const comboId = customId || `combobox-${baseId}`;
52
- const listboxId = `listbox-${baseId}`;
53
-
54
- const [internalVal, setInternalVal] = useState(defaultValue);
55
- const isControlled = controlledVal !== undefined;
56
- const selectedValue = isControlled ? controlledVal : internalVal;
57
-
58
- const [search, setSearch] = useState('');
59
- const [isOpen, setIsOpen] = useState(false);
60
- const [highlightIndex, setHighlightIndex] = useState(0);
61
-
62
- const searchInputRef = useRef<HTMLInputElement>(null);
63
- const triggerRef = useRef<HTMLButtonElement>(null);
64
-
65
- const filteredOptions = options.filter(
66
- (opt) =>
67
- opt.label.toLowerCase().includes(search.toLowerCase()) ||
68
- opt.value.toLowerCase().includes(search.toLowerCase()) ||
69
- (opt.description && opt.description.toLowerCase().includes(search.toLowerCase()))
70
- );
71
-
72
- const selectedOption = options.find((opt) => opt.value === selectedValue);
73
-
74
- // Keep highlight index valid
75
- useEffect(() => {
76
- if (filteredOptions.length > 0) {
77
- setHighlightIndex((prev) => Math.min(prev, filteredOptions.length - 1));
78
- }
79
- }, [filteredOptions.length]);
80
-
81
- const handleSelect = (val: string) => {
82
- if (!isControlled) {
83
- setInternalVal(val);
84
- }
85
- onChange?.(val);
86
- setIsOpen(false);
87
- setSearch('');
88
- triggerRef.current?.focus();
89
- };
90
-
91
- const handleClear = (e: React.MouseEvent) => {
92
- e.stopPropagation();
93
- if (!isControlled) {
94
- setInternalVal('');
95
- }
96
- onChange?.('');
97
- };
98
-
99
- const handleKeyDown = (e: React.KeyboardEvent) => {
100
- if (!isOpen) {
101
- if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
102
- e.preventDefault();
103
- setIsOpen(true);
104
- }
105
- return;
106
- }
107
-
108
- if (e.key === 'ArrowDown') {
109
- e.preventDefault();
110
- let next = highlightIndex + 1;
111
- while (next < filteredOptions.length && filteredOptions[next]?.disabled) {
112
- next++;
113
- }
114
- if (next < filteredOptions.length) {
115
- setHighlightIndex(next);
116
- }
117
- } else if (e.key === 'ArrowUp') {
118
- e.preventDefault();
119
- let prev = highlightIndex - 1;
120
- while (prev >= 0 && filteredOptions[prev]?.disabled) {
121
- prev--;
122
- }
123
- if (prev >= 0) {
124
- setHighlightIndex(prev);
125
- }
126
- } else if (e.key === 'Home') {
127
- e.preventDefault();
128
- let first = 0;
129
- while (first < filteredOptions.length && filteredOptions[first]?.disabled) {
130
- first++;
131
- }
132
- setHighlightIndex(first);
133
- } else if (e.key === 'End') {
134
- e.preventDefault();
135
- let last = filteredOptions.length - 1;
136
- while (last >= 0 && filteredOptions[last]?.disabled) {
137
- last--;
138
- }
139
- setHighlightIndex(last);
140
- } else if (e.key === 'Enter') {
141
- e.preventDefault();
142
- const highlighted = filteredOptions[highlightIndex];
143
- if (highlighted && !highlighted.disabled) {
144
- handleSelect(highlighted.value);
145
- }
146
- } else if (e.key === 'Escape') {
147
- e.preventDefault();
148
- setIsOpen(false);
149
- triggerRef.current?.focus();
150
- }
151
- };
152
-
153
- const activeDescendantId =
154
- filteredOptions[highlightIndex]
155
- ? `combobox-opt-${baseId}-${filteredOptions[highlightIndex].value}`
156
- : undefined;
157
-
158
- return (
159
- <div ref={ref} className={cn('relative w-full', className)}>
160
- {name && <input type="hidden" name={name} value={selectedValue} />}
161
- <Popover
162
- isOpen={isOpen}
163
- onClose={() => setIsOpen(false)}
164
- trigger={
165
- <button
166
- ref={triggerRef}
167
- id={comboId}
168
- type="button"
169
- disabled={disabled}
170
- role="combobox"
171
- aria-expanded={isOpen}
172
- aria-haspopup="listbox"
173
- aria-controls={isOpen ? listboxId : undefined}
174
- aria-label={ariaLabel}
175
- onClick={() => !disabled && setIsOpen(!isOpen)}
176
- onKeyDown={handleKeyDown}
177
- className={cn(
178
- 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
179
- disabled
180
- ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
181
- : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
182
- )}
183
- >
184
- <div className="flex items-center gap-2.5 truncate">
185
- {selectedOption?.icon}
186
- <span className={selectedOption ? 'text-[var(--ui-text-primary)] font-medium' : 'text-[var(--ui-text-muted)]'}>
187
- {selectedOption ? selectedOption.label : placeholder}
188
- </span>
189
- </div>
190
-
191
- <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
192
- {clearable && selectedValue && !disabled && (
193
- <span
194
- onClick={handleClear}
195
- className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
196
- >
197
- <X className="w-3.5 h-3.5" />
198
- </span>
199
- )}
200
- <ChevronsUpDown className="w-4 h-4 opacity-50 shrink-0" />
201
- </div>
202
- </button>
203
- }
204
- >
205
- <div className="p-2 w-72 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-2">
206
- <div className="relative flex items-center">
207
- <Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
208
- <input
209
- ref={searchInputRef}
210
- type="text"
211
- autoFocus
212
- placeholder={searchPlaceholder}
213
- value={search}
214
- onChange={(e) => {
215
- setSearch(e.target.value);
216
- setHighlightIndex(0);
217
- }}
218
- onKeyDown={handleKeyDown}
219
- role="combobox"
220
- aria-autocomplete="list"
221
- aria-expanded="true"
222
- aria-controls={listboxId}
223
- aria-activedescendant={activeDescendantId}
224
- className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-secondary)] border border-[var(--ui-border-subtle)] focus:outline-none focus:border-[var(--ui-primary)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)]"
225
- />
226
- </div>
227
-
228
- <div id={listboxId} className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
229
- {filteredOptions.length === 0 ? (
230
- <div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">{emptyText}</div>
231
- ) : (
232
- filteredOptions.map((opt, idx) => {
233
- const isSelected = opt.value === selectedValue;
234
- const isHighlighted = idx === highlightIndex;
235
- const optId = `combobox-opt-${baseId}-${opt.value}`;
236
-
237
- return (
238
- <div
239
- key={opt.value}
240
- id={optId}
241
- role="option"
242
- aria-selected={isSelected}
243
- aria-disabled={opt.disabled}
244
- onClick={() => !opt.disabled && handleSelect(opt.value)}
245
- onMouseEnter={() => !opt.disabled && setHighlightIndex(idx)}
246
- className={cn(
247
- 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors select-none',
248
- opt.disabled && 'opacity-40 cursor-not-allowed pointer-events-none',
249
- isSelected
250
- ? 'bg-[var(--ui-primary)] text-white font-semibold shadow-xs'
251
- : isHighlighted
252
- ? 'bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
253
- : 'text-[var(--ui-text-primary)]'
254
- )}
255
- >
256
- <div className="flex items-center gap-2 truncate">
257
- {opt.icon}
258
- <div>
259
- <span className="block truncate">{opt.label}</span>
260
- {opt.description && (
261
- <span className={cn('text-[10px] block truncate', isSelected ? 'text-white/80' : 'text-[var(--ui-text-muted)]')}>
262
- {opt.description}
263
- </span>
264
- )}
265
- </div>
266
- </div>
267
-
268
- {isSelected && <Check className="w-3.5 h-3.5 shrink-0" />}
269
- </div>
270
- );
271
- })
272
- )}
273
- </div>
274
- </div>
275
- </Popover>
276
- </div>
277
- );
278
- }
279
- );
280
- Combobox.displayName = 'Combobox';
281
-
282
- /* -------------------------------------------------------------------------------------------------
283
- * MultiSelect Component
284
- * -----------------------------------------------------------------------------------------------*/
285
- export interface MultiSelectProps {
286
- options: ComboboxOption[];
287
- value?: string[];
288
- defaultValue?: string[];
289
- onChange?: (val: string[]) => void;
290
- placeholder?: string;
291
- searchPlaceholder?: string;
292
- maxCount?: number;
293
- disabled?: boolean;
294
- className?: string;
295
- }
296
-
297
- export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
298
- (
299
- {
300
- options,
301
- value: controlledVal,
302
- defaultValue = [],
303
- onChange,
304
- placeholder = 'Select items...',
305
- searchPlaceholder = 'Search options...',
306
- maxCount = 3,
307
- disabled = false,
308
- className,
309
- },
310
- ref
311
- ) => {
312
- const [internalVal, setInternalVal] = useState<string[]>(defaultValue);
313
- const isControlled = controlledVal !== undefined;
314
- const selectedValues = isControlled ? controlledVal : internalVal;
315
-
316
- const [search, setSearch] = useState('');
317
- const [isOpen, setIsOpen] = useState(false);
318
- const [highlightedIndex, setHighlightedIndex] = useState(0);
319
-
320
- const filteredOptions = options.filter(
321
- (opt) =>
322
- opt.label.toLowerCase().includes(search.toLowerCase()) ||
323
- opt.value.toLowerCase().includes(search.toLowerCase())
324
- );
325
-
326
- const toggleOption = (val: string) => {
327
- const opt = options.find((o) => o.value === val);
328
- if (opt?.disabled) return;
329
-
330
- const next = selectedValues.includes(val)
331
- ? selectedValues.filter((v) => v !== val)
332
- : [...selectedValues, val];
333
- if (!isControlled) {
334
- setInternalVal(next);
335
- }
336
- onChange?.(next);
337
- };
338
-
339
- const removeTag = (val: string, e?: React.MouseEvent) => {
340
- e?.stopPropagation?.();
341
- const next = selectedValues.filter((v) => v !== val);
342
- if (!isControlled) {
343
- setInternalVal(next);
344
- }
345
- onChange?.(next);
346
- };
347
-
348
- const clearAll = (e?: React.MouseEvent) => {
349
- e?.stopPropagation?.();
350
- if (!isControlled) {
351
- setInternalVal([]);
352
- }
353
- onChange?.([]);
354
- };
355
-
356
- const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
357
- if (filteredOptions.length === 0) return;
358
-
359
- if (e.key === 'ArrowDown') {
360
- e.preventDefault();
361
- setHighlightedIndex((prev) => {
362
- let next = (prev + 1) % filteredOptions.length;
363
- while (filteredOptions[next]?.disabled && next !== prev) {
364
- next = (next + 1) % filteredOptions.length;
365
- }
366
- return next;
367
- });
368
- } else if (e.key === 'ArrowUp') {
369
- e.preventDefault();
370
- setHighlightedIndex((prev) => {
371
- let next = (prev - 1 + filteredOptions.length) % filteredOptions.length;
372
- while (filteredOptions[next]?.disabled && next !== prev) {
373
- next = (next - 1 + filteredOptions.length) % filteredOptions.length;
374
- }
375
- return next;
376
- });
377
- } else if (e.key === 'Enter' || e.key === ' ') {
378
- e.preventDefault();
379
- const current = filteredOptions[highlightedIndex];
380
- if (current && !current.disabled) {
381
- toggleOption(current.value);
382
- }
383
- } else if (e.key === 'Escape') {
384
- e.preventDefault();
385
- setIsOpen(false);
386
- }
387
- };
388
-
389
- return (
390
- <div ref={ref} className={cn('relative w-full', className)}>
391
- <Popover
392
- isOpen={isOpen}
393
- onClose={() => setIsOpen(false)}
394
- trigger={
395
- <div
396
- tabIndex={disabled ? -1 : 0}
397
- role="combobox"
398
- aria-expanded={isOpen}
399
- aria-haspopup="listbox"
400
- onClick={() => !disabled && setIsOpen(!isOpen)}
401
- onKeyDown={(e) => {
402
- if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !disabled) {
403
- e.preventDefault();
404
- setIsOpen(true);
405
- }
406
- }}
407
- className={cn(
408
- 'min-h-10 py-1.5 px-3 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] flex flex-wrap items-center justify-between gap-1.5 text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
409
- disabled
410
- ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
411
- : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
412
- )}
413
- >
414
- <div className="flex flex-wrap items-center gap-1.5 flex-1">
415
- {selectedValues.length === 0 ? (
416
- <span className="text-[var(--ui-text-muted)] text-sm">{placeholder}</span>
417
- ) : (
418
- <>
419
- {selectedValues.slice(0, maxCount).map((val) => {
420
- const opt = options.find((o) => o.value === val);
421
- return (
422
- <Chip
423
- key={val}
424
- size="sm"
425
- variant="primary"
426
- onRemove={() => removeTag(val, {} as any)}
427
- >
428
- {opt?.label || val}
429
- </Chip>
430
- );
431
- })}
432
- {selectedValues.length > maxCount && (
433
- <Chip size="sm" variant="secondary">
434
- +{selectedValues.length - maxCount} more
435
- </Chip>
436
- )}
437
- </>
438
- )}
439
- </div>
440
-
441
- <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
442
- {selectedValues.length > 0 && !disabled && (
443
- <span
444
- onClick={clearAll}
445
- className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
446
- >
447
- <X className="w-3.5 h-3.5" />
448
- </span>
449
- )}
450
- <ChevronsUpDown className="w-4 h-4 opacity-50 shrink-0" />
451
- </div>
452
- </div>
453
- }
454
- >
455
- <div className="p-2 w-76 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-2">
456
- <div className="relative flex items-center">
457
- <Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
458
- <input
459
- type="text"
460
- autoFocus
461
- placeholder={searchPlaceholder}
462
- value={search}
463
- onChange={(e) => {
464
- setSearch(e.target.value);
465
- setHighlightedIndex(0);
466
- }}
467
- onKeyDown={handleSearchKeyDown}
468
- className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-secondary)] border border-[var(--ui-border-subtle)] focus:outline-none focus:border-[var(--ui-primary)] text-[var(--ui-text-primary)]"
469
- />
470
- </div>
471
-
472
- <div className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
473
- {filteredOptions.length === 0 ? (
474
- <div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">No options found.</div>
475
- ) : (
476
- filteredOptions.map((opt, index) => {
477
- const isChecked = selectedValues.includes(opt.value);
478
- const isHighlighted = highlightedIndex === index;
479
-
480
- return (
481
- <div
482
- key={opt.value}
483
- role="option"
484
- aria-selected={isChecked}
485
- aria-disabled={opt.disabled}
486
- onClick={() => !opt.disabled && toggleOption(opt.value)}
487
- className={cn(
488
- 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition-colors select-none',
489
- opt.disabled
490
- ? 'opacity-40 cursor-not-allowed text-[var(--ui-text-muted)]'
491
- : 'cursor-pointer',
492
- isChecked && 'text-[var(--ui-primary)] font-semibold',
493
- isHighlighted && !opt.disabled && 'bg-[var(--ui-primary)]/15'
494
- )}
495
- >
496
- <div className="flex items-center gap-2 truncate">
497
- {opt.icon}
498
- <span>{opt.label}</span>
499
- </div>
500
- {isChecked && <Check className="w-3.5 h-3.5 text-[var(--ui-primary)] shrink-0" />}
501
- </div>
502
- );
503
- })
504
- )}
505
- </div>
506
- </div>
507
- </Popover>
508
- </div>
509
- );
510
- }
511
- );
512
- MultiSelect.displayName = 'MultiSelect';
1
+ import React, { forwardRef, useState, useRef, useEffect, useId } from 'react';
2
+ import { Check, ChevronsUpDown, Search, X } from 'lucide-react';
3
+ import { cn } from '../utils/cn';
4
+ import { Popover } from '../overlays/Popover';
5
+ import { Chip } from '../data-display/Chip';
6
+
7
+ export interface ComboboxOption {
8
+ value: string;
9
+ label: string;
10
+ description?: string;
11
+ icon?: React.ReactNode;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ export interface ComboboxProps {
16
+ options: ComboboxOption[];
17
+ value?: string;
18
+ defaultValue?: string;
19
+ onChange?: (val: string) => void;
20
+ placeholder?: string;
21
+ searchPlaceholder?: string;
22
+ emptyText?: string;
23
+ disabled?: boolean;
24
+ clearable?: boolean;
25
+ className?: string;
26
+ name?: string;
27
+ id?: string;
28
+ 'aria-label'?: string;
29
+ }
30
+
31
+ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
32
+ (
33
+ {
34
+ options,
35
+ value: controlledVal,
36
+ defaultValue = '',
37
+ onChange,
38
+ placeholder = 'Select option...',
39
+ searchPlaceholder = 'Search items...',
40
+ emptyText = 'No items found.',
41
+ disabled = false,
42
+ clearable = true,
43
+ className,
44
+ name,
45
+ id: customId,
46
+ 'aria-label': ariaLabel = 'Select option',
47
+ },
48
+ ref
49
+ ) => {
50
+ const baseId = useId();
51
+ const comboId = customId || `combobox-${baseId}`;
52
+ const listboxId = `listbox-${baseId}`;
53
+
54
+ const [internalVal, setInternalVal] = useState(defaultValue);
55
+ const isControlled = controlledVal !== undefined;
56
+ const selectedValue = isControlled ? controlledVal : internalVal;
57
+
58
+ const [search, setSearch] = useState('');
59
+ const [isOpen, setIsOpen] = useState(false);
60
+ const [highlightIndex, setHighlightIndex] = useState(0);
61
+
62
+ const searchInputRef = useRef<HTMLInputElement>(null);
63
+ const triggerRef = useRef<HTMLButtonElement>(null);
64
+
65
+ const filteredOptions = options.filter(
66
+ (opt) =>
67
+ opt.label.toLowerCase().includes(search.toLowerCase()) ||
68
+ opt.value.toLowerCase().includes(search.toLowerCase()) ||
69
+ (opt.description && opt.description.toLowerCase().includes(search.toLowerCase()))
70
+ );
71
+
72
+ const selectedOption = options.find((opt) => opt.value === selectedValue);
73
+
74
+ // Keep highlight index valid
75
+ useEffect(() => {
76
+ if (filteredOptions.length > 0) {
77
+ setHighlightIndex((prev) => Math.min(prev, filteredOptions.length - 1));
78
+ }
79
+ }, [filteredOptions.length]);
80
+
81
+ const handleSelect = (val: string) => {
82
+ if (!isControlled) {
83
+ setInternalVal(val);
84
+ }
85
+ onChange?.(val);
86
+ setIsOpen(false);
87
+ setSearch('');
88
+ triggerRef.current?.focus();
89
+ };
90
+
91
+ const handleClear = (e: React.MouseEvent) => {
92
+ e.stopPropagation();
93
+ if (!isControlled) {
94
+ setInternalVal('');
95
+ }
96
+ onChange?.('');
97
+ };
98
+
99
+ const handleKeyDown = (e: React.KeyboardEvent) => {
100
+ if (!isOpen) {
101
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
102
+ e.preventDefault();
103
+ setIsOpen(true);
104
+ }
105
+ return;
106
+ }
107
+
108
+ if (e.key === 'ArrowDown') {
109
+ e.preventDefault();
110
+ let next = highlightIndex + 1;
111
+ while (next < filteredOptions.length && filteredOptions[next]?.disabled) {
112
+ next++;
113
+ }
114
+ if (next < filteredOptions.length) {
115
+ setHighlightIndex(next);
116
+ }
117
+ } else if (e.key === 'ArrowUp') {
118
+ e.preventDefault();
119
+ let prev = highlightIndex - 1;
120
+ while (prev >= 0 && filteredOptions[prev]?.disabled) {
121
+ prev--;
122
+ }
123
+ if (prev >= 0) {
124
+ setHighlightIndex(prev);
125
+ }
126
+ } else if (e.key === 'Home') {
127
+ e.preventDefault();
128
+ let first = 0;
129
+ while (first < filteredOptions.length && filteredOptions[first]?.disabled) {
130
+ first++;
131
+ }
132
+ setHighlightIndex(first);
133
+ } else if (e.key === 'End') {
134
+ e.preventDefault();
135
+ let last = filteredOptions.length - 1;
136
+ while (last >= 0 && filteredOptions[last]?.disabled) {
137
+ last--;
138
+ }
139
+ setHighlightIndex(last);
140
+ } else if (e.key === 'Enter') {
141
+ e.preventDefault();
142
+ const highlighted = filteredOptions[highlightIndex];
143
+ if (highlighted && !highlighted.disabled) {
144
+ handleSelect(highlighted.value);
145
+ }
146
+ } else if (e.key === 'Escape') {
147
+ e.preventDefault();
148
+ setIsOpen(false);
149
+ triggerRef.current?.focus();
150
+ }
151
+ };
152
+
153
+ const activeDescendantId =
154
+ filteredOptions[highlightIndex]
155
+ ? `combobox-opt-${baseId}-${filteredOptions[highlightIndex].value}`
156
+ : undefined;
157
+
158
+ return (
159
+ <div ref={ref} className={cn('relative w-full', className)}>
160
+ {name && <input type="hidden" name={name} value={selectedValue} />}
161
+ <Popover
162
+ isOpen={isOpen}
163
+ onClose={() => setIsOpen(false)}
164
+ trigger={
165
+ <button
166
+ ref={triggerRef}
167
+ id={comboId}
168
+ type="button"
169
+ disabled={disabled}
170
+ role="combobox"
171
+ aria-expanded={isOpen}
172
+ aria-haspopup="listbox"
173
+ aria-controls={isOpen ? listboxId : undefined}
174
+ aria-label={ariaLabel}
175
+ onClick={() => !disabled && setIsOpen(!isOpen)}
176
+ onKeyDown={handleKeyDown}
177
+ className={cn(
178
+ 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
179
+ disabled
180
+ ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
181
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
182
+ )}
183
+ >
184
+ <div className="flex items-center gap-2.5 truncate">
185
+ {selectedOption?.icon}
186
+ <span className={selectedOption ? 'text-[var(--ui-text-primary)] font-medium' : 'text-[var(--ui-text-muted)]'}>
187
+ {selectedOption ? selectedOption.label : placeholder}
188
+ </span>
189
+ </div>
190
+
191
+ <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
192
+ {clearable && selectedValue && !disabled && (
193
+ <span
194
+ onClick={handleClear}
195
+ className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
196
+ >
197
+ <X className="w-3.5 h-3.5" />
198
+ </span>
199
+ )}
200
+ <ChevronsUpDown className="w-4 h-4 opacity-50 shrink-0" />
201
+ </div>
202
+ </button>
203
+ }
204
+ >
205
+ <div className="p-2 w-72 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-2">
206
+ <div className="relative flex items-center">
207
+ <Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
208
+ <input
209
+ ref={searchInputRef}
210
+ type="text"
211
+ autoFocus
212
+ placeholder={searchPlaceholder}
213
+ value={search}
214
+ onChange={(e) => {
215
+ setSearch(e.target.value);
216
+ setHighlightIndex(0);
217
+ }}
218
+ onKeyDown={handleKeyDown}
219
+ role="combobox"
220
+ aria-autocomplete="list"
221
+ aria-expanded="true"
222
+ aria-controls={listboxId}
223
+ aria-activedescendant={activeDescendantId}
224
+ className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-secondary)] border border-[var(--ui-border-subtle)] focus:outline-none focus:border-[var(--ui-primary)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)]"
225
+ />
226
+ </div>
227
+
228
+ <div id={listboxId} className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
229
+ {filteredOptions.length === 0 ? (
230
+ <div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">{emptyText}</div>
231
+ ) : (
232
+ filteredOptions.map((opt, idx) => {
233
+ const isSelected = opt.value === selectedValue;
234
+ const isHighlighted = idx === highlightIndex;
235
+ const optId = `combobox-opt-${baseId}-${opt.value}`;
236
+
237
+ return (
238
+ <div
239
+ key={opt.value}
240
+ id={optId}
241
+ role="option"
242
+ aria-selected={isSelected}
243
+ aria-disabled={opt.disabled}
244
+ onClick={() => !opt.disabled && handleSelect(opt.value)}
245
+ onMouseEnter={() => !opt.disabled && setHighlightIndex(idx)}
246
+ className={cn(
247
+ 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors select-none',
248
+ opt.disabled && 'opacity-40 cursor-not-allowed pointer-events-none',
249
+ isSelected
250
+ ? 'bg-[var(--ui-primary)] text-white font-semibold shadow-xs'
251
+ : isHighlighted
252
+ ? 'bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
253
+ : 'text-[var(--ui-text-primary)]'
254
+ )}
255
+ >
256
+ <div className="flex items-center gap-2 truncate">
257
+ {opt.icon}
258
+ <div>
259
+ <span className="block truncate">{opt.label}</span>
260
+ {opt.description && (
261
+ <span className={cn('text-[10px] block truncate', isSelected ? 'text-white/80' : 'text-[var(--ui-text-muted)]')}>
262
+ {opt.description}
263
+ </span>
264
+ )}
265
+ </div>
266
+ </div>
267
+
268
+ {isSelected && <Check className="w-3.5 h-3.5 shrink-0" />}
269
+ </div>
270
+ );
271
+ })
272
+ )}
273
+ </div>
274
+ </div>
275
+ </Popover>
276
+ </div>
277
+ );
278
+ }
279
+ );
280
+ Combobox.displayName = 'Combobox';
281
+
282
+ /* -------------------------------------------------------------------------------------------------
283
+ * MultiSelect Component
284
+ * -----------------------------------------------------------------------------------------------*/
285
+ export interface MultiSelectProps {
286
+ options: ComboboxOption[];
287
+ value?: string[];
288
+ defaultValue?: string[];
289
+ onChange?: (val: string[]) => void;
290
+ placeholder?: string;
291
+ searchPlaceholder?: string;
292
+ maxCount?: number;
293
+ disabled?: boolean;
294
+ className?: string;
295
+ }
296
+
297
+ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
298
+ (
299
+ {
300
+ options,
301
+ value: controlledVal,
302
+ defaultValue = [],
303
+ onChange,
304
+ placeholder = 'Select items...',
305
+ searchPlaceholder = 'Search options...',
306
+ maxCount = 3,
307
+ disabled = false,
308
+ className,
309
+ },
310
+ ref
311
+ ) => {
312
+ const [internalVal, setInternalVal] = useState<string[]>(defaultValue);
313
+ const isControlled = controlledVal !== undefined;
314
+ const selectedValues = isControlled ? controlledVal : internalVal;
315
+
316
+ const [search, setSearch] = useState('');
317
+ const [isOpen, setIsOpen] = useState(false);
318
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
319
+
320
+ const filteredOptions = options.filter(
321
+ (opt) =>
322
+ opt.label.toLowerCase().includes(search.toLowerCase()) ||
323
+ opt.value.toLowerCase().includes(search.toLowerCase())
324
+ );
325
+
326
+ const toggleOption = (val: string) => {
327
+ const opt = options.find((o) => o.value === val);
328
+ if (opt?.disabled) return;
329
+
330
+ const next = selectedValues.includes(val)
331
+ ? selectedValues.filter((v) => v !== val)
332
+ : [...selectedValues, val];
333
+ if (!isControlled) {
334
+ setInternalVal(next);
335
+ }
336
+ onChange?.(next);
337
+ };
338
+
339
+ const removeTag = (val: string, e?: React.MouseEvent) => {
340
+ e?.stopPropagation?.();
341
+ const next = selectedValues.filter((v) => v !== val);
342
+ if (!isControlled) {
343
+ setInternalVal(next);
344
+ }
345
+ onChange?.(next);
346
+ };
347
+
348
+ const clearAll = (e?: React.MouseEvent) => {
349
+ e?.stopPropagation?.();
350
+ if (!isControlled) {
351
+ setInternalVal([]);
352
+ }
353
+ onChange?.([]);
354
+ };
355
+
356
+ const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
357
+ if (filteredOptions.length === 0) return;
358
+
359
+ if (e.key === 'ArrowDown') {
360
+ e.preventDefault();
361
+ setHighlightedIndex((prev) => {
362
+ let next = (prev + 1) % filteredOptions.length;
363
+ while (filteredOptions[next]?.disabled && next !== prev) {
364
+ next = (next + 1) % filteredOptions.length;
365
+ }
366
+ return next;
367
+ });
368
+ } else if (e.key === 'ArrowUp') {
369
+ e.preventDefault();
370
+ setHighlightedIndex((prev) => {
371
+ let next = (prev - 1 + filteredOptions.length) % filteredOptions.length;
372
+ while (filteredOptions[next]?.disabled && next !== prev) {
373
+ next = (next - 1 + filteredOptions.length) % filteredOptions.length;
374
+ }
375
+ return next;
376
+ });
377
+ } else if (e.key === 'Enter' || e.key === ' ') {
378
+ e.preventDefault();
379
+ const current = filteredOptions[highlightedIndex];
380
+ if (current && !current.disabled) {
381
+ toggleOption(current.value);
382
+ }
383
+ } else if (e.key === 'Escape') {
384
+ e.preventDefault();
385
+ setIsOpen(false);
386
+ }
387
+ };
388
+
389
+ return (
390
+ <div ref={ref} className={cn('relative w-full', className)}>
391
+ <Popover
392
+ isOpen={isOpen}
393
+ onClose={() => setIsOpen(false)}
394
+ trigger={
395
+ <div
396
+ tabIndex={disabled ? -1 : 0}
397
+ role="combobox"
398
+ aria-expanded={isOpen}
399
+ aria-haspopup="listbox"
400
+ onClick={() => !disabled && setIsOpen(!isOpen)}
401
+ onKeyDown={(e) => {
402
+ if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !disabled) {
403
+ e.preventDefault();
404
+ setIsOpen(true);
405
+ }
406
+ }}
407
+ className={cn(
408
+ 'min-h-10 py-1.5 px-3 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] flex flex-wrap items-center justify-between gap-1.5 text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
409
+ disabled
410
+ ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
411
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
412
+ )}
413
+ >
414
+ <div className="flex flex-wrap items-center gap-1.5 flex-1">
415
+ {selectedValues.length === 0 ? (
416
+ <span className="text-[var(--ui-text-muted)] text-sm">{placeholder}</span>
417
+ ) : (
418
+ <>
419
+ {selectedValues.slice(0, maxCount).map((val) => {
420
+ const opt = options.find((o) => o.value === val);
421
+ return (
422
+ <Chip
423
+ key={val}
424
+ size="sm"
425
+ variant="primary"
426
+ onRemove={() => removeTag(val, {} as any)}
427
+ >
428
+ {opt?.label || val}
429
+ </Chip>
430
+ );
431
+ })}
432
+ {selectedValues.length > maxCount && (
433
+ <Chip size="sm" variant="secondary">
434
+ +{selectedValues.length - maxCount} more
435
+ </Chip>
436
+ )}
437
+ </>
438
+ )}
439
+ </div>
440
+
441
+ <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
442
+ {selectedValues.length > 0 && !disabled && (
443
+ <span
444
+ onClick={clearAll}
445
+ className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
446
+ >
447
+ <X className="w-3.5 h-3.5" />
448
+ </span>
449
+ )}
450
+ <ChevronsUpDown className="w-4 h-4 opacity-50 shrink-0" />
451
+ </div>
452
+ </div>
453
+ }
454
+ >
455
+ <div className="p-2 w-76 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-2">
456
+ <div className="relative flex items-center">
457
+ <Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
458
+ <input
459
+ type="text"
460
+ autoFocus
461
+ placeholder={searchPlaceholder}
462
+ value={search}
463
+ onChange={(e) => {
464
+ setSearch(e.target.value);
465
+ setHighlightedIndex(0);
466
+ }}
467
+ onKeyDown={handleSearchKeyDown}
468
+ className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-secondary)] border border-[var(--ui-border-subtle)] focus:outline-none focus:border-[var(--ui-primary)] text-[var(--ui-text-primary)]"
469
+ />
470
+ </div>
471
+
472
+ <div className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
473
+ {filteredOptions.length === 0 ? (
474
+ <div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">No options found.</div>
475
+ ) : (
476
+ filteredOptions.map((opt, index) => {
477
+ const isChecked = selectedValues.includes(opt.value);
478
+ const isHighlighted = highlightedIndex === index;
479
+
480
+ return (
481
+ <div
482
+ key={opt.value}
483
+ role="option"
484
+ aria-selected={isChecked}
485
+ aria-disabled={opt.disabled}
486
+ onClick={() => !opt.disabled && toggleOption(opt.value)}
487
+ className={cn(
488
+ 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition-colors select-none',
489
+ opt.disabled
490
+ ? 'opacity-40 cursor-not-allowed text-[var(--ui-text-muted)]'
491
+ : 'cursor-pointer',
492
+ isChecked && 'text-[var(--ui-primary)] font-semibold',
493
+ isHighlighted && !opt.disabled && 'bg-[var(--ui-primary)]/15'
494
+ )}
495
+ >
496
+ <div className="flex items-center gap-2 truncate">
497
+ {opt.icon}
498
+ <span>{opt.label}</span>
499
+ </div>
500
+ {isChecked && <Check className="w-3.5 h-3.5 text-[var(--ui-primary)] shrink-0" />}
501
+ </div>
502
+ );
503
+ })
504
+ )}
505
+ </div>
506
+ </div>
507
+ </Popover>
508
+ </div>
509
+ );
510
+ }
511
+ );
512
+ MultiSelect.displayName = 'MultiSelect';