@emara/ui 1.1.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 (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,874 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
15
+ import {
16
+ RiArrowDownSLine,
17
+ RiCheckLine,
18
+ RiCloseLine,
19
+ RiLoader2Line,
20
+ RiSearchLine,
21
+ } from "@remixicon/react";
22
+ import { Command as CommandPrimitive, useCommandState } from "cmdk";
23
+ import { cva, type VariantProps } from "class-variance-authority";
24
+
25
+ import { cn } from "@/lib/utils";
26
+
27
+ // Per docs/emara-ui-phase-2-components.md §5.
28
+ // Modes: single (default) + multiple. + creatable + async loadOptions.
29
+
30
+ /**
31
+ * Shape of options returned by `loadOptions`. Consumers can extend with extra
32
+ * fields — only `value` is required.
33
+ */
34
+ export type ComboboxOption = {
35
+ value: string;
36
+ label?: React.ReactNode;
37
+ description?: React.ReactNode;
38
+ icon?: React.ReactNode;
39
+ keywords?: string[];
40
+ disabled?: boolean;
41
+ };
42
+
43
+ // ----------------------------------------------------------------------------
44
+ // Context — Combobox root tracks value(s), search, open, mode flag.
45
+ // Discriminated union so children can branch on `mode` without ambiguity.
46
+ // ----------------------------------------------------------------------------
47
+
48
+ type ComboboxBaseContext = {
49
+ open: boolean;
50
+ setOpen: (open: boolean) => void;
51
+ search: string;
52
+ setSearch: (v: string) => void;
53
+ disabled: boolean;
54
+ invalid: boolean;
55
+ /** Combined loading: explicit `loading` prop OR an in-flight `loadOptions` fetch. */
56
+ loading: boolean;
57
+ contentId: string;
58
+ creatable: boolean;
59
+ onCreate: ((query: string) => void) | undefined;
60
+ /** Options fetched by the latest `loadOptions(search)` call. Empty array if
61
+ * `loadOptions` is not provided. */
62
+ loadedOptions: ComboboxOption[];
63
+ /** True when `loadOptions` is wired — children should not also render
64
+ * static items via `<ComboboxList>` unless they want to mix. */
65
+ hasLoadOptions: boolean;
66
+ /** Optional grouping function applied to loaded options. */
67
+ groupBy: ((option: ComboboxOption) => string) | undefined;
68
+ };
69
+
70
+ type ComboboxSingleContext = ComboboxBaseContext & {
71
+ mode: "single";
72
+ value: string;
73
+ setValue: (v: string) => void;
74
+ };
75
+
76
+ type ComboboxMultipleContext = ComboboxBaseContext & {
77
+ mode: "multiple";
78
+ values: string[];
79
+ toggleValue: (v: string) => void;
80
+ setValues: (vs: string[]) => void;
81
+ maxSelected: number | undefined;
82
+ };
83
+
84
+ type ComboboxContextValue = ComboboxSingleContext | ComboboxMultipleContext;
85
+
86
+ const ComboboxContext = createContext<ComboboxContextValue | null>(null);
87
+ function useCombobox(): ComboboxContextValue {
88
+ const ctx = useContext(ComboboxContext);
89
+ if (!ctx) throw new Error("Combobox sub-component must be used inside <Combobox>.");
90
+ return ctx;
91
+ }
92
+
93
+ // ----------------------------------------------------------------------------
94
+ // Combobox root
95
+ // ----------------------------------------------------------------------------
96
+
97
+ type ComboboxRootCommonProps = {
98
+ open?: boolean;
99
+ defaultOpen?: boolean;
100
+ onOpenChange?: (open: boolean) => void;
101
+ disabled?: boolean;
102
+ required?: boolean;
103
+ name?: string;
104
+ invalid?: boolean;
105
+ loading?: boolean;
106
+ /** Allow creating a new option from the current search query. When set,
107
+ * a "+ Create '{query}'" row is appended below filtered items. The
108
+ * consumer is expected to add the new option to their data source +
109
+ * selection state in `onCreate`. */
110
+ creatable?: boolean;
111
+ /** Fired when the user picks the "+ Create" row. Receives the current
112
+ * search string; the component does not modify selection state itself
113
+ * in response — that's the consumer's responsibility. */
114
+ onCreate?: (query: string) => void;
115
+ /**
116
+ * Async option fetcher. When provided, the component intercepts the
117
+ * search input: it debounces ~200ms, calls `loadOptions(query)`, and
118
+ * renders the returned options as items automatically (no need to
119
+ * write `<ComboboxItem>` children). Stale responses are dropped via
120
+ * a request-id guard so out-of-order fetches don't flicker the UI.
121
+ * While a fetch is in flight, `loading` is `true` and cmdk's internal
122
+ * filter is disabled.
123
+ */
124
+ loadOptions?: (query: string) => Promise<ComboboxOption[]>;
125
+ /** Debounce window for `loadOptions` in milliseconds. Default 200ms. */
126
+ loadOptionsDebounceMs?: number;
127
+ /**
128
+ * Auto-grouping for `loadOptions`-rendered items. When provided, the
129
+ * loaded options are partitioned by the function's return value and
130
+ * each group is rendered inside a `<ComboboxGroup heading>`. Groups
131
+ * preserve insertion order — sort your options array if you want a
132
+ * different order. No effect when `loadOptions` is not wired.
133
+ */
134
+ groupBy?: (option: ComboboxOption) => string;
135
+ children: React.ReactNode;
136
+ };
137
+
138
+ type ComboboxSingleProps = ComboboxRootCommonProps & {
139
+ multiple?: false;
140
+ value?: string;
141
+ defaultValue?: string;
142
+ onValueChange?: (value: string) => void;
143
+ };
144
+
145
+ type ComboboxMultipleProps = ComboboxRootCommonProps & {
146
+ multiple: true;
147
+ value?: string[];
148
+ defaultValue?: string[];
149
+ onValueChange?: (values: string[]) => void;
150
+ /** Cap on number of items that can be selected at once. Once reached,
151
+ * additional items are disabled (can still deselect existing ones). */
152
+ maxSelected?: number;
153
+ };
154
+
155
+ type ComboboxRootProps = ComboboxSingleProps | ComboboxMultipleProps;
156
+
157
+ const Combobox = function Combobox(props: ComboboxRootProps) {
158
+ const {
159
+ open: openProp,
160
+ defaultOpen = false,
161
+ onOpenChange,
162
+ disabled = false,
163
+ invalid = false,
164
+ loading = false,
165
+ creatable = false,
166
+ onCreate,
167
+ loadOptions,
168
+ loadOptionsDebounceMs = 200,
169
+ groupBy,
170
+ children,
171
+ } = props;
172
+ const multiple = props.multiple === true;
173
+
174
+ const isOpenControlled = openProp !== undefined;
175
+ const [openInternal, setOpenInternal] = useState(defaultOpen);
176
+ const open = isOpenControlled ? openProp : openInternal;
177
+ const setOpen = useCallback(
178
+ (next: boolean) => {
179
+ if (!isOpenControlled) setOpenInternal(next);
180
+ onOpenChange?.(next);
181
+ },
182
+ [isOpenControlled, onOpenChange],
183
+ );
184
+
185
+ // ---- Single-mode value state ----
186
+ const singleValueProp = !multiple ? (props as ComboboxSingleProps).value : undefined;
187
+ const singleDefault = !multiple ? ((props as ComboboxSingleProps).defaultValue ?? "") : "";
188
+ const isSingleControlled = singleValueProp !== undefined;
189
+ const [singleInternal, setSingleInternal] = useState(singleDefault);
190
+ const singleValue = isSingleControlled ? (singleValueProp ?? "") : singleInternal;
191
+ const setSingleValue = useCallback(
192
+ (next: string) => {
193
+ if (!isSingleControlled) setSingleInternal(next);
194
+ if (!multiple) (props as ComboboxSingleProps).onValueChange?.(next);
195
+ },
196
+ // We deliberately do not depend on `props` (would re-create every render).
197
+ // Single-mode handlers don't change identity across renders in practice.
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ [isSingleControlled, multiple],
200
+ );
201
+
202
+ // ---- Multi-mode value state ----
203
+ const multiValueProp = multiple ? (props as ComboboxMultipleProps).value : undefined;
204
+ const multiDefault = multiple ? ((props as ComboboxMultipleProps).defaultValue ?? []) : [];
205
+ const isMultiControlled = multiValueProp !== undefined;
206
+ const [multiInternal, setMultiInternal] = useState<string[]>(multiDefault);
207
+ const multiValues = isMultiControlled ? (multiValueProp ?? []) : multiInternal;
208
+ const setValues = useCallback(
209
+ (next: string[]) => {
210
+ if (!isMultiControlled) setMultiInternal(next);
211
+ if (multiple) (props as ComboboxMultipleProps).onValueChange?.(next);
212
+ },
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ [isMultiControlled, multiple],
215
+ );
216
+ const toggleValue = useCallback(
217
+ (v: string) => {
218
+ const current = isMultiControlled ? (multiValueProp ?? []) : multiInternal;
219
+ const next = current.includes(v) ? current.filter((x) => x !== v) : [...current, v];
220
+ setValues(next);
221
+ },
222
+ [isMultiControlled, multiValueProp, multiInternal, setValues],
223
+ );
224
+
225
+ const [search, setSearch] = useState("");
226
+ const contentId = useId();
227
+
228
+ // ---- Async options state (loadOptions) ----
229
+ const [loadedOptions, setLoadedOptions] = useState<ComboboxOption[]>([]);
230
+ const [loadingInternal, setLoadingInternal] = useState(false);
231
+ const requestIdRef = useRef(0);
232
+ useEffect(() => {
233
+ if (!loadOptions) return;
234
+ const myRequestId = ++requestIdRef.current;
235
+ setLoadingInternal(true);
236
+ const timeoutId = window.setTimeout(() => {
237
+ Promise.resolve(loadOptions(search))
238
+ .then((opts) => {
239
+ // Drop stale response — a newer request has been issued.
240
+ if (myRequestId !== requestIdRef.current) return;
241
+ setLoadedOptions(opts);
242
+ })
243
+ .catch(() => {
244
+ // Swallow — consumer's fetcher is responsible for surfacing errors.
245
+ if (myRequestId !== requestIdRef.current) return;
246
+ setLoadedOptions([]);
247
+ })
248
+ .finally(() => {
249
+ if (myRequestId === requestIdRef.current) setLoadingInternal(false);
250
+ });
251
+ }, loadOptionsDebounceMs);
252
+ return () => window.clearTimeout(timeoutId);
253
+ }, [search, loadOptions, loadOptionsDebounceMs]);
254
+ const effectiveLoading = loading || (Boolean(loadOptions) && loadingInternal);
255
+
256
+ const ctx: ComboboxContextValue = useMemo(() => {
257
+ const base: ComboboxBaseContext = {
258
+ open,
259
+ setOpen,
260
+ search,
261
+ setSearch,
262
+ disabled,
263
+ invalid,
264
+ loading: effectiveLoading,
265
+ contentId,
266
+ creatable,
267
+ onCreate,
268
+ loadedOptions,
269
+ hasLoadOptions: Boolean(loadOptions),
270
+ groupBy,
271
+ };
272
+ if (multiple) {
273
+ return {
274
+ ...base,
275
+ mode: "multiple",
276
+ values: multiValues,
277
+ toggleValue,
278
+ setValues,
279
+ maxSelected: (props as ComboboxMultipleProps).maxSelected,
280
+ };
281
+ }
282
+ return { ...base, mode: "single", value: singleValue, setValue: setSingleValue };
283
+ // eslint-disable-next-line react-hooks/exhaustive-deps
284
+ }, [
285
+ multiple,
286
+ open,
287
+ setOpen,
288
+ search,
289
+ disabled,
290
+ invalid,
291
+ effectiveLoading,
292
+ contentId,
293
+ creatable,
294
+ onCreate,
295
+ loadOptions,
296
+ loadedOptions,
297
+ groupBy,
298
+ singleValue,
299
+ setSingleValue,
300
+ multiValues,
301
+ toggleValue,
302
+ setValues,
303
+ ]);
304
+
305
+ return (
306
+ <ComboboxContext.Provider value={ctx}>
307
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
308
+ {children}
309
+ </PopoverPrimitive.Root>
310
+ </ComboboxContext.Provider>
311
+ );
312
+ };
313
+
314
+ // ----------------------------------------------------------------------------
315
+ // ComboboxTrigger
316
+ // ----------------------------------------------------------------------------
317
+
318
+ const triggerVariants = cva(
319
+ [
320
+ "flex items-center justify-between w-full gap-2",
321
+ "rounded-md border border-input bg-background text-foreground",
322
+ "transition-colors",
323
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
324
+ "disabled:cursor-not-allowed disabled:opacity-50",
325
+ "[&>span:first-child]:line-clamp-1 [&>span:first-child]:text-start",
326
+ ].join(" "),
327
+ {
328
+ variants: {
329
+ size: {
330
+ xs: "h-7 ps-2.5 pe-2 text-xs",
331
+ sm: "h-8 ps-3 pe-2 text-xs",
332
+ md: "h-9 ps-3 pe-2 text-sm",
333
+ lg: "h-10 ps-3.5 pe-2.5 text-base",
334
+ xl: "h-12 ps-4 pe-3 text-base",
335
+ },
336
+ invalid: {
337
+ true: "border-destructive focus-visible:ring-destructive",
338
+ false: "",
339
+ },
340
+ },
341
+ defaultVariants: { size: "md", invalid: false },
342
+ },
343
+ );
344
+
345
+ type ComboboxTriggerProps = Omit<
346
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
347
+ "type" | "children"
348
+ > &
349
+ VariantProps<typeof triggerVariants> & {
350
+ placeholder?: string;
351
+ clearable?: boolean;
352
+ onClear?: () => void;
353
+ /** Map a value to a display label. Used for pill text in multiple mode,
354
+ * and as the trigger label in single mode when no `children` is given. */
355
+ getOptionLabel?: (value: string) => React.ReactNode;
356
+ /** Multi-mode only: max pills to render in the trigger before collapsing
357
+ * the remainder into a "+N more" badge. Defaults to 3. */
358
+ maxVisiblePills?: number;
359
+ /**
360
+ * Single mode: what to render when the Combobox has a value. Consumers
361
+ * compute this from their own options array (matching shadcn's recipe).
362
+ * Ignored in multiple mode — pills are rendered instead.
363
+ */
364
+ children?: React.ReactNode;
365
+ };
366
+
367
+ const ComboboxTrigger = forwardRef<HTMLButtonElement, ComboboxTriggerProps>(
368
+ function ComboboxTrigger(
369
+ {
370
+ className,
371
+ size,
372
+ invalid: invalidProp,
373
+ placeholder,
374
+ clearable = false,
375
+ onClear,
376
+ getOptionLabel,
377
+ maxVisiblePills = 3,
378
+ children,
379
+ ...props
380
+ },
381
+ ref,
382
+ ) {
383
+ const ctx = useCombobox();
384
+ const invalid = invalidProp ?? ctx.invalid;
385
+ const isMulti = ctx.mode === "multiple";
386
+ const hasSelection = isMulti ? ctx.values.length > 0 : Boolean(ctx.value);
387
+ const showClear = clearable && !ctx.disabled && !ctx.loading && hasSelection;
388
+
389
+ // ---- Multi-mode pill rendering ----
390
+ const visiblePills = isMulti ? ctx.values.slice(0, maxVisiblePills) : [];
391
+ const overflowCount = isMulti ? Math.max(0, ctx.values.length - maxVisiblePills) : 0;
392
+ const labelFor = (v: string): React.ReactNode => (getOptionLabel ? getOptionLabel(v) : v);
393
+
394
+ // ---- Single-mode display fallback ----
395
+ const singleDisplay =
396
+ !isMulti && ctx.value
397
+ ? (children ?? (getOptionLabel ? getOptionLabel(ctx.value) : ctx.value))
398
+ : (placeholder ?? "Select…");
399
+
400
+ // WAI-ARIA accessible name fallback.
401
+ const consumerLabelled =
402
+ props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined;
403
+ const ariaLabelFallback = consumerLabelled
404
+ ? undefined
405
+ : isMulti
406
+ ? hasSelection
407
+ ? `${ctx.values.length} selected`
408
+ : (placeholder ?? "Select…")
409
+ : typeof singleDisplay === "string"
410
+ ? singleDisplay
411
+ : (placeholder ?? "Select…");
412
+
413
+ const handleClear = (e: React.MouseEvent) => {
414
+ e.preventDefault();
415
+ e.stopPropagation();
416
+ if (ctx.mode === "multiple") ctx.setValues([]);
417
+ else ctx.setValue("");
418
+ onClear?.();
419
+ };
420
+
421
+ return (
422
+ <PopoverPrimitive.Trigger asChild>
423
+ <button
424
+ ref={ref}
425
+ type="button"
426
+ role="combobox"
427
+ aria-expanded={ctx.open}
428
+ aria-controls={ctx.open ? ctx.contentId : undefined}
429
+ aria-invalid={invalid || undefined}
430
+ aria-label={ariaLabelFallback}
431
+ disabled={ctx.disabled}
432
+ className={cn(
433
+ triggerVariants({ size, invalid }),
434
+ // In multi mode the trigger may grow to fit pills; relax height.
435
+ isMulti && hasSelection && "h-auto min-h-9 flex-wrap py-1",
436
+ className,
437
+ )}
438
+ {...props}
439
+ >
440
+ {isMulti ? (
441
+ <span className="flex flex-1 flex-wrap items-center gap-1">
442
+ {!hasSelection ? (
443
+ <span className="text-muted-foreground">{placeholder ?? "Select…"}</span>
444
+ ) : (
445
+ <>
446
+ {visiblePills.map((v) => (
447
+ <span
448
+ key={v}
449
+ className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs"
450
+ >
451
+ <span>{labelFor(v)}</span>
452
+ {!ctx.disabled ? (
453
+ <button
454
+ type="button"
455
+ aria-label={`Remove ${typeof labelFor(v) === "string" ? labelFor(v) : v}`}
456
+ onClick={(e) => {
457
+ e.preventDefault();
458
+ e.stopPropagation();
459
+ if (ctx.mode === "multiple") ctx.toggleValue(v);
460
+ }}
461
+ className="focus-visible:ring-ring hover:text-foreground inline-flex items-center justify-center rounded-sm focus-visible:ring-2 focus-visible:outline-none"
462
+ >
463
+ <RiCloseLine className="size-3" />
464
+ </button>
465
+ ) : null}
466
+ </span>
467
+ ))}
468
+ {overflowCount > 0 ? (
469
+ <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs">
470
+ +{overflowCount} more
471
+ </span>
472
+ ) : null}
473
+ </>
474
+ )}
475
+ </span>
476
+ ) : (
477
+ <span className={cn(!hasSelection && "text-muted-foreground")}>{singleDisplay}</span>
478
+ )}
479
+ <span className="text-muted-foreground ms-auto inline-flex shrink-0 items-center gap-1 [&_svg]:size-4 [&_svg]:shrink-0">
480
+ {showClear ? (
481
+ <button
482
+ type="button"
483
+ aria-label="Clear"
484
+ onClick={handleClear}
485
+ className={cn(
486
+ "inline-flex items-center justify-center rounded p-0.5",
487
+ "hover:text-foreground",
488
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
489
+ )}
490
+ >
491
+ <RiCloseLine />
492
+ </button>
493
+ ) : null}
494
+ {ctx.loading ? <RiLoader2Line className="size-4 animate-spin" /> : <RiArrowDownSLine />}
495
+ </span>
496
+ </button>
497
+ </PopoverPrimitive.Trigger>
498
+ );
499
+ },
500
+ );
501
+ ComboboxTrigger.displayName = "ComboboxTrigger";
502
+
503
+ // ----------------------------------------------------------------------------
504
+ // ComboboxContent — Popover content hosting the cmdk Command palette.
505
+ // ----------------------------------------------------------------------------
506
+
507
+ /**
508
+ * Screen-reader-only result counter. Mounted inside the Command tree so
509
+ * `useCommandState` can read cmdk's filtered count. Only renders while the
510
+ * user is actively searching — otherwise it would announce on every open.
511
+ * Spec: docs/emara-ui-phase-2-components.md §5 Accessibility ("Live region
512
+ * announces result count").
513
+ */
514
+ /**
515
+ * Renders options fetched by `loadOptions` as ComboboxItems automatically.
516
+ * Mounted inside ComboboxContent only when the root has loadOptions wired.
517
+ * Consumers can still mix static items via a ComboboxList sibling — those
518
+ * are rendered alongside (with cmdk filtering disabled while loading so
519
+ * the async items never get hidden by the search input).
520
+ */
521
+ function ComboboxAsyncOptions(): React.ReactElement | null {
522
+ const ctx = useCombobox();
523
+ if (!ctx.hasLoadOptions) return null;
524
+
525
+ const renderItem = (opt: ComboboxOption) => (
526
+ <ComboboxItem
527
+ key={opt.value}
528
+ value={opt.value}
529
+ {...(opt.description !== undefined && { description: opt.description })}
530
+ {...(opt.icon !== undefined && { icon: opt.icon })}
531
+ {...(opt.keywords !== undefined && { keywords: opt.keywords })}
532
+ {...(opt.disabled !== undefined && { disabled: opt.disabled })}
533
+ >
534
+ {opt.label ?? opt.value}
535
+ </ComboboxItem>
536
+ );
537
+
538
+ if (!ctx.groupBy) {
539
+ return (
540
+ <CommandPrimitive.List className="max-h-64 overflow-y-auto p-1">
541
+ {ctx.loadedOptions.map(renderItem)}
542
+ </CommandPrimitive.List>
543
+ );
544
+ }
545
+
546
+ // Partition into groups preserving first-seen insertion order.
547
+ const groups = new Map<string, ComboboxOption[]>();
548
+ for (const opt of ctx.loadedOptions) {
549
+ const key = ctx.groupBy(opt);
550
+ const arr = groups.get(key);
551
+ if (arr) arr.push(opt);
552
+ else groups.set(key, [opt]);
553
+ }
554
+
555
+ return (
556
+ <CommandPrimitive.List className="max-h-64 overflow-y-auto p-1">
557
+ {Array.from(groups.entries()).map(([heading, opts]) => (
558
+ <ComboboxGroup key={heading} heading={heading}>
559
+ {opts.map(renderItem)}
560
+ </ComboboxGroup>
561
+ ))}
562
+ </CommandPrimitive.List>
563
+ );
564
+ }
565
+
566
+ function ComboboxResultCount(): React.ReactElement | null {
567
+ const count = useCommandState((s) => s.filtered.count) ?? 0;
568
+ const search = useCommandState((s) => s.search) ?? "";
569
+ if (!search) return null;
570
+ const label = count === 0 ? "No results" : count === 1 ? "1 result" : `${count} results`;
571
+ return (
572
+ <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
573
+ {label}
574
+ </div>
575
+ );
576
+ }
577
+
578
+ /**
579
+ * Renders a "+ Create '{search}'" row when the consumer has set `creatable`
580
+ * on the root. The row's cmdk `value` is the search string itself so it
581
+ * always survives cmdk's filter while the user is typing.
582
+ *
583
+ * On select: calls the consumer's `onCreate(search)` and clears the search.
584
+ * In single mode, the popover closes (matching ComboboxItem). In multi
585
+ * mode it stays open so the user can keep adding.
586
+ *
587
+ * The component does not mutate selection state itself — adding the new
588
+ * option to the data source AND the value array is the consumer's job
589
+ * inside `onCreate`. See Storybook for the canonical pattern.
590
+ */
591
+ function ComboboxCreatable(): React.ReactElement | null {
592
+ const ctx = useCombobox();
593
+ const search = useCommandState((s) => s.search) ?? "";
594
+ if (!ctx.creatable || !search.trim()) return null;
595
+ return (
596
+ <CommandPrimitive.Item
597
+ // Use a sentinel value prefixed to avoid clashes with consumer options.
598
+ value={`__create__::${search}`}
599
+ // Always match — searches change as user types but cmdk filters by
600
+ // value vs. search. Forcing this item to match the current search is
601
+ // the cleanest way to keep it visible.
602
+ keywords={[search]}
603
+ onSelect={() => {
604
+ ctx.onCreate?.(search);
605
+ ctx.setSearch("");
606
+ if (ctx.mode === "single") ctx.setOpen(false);
607
+ }}
608
+ className={cn(
609
+ "relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none select-none",
610
+ "transition-colors",
611
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
612
+ "border-border/60 mt-1 border-t pt-2",
613
+ )}
614
+ >
615
+ <span
616
+ aria-hidden="true"
617
+ className="absolute start-2 inline-flex h-4 w-4 items-center justify-center"
618
+ >
619
+ +
620
+ </span>
621
+ <span>
622
+ Create <span className="font-medium">&quot;{search}&quot;</span>
623
+ </span>
624
+ </CommandPrimitive.Item>
625
+ );
626
+ }
627
+
628
+ type ComboboxContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
629
+ emptyMessage?: React.ReactNode;
630
+ };
631
+
632
+ const ComboboxContent = forwardRef<
633
+ React.ElementRef<typeof PopoverPrimitive.Content>,
634
+ ComboboxContentProps
635
+ >(function ComboboxContent(
636
+ { className, align = "start", sideOffset = 4, emptyMessage, children, ...props },
637
+ ref,
638
+ ) {
639
+ const ctx = useCombobox();
640
+ return (
641
+ <PopoverPrimitive.Portal>
642
+ <PopoverPrimitive.Content
643
+ ref={ref}
644
+ id={ctx.contentId}
645
+ align={align}
646
+ sideOffset={sideOffset}
647
+ className={cn(
648
+ "border-border bg-popover text-popover-foreground z-popover w-(--radix-popover-trigger-width) overflow-hidden rounded-md border shadow-md outline-none",
649
+ "data-[state=open]:animate-[scale-in_var(--duration-fast)_var(--ease-out)]",
650
+ "data-[state=closed]:animate-[scale-out_var(--duration-fast)_var(--ease-in)]",
651
+ className,
652
+ )}
653
+ {...props}
654
+ >
655
+ <CommandPrimitive shouldFilter={!ctx.loading && !ctx.hasLoadOptions}>
656
+ {children}
657
+ <ComboboxAsyncOptions />
658
+ {emptyMessage !== undefined ? (
659
+ <CommandPrimitive.Empty className="text-muted-foreground px-3 py-6 text-center text-sm">
660
+ {emptyMessage}
661
+ </CommandPrimitive.Empty>
662
+ ) : null}
663
+ <ComboboxCreatable />
664
+ <ComboboxResultCount />
665
+ </CommandPrimitive>
666
+ </PopoverPrimitive.Content>
667
+ </PopoverPrimitive.Portal>
668
+ );
669
+ });
670
+ ComboboxContent.displayName = "ComboboxContent";
671
+
672
+ // ----------------------------------------------------------------------------
673
+ // ComboboxInput — cmdk's input, wired to the root's search state.
674
+ // ----------------------------------------------------------------------------
675
+
676
+ const ComboboxInput = forwardRef<
677
+ React.ElementRef<typeof CommandPrimitive.Input>,
678
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
679
+ >(function ComboboxInput({ className, placeholder = "Search…", ...props }, ref) {
680
+ const ctx = useCombobox();
681
+ return (
682
+ <div className="border-border flex items-center gap-2 border-b ps-3 pe-2">
683
+ <RiSearchLine className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
684
+ <CommandPrimitive.Input
685
+ ref={ref}
686
+ value={ctx.search}
687
+ onValueChange={ctx.setSearch}
688
+ placeholder={placeholder}
689
+ dir="auto"
690
+ className={cn(
691
+ "text-foreground placeholder:text-muted-foreground h-9 flex-1 bg-transparent text-sm outline-none",
692
+ "disabled:cursor-not-allowed disabled:opacity-50",
693
+ className,
694
+ )}
695
+ {...props}
696
+ />
697
+ </div>
698
+ );
699
+ });
700
+ ComboboxInput.displayName = "ComboboxInput";
701
+
702
+ // ----------------------------------------------------------------------------
703
+ // ComboboxEmpty
704
+ // ----------------------------------------------------------------------------
705
+
706
+ const ComboboxEmpty = forwardRef<
707
+ React.ElementRef<typeof CommandPrimitive.Empty>,
708
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
709
+ >(function ComboboxEmpty({ className, ...props }, ref) {
710
+ return (
711
+ <CommandPrimitive.Empty
712
+ ref={ref}
713
+ className={cn("text-muted-foreground px-3 py-6 text-center text-sm", className)}
714
+ {...props}
715
+ />
716
+ );
717
+ });
718
+ ComboboxEmpty.displayName = "ComboboxEmpty";
719
+
720
+ // ----------------------------------------------------------------------------
721
+ // ComboboxList — the scrollable list region. Required by cmdk.
722
+ // ----------------------------------------------------------------------------
723
+
724
+ const ComboboxList = forwardRef<
725
+ React.ElementRef<typeof CommandPrimitive.List>,
726
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
727
+ >(function ComboboxList({ className, ...props }, ref) {
728
+ return (
729
+ <CommandPrimitive.List
730
+ ref={ref}
731
+ className={cn("max-h-64 overflow-y-auto p-1", className)}
732
+ {...props}
733
+ />
734
+ );
735
+ });
736
+ ComboboxList.displayName = "ComboboxList";
737
+
738
+ // ----------------------------------------------------------------------------
739
+ // ComboboxGroup / ComboboxLabel
740
+ // ----------------------------------------------------------------------------
741
+
742
+ const ComboboxGroup = forwardRef<
743
+ React.ElementRef<typeof CommandPrimitive.Group>,
744
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
745
+ >(function ComboboxGroup({ className, ...props }, ref) {
746
+ return (
747
+ <CommandPrimitive.Group
748
+ ref={ref}
749
+ className={cn(
750
+ "text-foreground overflow-hidden",
751
+ "[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:uppercase",
752
+ className,
753
+ )}
754
+ {...props}
755
+ />
756
+ );
757
+ });
758
+ ComboboxGroup.displayName = "ComboboxGroup";
759
+
760
+ // ----------------------------------------------------------------------------
761
+ // ComboboxItem
762
+ // ----------------------------------------------------------------------------
763
+
764
+ type ComboboxItemProps = Omit<
765
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>,
766
+ "onSelect" | "value"
767
+ > & {
768
+ value: string;
769
+ description?: React.ReactNode;
770
+ icon?: React.ReactNode;
771
+ /** Extra search terms the item matches against, in addition to `value`
772
+ * and rendered children. Example: a "New York" item with
773
+ * `keywords={["nyc", "ny"]}` will match either abbreviation. */
774
+ keywords?: string[];
775
+ };
776
+
777
+ const ComboboxItem = forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, ComboboxItemProps>(
778
+ function ComboboxItem(
779
+ { className, value, description, icon, disabled, children, ...props },
780
+ ref,
781
+ ) {
782
+ const ctx = useCombobox();
783
+ const selected = ctx.mode === "multiple" ? ctx.values.includes(value) : ctx.value === value;
784
+ // Multi mode: when at maxSelected, items not already selected are gated.
785
+ const blockedByMax =
786
+ ctx.mode === "multiple" &&
787
+ ctx.maxSelected !== undefined &&
788
+ !selected &&
789
+ ctx.values.length >= ctx.maxSelected;
790
+ const effectiveDisabled = disabled || blockedByMax;
791
+
792
+ return (
793
+ <CommandPrimitive.Item
794
+ ref={ref}
795
+ value={value}
796
+ disabled={effectiveDisabled}
797
+ onSelect={() => {
798
+ if (effectiveDisabled) return;
799
+ if (ctx.mode === "multiple") {
800
+ ctx.toggleValue(value);
801
+ // Keep popover open + clear search after a successful toggle so
802
+ // the user can pick another option immediately.
803
+ ctx.setSearch("");
804
+ } else {
805
+ ctx.setValue(value);
806
+ ctx.setSearch("");
807
+ ctx.setOpen(false);
808
+ }
809
+ }}
810
+ className={cn(
811
+ "relative flex w-full cursor-pointer items-start gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none select-none",
812
+ "transition-colors",
813
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
814
+ "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
815
+ className,
816
+ )}
817
+ aria-selected={selected || undefined}
818
+ {...props}
819
+ >
820
+ <span className="absolute start-2 inline-flex h-4 w-4 items-center justify-center">
821
+ {selected ? <RiCheckLine className="size-4" /> : null}
822
+ </span>
823
+ <span className="flex flex-1 flex-col gap-0.5 leading-none">
824
+ <span className="inline-flex items-center gap-2">
825
+ {icon ? (
826
+ <span
827
+ aria-hidden="true"
828
+ className="text-muted-foreground inline-flex shrink-0 [&_svg]:size-4 [&_svg]:shrink-0"
829
+ >
830
+ {icon}
831
+ </span>
832
+ ) : null}
833
+ <span>{children ?? value}</span>
834
+ </span>
835
+ {description ? (
836
+ <span className="text-muted-foreground text-xs">{description}</span>
837
+ ) : null}
838
+ </span>
839
+ </CommandPrimitive.Item>
840
+ );
841
+ },
842
+ );
843
+ ComboboxItem.displayName = "ComboboxItem";
844
+
845
+ // ----------------------------------------------------------------------------
846
+ // ComboboxSeparator
847
+ // ----------------------------------------------------------------------------
848
+
849
+ const ComboboxSeparator = forwardRef<
850
+ React.ElementRef<typeof CommandPrimitive.Separator>,
851
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
852
+ >(function ComboboxSeparator({ className, ...props }, ref) {
853
+ return (
854
+ <CommandPrimitive.Separator
855
+ ref={ref}
856
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
857
+ {...props}
858
+ />
859
+ );
860
+ });
861
+ ComboboxSeparator.displayName = "ComboboxSeparator";
862
+
863
+ export {
864
+ Combobox,
865
+ ComboboxTrigger,
866
+ ComboboxContent,
867
+ ComboboxInput,
868
+ ComboboxList,
869
+ ComboboxEmpty,
870
+ ComboboxGroup,
871
+ ComboboxItem,
872
+ ComboboxSeparator,
873
+ };
874
+ export type { ComboboxRootProps, ComboboxTriggerProps, ComboboxContentProps, ComboboxItemProps };