@thangnqdev/ui 0.1.2 → 0.2.1

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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/bento/styles.css +2 -46
  7. package/dist/buttons/index.cjs +111 -18
  8. package/dist/buttons/index.cjs.map +1 -1
  9. package/dist/buttons/index.d.cts +1 -1
  10. package/dist/buttons/index.d.ts +1 -1
  11. package/dist/buttons/index.js +3 -3
  12. package/dist/buttons/styles.css +2 -6
  13. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  14. package/dist/chunk-2YJBW4E2.js.map +1 -0
  15. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  16. package/dist/chunk-4CRFNALF.js.map +1 -0
  17. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  18. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  19. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  20. package/dist/chunk-77KTYOMQ.js.map +1 -0
  21. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  22. package/dist/chunk-ADO3SVUA.js.map +1 -0
  23. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  24. package/dist/chunk-D7FB3TCA.js.map +1 -0
  25. package/dist/chunk-DSEL7ELZ.js +314 -0
  26. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  27. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  28. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  29. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  30. package/dist/chunk-GXWB6BSY.js.map +1 -0
  31. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  32. package/dist/chunk-I2FQBMVW.js.map +1 -0
  33. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  34. package/dist/chunk-I5O2WCGF.js.map +1 -0
  35. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  36. package/dist/chunk-ME4XILG5.js.map +1 -0
  37. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  38. package/dist/chunk-PC5SZFZC.js.map +1 -0
  39. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  40. package/dist/chunk-S6XJMYME.js.map +1 -0
  41. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  42. package/dist/chunk-V52JOSDF.js.map +1 -0
  43. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  44. package/dist/chunk-W5WNWTQC.js.map +1 -0
  45. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  46. package/dist/chunk-YJ64RI7J.js.map +1 -0
  47. package/dist/chunk-YOCGBORV.js +155 -0
  48. package/dist/chunk-YOCGBORV.js.map +1 -0
  49. package/dist/chunk-ZEDXARXW.js +96 -0
  50. package/dist/chunk-ZEDXARXW.js.map +1 -0
  51. package/dist/components.css +2 -0
  52. package/dist/core/index.cjs +33 -24
  53. package/dist/core/index.cjs.map +1 -1
  54. package/dist/core/index.js +2 -2
  55. package/dist/core/styles.css +2 -134
  56. package/dist/data-display/index.cjs +770 -247
  57. package/dist/data-display/index.cjs.map +1 -1
  58. package/dist/data-display/index.d.cts +1 -1
  59. package/dist/data-display/index.d.ts +1 -1
  60. package/dist/data-display/index.js +5 -4
  61. package/dist/data-display/styles.css +2 -4
  62. package/dist/feedback/index.cjs.map +1 -1
  63. package/dist/feedback/index.js +2 -2
  64. package/dist/feedback/styles.css +2 -4
  65. package/dist/forms/index.cjs +1211 -459
  66. package/dist/forms/index.cjs.map +1 -1
  67. package/dist/forms/index.d.cts +1 -1
  68. package/dist/forms/index.d.ts +1 -1
  69. package/dist/forms/index.js +8 -8
  70. package/dist/forms/styles.css +2 -6
  71. package/dist/glass/index.cjs.map +1 -1
  72. package/dist/glass/index.js +2 -2
  73. package/dist/glass/styles.css +2 -81
  74. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  75. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  76. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  77. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  78. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  79. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  80. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  81. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  82. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  83. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  84. package/dist/index.cjs +2481 -917
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +5 -5
  87. package/dist/index.d.ts +5 -5
  88. package/dist/index.js +19 -19
  89. package/dist/layout/index.cjs.map +1 -1
  90. package/dist/layout/index.js +2 -2
  91. package/dist/layout/styles.css +2 -8
  92. package/dist/navigation/index.cjs +288 -48
  93. package/dist/navigation/index.cjs.map +1 -1
  94. package/dist/navigation/index.d.cts +1 -1
  95. package/dist/navigation/index.d.ts +1 -1
  96. package/dist/navigation/index.js +6 -6
  97. package/dist/navigation/styles.css +2 -4
  98. package/dist/overlays/index.cjs +566 -229
  99. package/dist/overlays/index.cjs.map +1 -1
  100. package/dist/overlays/index.d.cts +1 -1
  101. package/dist/overlays/index.d.ts +1 -1
  102. package/dist/overlays/index.js +7 -7
  103. package/dist/overlays/styles.css +2 -8
  104. package/dist/reset.css +45 -0
  105. package/dist/styles.css +1 -1
  106. package/dist/tokens.css +11 -0
  107. package/dist/typography/index.cjs.map +1 -1
  108. package/dist/typography/index.js +4 -4
  109. package/dist/typography/styles.css +2 -5
  110. package/llms.txt +211 -113
  111. package/package.json +167 -116
  112. package/scripts/postinstall.js +125 -0
  113. package/src/bento/BentoCard.tsx +140 -140
  114. package/src/bento/BentoGrid.tsx +50 -50
  115. package/src/bento/index.ts +2 -2
  116. package/src/bento/tokens.css +46 -46
  117. package/src/buttons/Button.tsx +104 -104
  118. package/src/buttons/ButtonGroup.tsx +43 -43
  119. package/src/buttons/IconButton.tsx +79 -79
  120. package/src/buttons/SplitButton.tsx +186 -86
  121. package/src/buttons/ToggleButton.tsx +157 -157
  122. package/src/buttons/index.ts +5 -5
  123. package/src/buttons/tokens.css +6 -6
  124. package/src/core/ThemeProvider.tsx +135 -123
  125. package/src/core/index.ts +2 -2
  126. package/src/core/tokens.css +134 -134
  127. package/src/data-display/Accordion.tsx +147 -147
  128. package/src/data-display/Avatar.tsx +134 -134
  129. package/src/data-display/Card.tsx +112 -112
  130. package/src/data-display/Chip.tsx +74 -74
  131. package/src/data-display/DataTablePro.tsx +299 -121
  132. package/src/data-display/StatCard.tsx +60 -60
  133. package/src/data-display/Table.tsx +74 -74
  134. package/src/data-display/Timeline.tsx +62 -62
  135. package/src/data-display/TreeView.tsx +243 -104
  136. package/src/data-display/tokens.css +4 -4
  137. package/src/feedback/Alert.tsx +80 -80
  138. package/src/feedback/EmptyState.tsx +45 -45
  139. package/src/feedback/Skeleton.tsx +66 -66
  140. package/src/feedback/Spinner.tsx +65 -65
  141. package/src/feedback/index.ts +5 -5
  142. package/src/feedback/tokens.css +4 -4
  143. package/src/forms/Checkbox.tsx +149 -69
  144. package/src/forms/CheckboxGroup.tsx +76 -29
  145. package/src/forms/Combobox.tsx +172 -28
  146. package/src/forms/DatePicker.tsx +385 -81
  147. package/src/forms/FileUpload.tsx +51 -7
  148. package/src/forms/FormField.tsx +115 -115
  149. package/src/forms/Input.tsx +151 -151
  150. package/src/forms/NumberInput.tsx +34 -20
  151. package/src/forms/PasswordInput.tsx +35 -35
  152. package/src/forms/PinInput.tsx +139 -125
  153. package/src/forms/Radio.tsx +142 -142
  154. package/src/forms/RangeSlider.tsx +237 -111
  155. package/src/forms/SearchInput.tsx +34 -34
  156. package/src/forms/Select.tsx +103 -103
  157. package/src/forms/Slider.tsx +85 -85
  158. package/src/forms/Textarea.tsx +114 -114
  159. package/src/forms/tokens.css +6 -6
  160. package/src/glass/GlassButton.tsx +59 -59
  161. package/src/glass/GlassCard.tsx +71 -71
  162. package/src/glass/index.ts +3 -3
  163. package/src/glass/tokens.css +81 -81
  164. package/src/index.ts +26 -26
  165. package/src/layout/AspectRatio.tsx +39 -39
  166. package/src/layout/Container.tsx +53 -53
  167. package/src/layout/Divider.tsx +61 -61
  168. package/src/layout/Flex.tsx +79 -79
  169. package/src/layout/Grid.tsx +124 -124
  170. package/src/layout/PageLayout.tsx +59 -59
  171. package/src/layout/Section.tsx +47 -47
  172. package/src/layout/Spacer.tsx +30 -30
  173. package/src/layout/Stack.tsx +88 -88
  174. package/src/layout/tokens.css +8 -8
  175. package/src/navigation/Breadcrumb.tsx +73 -73
  176. package/src/navigation/CommandPalette.tsx +142 -142
  177. package/src/navigation/DropdownMenu.tsx +244 -160
  178. package/src/navigation/Pagination.tsx +114 -114
  179. package/src/navigation/Stepper.tsx +93 -93
  180. package/src/navigation/Tabs.tsx +265 -133
  181. package/src/navigation/index.ts +6 -6
  182. package/src/navigation/tokens.css +4 -4
  183. package/src/overlays/AlertDialog.tsx +63 -54
  184. package/src/overlays/BottomSheet.tsx +30 -10
  185. package/src/overlays/ContextMenu.tsx +51 -9
  186. package/src/overlays/Drawer.tsx +160 -145
  187. package/src/overlays/FocusTrap.tsx +122 -49
  188. package/src/overlays/Modal.tsx +296 -219
  189. package/src/overlays/Popover.tsx +55 -25
  190. package/src/overlays/Portal.tsx +23 -23
  191. package/src/overlays/Toast.tsx +175 -137
  192. package/src/overlays/Tooltip.tsx +125 -100
  193. package/src/overlays/tokens.css +8 -8
  194. package/src/registry/components.ts +1368 -0
  195. package/src/styles/components.css +39 -0
  196. package/src/styles/index.css +2 -93
  197. package/src/styles/reset.css +45 -0
  198. package/src/styles/tokens.css +11 -2
  199. package/src/typography/Blockquote.tsx +32 -32
  200. package/src/typography/Code.tsx +89 -89
  201. package/src/typography/Heading.tsx +56 -56
  202. package/src/typography/Kbd.tsx +25 -25
  203. package/src/typography/Label.tsx +47 -47
  204. package/src/typography/Link.tsx +56 -56
  205. package/src/typography/Text.tsx +62 -62
  206. package/src/typography/index.ts +7 -7
  207. package/src/typography/tokens.css +5 -5
  208. package/src/utils/cn.ts +6 -6
  209. package/src/utils/scroll-lock.ts +19 -0
  210. package/src/utils/useControllableState.ts +54 -0
  211. package/ui-contract.json +192 -35
  212. package/dist/chunk-2OXZK6IP.js.map +0 -1
  213. package/dist/chunk-4S6L5V7X.js.map +0 -1
  214. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  215. package/dist/chunk-66VWUJEI.js.map +0 -1
  216. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  217. package/dist/chunk-ATYGDTMK.js.map +0 -1
  218. package/dist/chunk-C4SUXMI3.js.map +0 -1
  219. package/dist/chunk-EU2LPUYP.js.map +0 -1
  220. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  221. package/dist/chunk-GXI2H5XA.js.map +0 -1
  222. package/dist/chunk-H3LCNPMC.js +0 -101
  223. package/dist/chunk-H3LCNPMC.js.map +0 -1
  224. package/dist/chunk-IABPUCAQ.js.map +0 -1
  225. package/dist/chunk-KJRBRTQE.js.map +0 -1
  226. package/dist/chunk-LBUBXUYQ.js +0 -40
  227. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  228. package/dist/chunk-LWFRUJRU.js.map +0 -1
  229. package/dist/chunk-NFVONJUK.js.map +0 -1
  230. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  231. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  232. package/dist/chunk-X76KBH4Y.js +0 -71
  233. package/dist/chunk-X76KBH4Y.js.map +0 -1
@@ -23,6 +23,9 @@ export interface ComboboxProps {
23
23
  disabled?: boolean;
24
24
  clearable?: boolean;
25
25
  className?: string;
26
+ name?: string;
27
+ id?: string;
28
+ 'aria-label'?: string;
26
29
  }
27
30
 
28
31
  export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
@@ -38,16 +41,27 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
38
41
  disabled = false,
39
42
  clearable = true,
40
43
  className,
44
+ name,
45
+ id: customId,
46
+ 'aria-label': ariaLabel = 'Select option',
41
47
  },
42
48
  ref
43
49
  ) => {
50
+ const baseId = useId();
51
+ const comboId = customId || `combobox-${baseId}`;
52
+ const listboxId = `listbox-${baseId}`;
53
+
44
54
  const [internalVal, setInternalVal] = useState(defaultValue);
45
- const selectedValue = controlledVal !== undefined ? controlledVal : internalVal;
55
+ const isControlled = controlledVal !== undefined;
56
+ const selectedValue = isControlled ? controlledVal : internalVal;
46
57
 
47
58
  const [search, setSearch] = useState('');
48
59
  const [isOpen, setIsOpen] = useState(false);
49
60
  const [highlightIndex, setHighlightIndex] = useState(0);
50
61
 
62
+ const searchInputRef = useRef<HTMLInputElement>(null);
63
+ const triggerRef = useRef<HTMLButtonElement>(null);
64
+
51
65
  const filteredOptions = options.filter(
52
66
  (opt) =>
53
67
  opt.label.toLowerCase().includes(search.toLowerCase()) ||
@@ -57,22 +71,35 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
57
71
 
58
72
  const selectedOption = options.find((opt) => opt.value === selectedValue);
59
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
+
60
81
  const handleSelect = (val: string) => {
61
- setInternalVal(val);
82
+ if (!isControlled) {
83
+ setInternalVal(val);
84
+ }
62
85
  onChange?.(val);
63
86
  setIsOpen(false);
64
87
  setSearch('');
88
+ triggerRef.current?.focus();
65
89
  };
66
90
 
67
91
  const handleClear = (e: React.MouseEvent) => {
68
92
  e.stopPropagation();
69
- setInternalVal('');
93
+ if (!isControlled) {
94
+ setInternalVal('');
95
+ }
70
96
  onChange?.('');
71
97
  };
72
98
 
73
99
  const handleKeyDown = (e: React.KeyboardEvent) => {
74
100
  if (!isOpen) {
75
- if (e.key === 'ArrowDown' || e.key === 'Enter') {
101
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
102
+ e.preventDefault();
76
103
  setIsOpen(true);
77
104
  }
78
105
  return;
@@ -80,36 +107,78 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
80
107
 
81
108
  if (e.key === 'ArrowDown') {
82
109
  e.preventDefault();
83
- setHighlightIndex((prev) => (prev + 1) % Math.max(1, filteredOptions.length));
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
+ }
84
117
  } else if (e.key === 'ArrowUp') {
85
118
  e.preventDefault();
86
- setHighlightIndex((prev) => (prev - 1 + filteredOptions.length) % Math.max(1, filteredOptions.length));
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);
87
140
  } else if (e.key === 'Enter') {
88
141
  e.preventDefault();
89
- if (filteredOptions[highlightIndex] && !filteredOptions[highlightIndex].disabled) {
90
- handleSelect(filteredOptions[highlightIndex].value);
142
+ const highlighted = filteredOptions[highlightIndex];
143
+ if (highlighted && !highlighted.disabled) {
144
+ handleSelect(highlighted.value);
91
145
  }
92
146
  } else if (e.key === 'Escape') {
147
+ e.preventDefault();
93
148
  setIsOpen(false);
149
+ triggerRef.current?.focus();
94
150
  }
95
151
  };
96
152
 
153
+ const activeDescendantId =
154
+ filteredOptions[highlightIndex]
155
+ ? `combobox-opt-${baseId}-${filteredOptions[highlightIndex].value}`
156
+ : undefined;
157
+
97
158
  return (
98
159
  <div ref={ref} className={cn('relative w-full', className)}>
160
+ {name && <input type="hidden" name={name} value={selectedValue} />}
99
161
  <Popover
100
162
  isOpen={isOpen}
101
163
  onClose={() => setIsOpen(false)}
102
164
  trigger={
103
165
  <button
166
+ ref={triggerRef}
167
+ id={comboId}
104
168
  type="button"
105
169
  disabled={disabled}
170
+ role="combobox"
171
+ aria-expanded={isOpen}
172
+ aria-haspopup="listbox"
173
+ aria-controls={isOpen ? listboxId : undefined}
174
+ aria-label={ariaLabel}
106
175
  onClick={() => !disabled && setIsOpen(!isOpen)}
107
176
  onKeyDown={handleKeyDown}
108
177
  className={cn(
109
- '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',
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)]',
110
179
  disabled
111
180
  ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
112
- : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] focus:ring-2 focus:ring-[var(--ui-primary)]/50'
181
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
113
182
  )}
114
183
  >
115
184
  <div className="flex items-center gap-2.5 truncate">
@@ -123,7 +192,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
123
192
  {clearable && selectedValue && !disabled && (
124
193
  <span
125
194
  onClick={handleClear}
126
- className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
195
+ className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
127
196
  >
128
197
  <X className="w-3.5 h-3.5" />
129
198
  </span>
@@ -137,6 +206,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
137
206
  <div className="relative flex items-center">
138
207
  <Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
139
208
  <input
209
+ ref={searchInputRef}
140
210
  type="text"
141
211
  autoFocus
142
212
  placeholder={searchPlaceholder}
@@ -145,27 +215,36 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
145
215
  setSearch(e.target.value);
146
216
  setHighlightIndex(0);
147
217
  }}
218
+ onKeyDown={handleKeyDown}
219
+ role="combobox"
220
+ aria-autocomplete="list"
221
+ aria-expanded="true"
222
+ aria-controls={listboxId}
223
+ aria-activedescendant={activeDescendantId}
148
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)]"
149
225
  />
150
226
  </div>
151
227
 
152
- <div className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
228
+ <div id={listboxId} className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
153
229
  {filteredOptions.length === 0 ? (
154
230
  <div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">{emptyText}</div>
155
231
  ) : (
156
232
  filteredOptions.map((opt, idx) => {
157
233
  const isSelected = opt.value === selectedValue;
158
234
  const isHighlighted = idx === highlightIndex;
235
+ const optId = `combobox-opt-${baseId}-${opt.value}`;
159
236
 
160
237
  return (
161
238
  <div
162
239
  key={opt.value}
240
+ id={optId}
163
241
  role="option"
164
242
  aria-selected={isSelected}
243
+ aria-disabled={opt.disabled}
165
244
  onClick={() => !opt.disabled && handleSelect(opt.value)}
166
- onMouseEnter={() => setHighlightIndex(idx)}
245
+ onMouseEnter={() => !opt.disabled && setHighlightIndex(idx)}
167
246
  className={cn(
168
- 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors',
247
+ 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors select-none',
169
248
  opt.disabled && 'opacity-40 cursor-not-allowed pointer-events-none',
170
249
  isSelected
171
250
  ? 'bg-[var(--ui-primary)] text-white font-semibold shadow-xs'
@@ -231,10 +310,12 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
231
310
  ref
232
311
  ) => {
233
312
  const [internalVal, setInternalVal] = useState<string[]>(defaultValue);
234
- const selectedValues = controlledVal !== undefined ? controlledVal : internalVal;
313
+ const isControlled = controlledVal !== undefined;
314
+ const selectedValues = isControlled ? controlledVal : internalVal;
235
315
 
236
316
  const [search, setSearch] = useState('');
237
317
  const [isOpen, setIsOpen] = useState(false);
318
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
238
319
 
239
320
  const filteredOptions = options.filter(
240
321
  (opt) =>
@@ -243,26 +324,68 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
243
324
  );
244
325
 
245
326
  const toggleOption = (val: string) => {
327
+ const opt = options.find((o) => o.value === val);
328
+ if (opt?.disabled) return;
329
+
246
330
  const next = selectedValues.includes(val)
247
331
  ? selectedValues.filter((v) => v !== val)
248
332
  : [...selectedValues, val];
249
- setInternalVal(next);
333
+ if (!isControlled) {
334
+ setInternalVal(next);
335
+ }
250
336
  onChange?.(next);
251
337
  };
252
338
 
253
339
  const removeTag = (val: string, e?: React.MouseEvent) => {
254
340
  e?.stopPropagation?.();
255
341
  const next = selectedValues.filter((v) => v !== val);
256
- setInternalVal(next);
342
+ if (!isControlled) {
343
+ setInternalVal(next);
344
+ }
257
345
  onChange?.(next);
258
346
  };
259
347
 
260
348
  const clearAll = (e?: React.MouseEvent) => {
261
349
  e?.stopPropagation?.();
262
- setInternalVal([]);
350
+ if (!isControlled) {
351
+ setInternalVal([]);
352
+ }
263
353
  onChange?.([]);
264
354
  };
265
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
+
266
389
  return (
267
390
  <div ref={ref} className={cn('relative w-full', className)}>
268
391
  <Popover
@@ -270,9 +393,19 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
270
393
  onClose={() => setIsOpen(false)}
271
394
  trigger={
272
395
  <div
396
+ tabIndex={disabled ? -1 : 0}
397
+ role="combobox"
398
+ aria-expanded={isOpen}
399
+ aria-haspopup="listbox"
273
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
+ }}
274
407
  className={cn(
275
- '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',
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)]',
276
409
  disabled
277
410
  ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
278
411
  : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
@@ -309,7 +442,7 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
309
442
  {selectedValues.length > 0 && !disabled && (
310
443
  <span
311
444
  onClick={clearAll}
312
- className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
445
+ className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
313
446
  >
314
447
  <X className="w-3.5 h-3.5" />
315
448
  </span>
@@ -327,26 +460,37 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
327
460
  autoFocus
328
461
  placeholder={searchPlaceholder}
329
462
  value={search}
330
- onChange={(e) => setSearch(e.target.value)}
463
+ onChange={(e) => {
464
+ setSearch(e.target.value);
465
+ setHighlightedIndex(0);
466
+ }}
467
+ onKeyDown={handleSearchKeyDown}
331
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)]"
332
469
  />
333
470
  </div>
334
471
 
335
- <div className="max-h-56 overflow-y-auto space-y-0.5">
472
+ <div className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
336
473
  {filteredOptions.length === 0 ? (
337
474
  <div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">No options found.</div>
338
475
  ) : (
339
- filteredOptions.map((opt) => {
476
+ filteredOptions.map((opt, index) => {
340
477
  const isChecked = selectedValues.includes(opt.value);
478
+ const isHighlighted = highlightedIndex === index;
479
+
341
480
  return (
342
481
  <div
343
482
  key={opt.value}
344
- onClick={() => toggleOption(opt.value)}
483
+ role="option"
484
+ aria-selected={isChecked}
485
+ aria-disabled={opt.disabled}
486
+ onClick={() => !opt.disabled && toggleOption(opt.value)}
345
487
  className={cn(
346
- 'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors',
347
- isChecked
348
- ? 'bg-[var(--ui-primary)]/15 text-[var(--ui-primary)] font-semibold'
349
- : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
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'
350
494
  )}
351
495
  >
352
496
  <div className="flex items-center gap-2 truncate">