1"use client";
2
3import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
4import { ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react";
5import * as React from "react";
6
7import { cn } from "~/lib/utils";
8import { Input } from "~/components/ui/input";
9import { ScrollArea } from "~/components/ui/scroll-area";
10
11const ComboboxContext = React.createContext<{
12 chipsRef: React.RefObject<Element | null> | null;
13 multiple: boolean;
14}>({
15 chipsRef: null,
16 multiple: false,
17});
18
19function Combobox<Value, Multiple extends boolean | undefined = false>(
20 props: ComboboxPrimitive.Root.Props<Value, Multiple>,
21) {
22 const chipsRef = React.useRef<Element | null>(null);
23 const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]);
24 return (
25 <ComboboxContext value={value}>
26 <ComboboxPrimitive.Root {...props} />
27 </ComboboxContext>
28 );
29}
30
31function ComboboxInput({
32 className,
33 showTrigger = true,
34 showClear = false,
35 startAddon,
36 size,
37 unstyled = false,
38 ...props
39}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
40 showTrigger?: boolean;
41 showClear?: boolean;
42 startAddon?: React.ReactNode;
43 size?: "sm" | "default" | "lg" | number;
44 unstyled?: boolean;
45 ref?: React.Ref<HTMLInputElement>;
46}) {
47 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
48
49 return (
50 <div className="relative not-has-[>*.w-full]:w-fit w-full text-foreground has-disabled:opacity-64">
51 {startAddon && (
52 <div
53 aria-hidden="true"
54 className="[&_svg]:-mx-0.5 pointer-events-none absolute inset-y-0 start-px z-10 flex items-center ps-[calc(--spacing(3)-1px)] opacity-80 has-[+[data-size=sm]]:ps-[calc(--spacing(2.5)-1px)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4"
55 data-slot="combobox-start-addon"
56 >
57 {startAddon}
58 </div>
59 )}
60 <ComboboxPrimitive.Input
61 className={cn(
62 startAddon &&
63 "data-[size=sm]:*:data-[slot=combobox-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=combobox-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=combobox-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=combobox-input]:ps-[calc(--spacing(8)-1px)]",
64 sizeValue === "sm"
65 ? "has-[+[data-slot=combobox-trigger],+[data-slot=combobox-clear]]:*:data-[slot=combobox-input]:pe-6.5"
66 : "has-[+[data-slot=combobox-trigger],+[data-slot=combobox-clear]]:*:data-[slot=combobox-input]:pe-7",
67 className,
68 )}
69 data-slot="combobox-input"
70 render={
71 <Input
72 className={cn(
73 "has-disabled:opacity-100",
74 unstyled && "rounded-none bg-transparent text-sm",
75 )}
76 nativeInput
77 size={sizeValue}
78 unstyled={unstyled}
79 />
80 }
81 {...props}
82 />
83 {showTrigger && (
84 <ComboboxTrigger
85 className={cn(
86 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-opacity pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=combobox-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
87 sizeValue === "sm" ? "end-0" : "end-0.5",
88 )}
89 >
90 <ComboboxPrimitive.Icon data-slot="combobox-icon">
91 <ChevronsUpDownIcon />
92 </ComboboxPrimitive.Icon>
93 </ComboboxTrigger>
94 )}
95 {showClear && (
96 <ComboboxClear
97 className={cn(
98 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-opacity pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=combobox-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
99 sizeValue === "sm" ? "end-0" : "end-0.5",
100 )}
101 >
102 <XIcon />
103 </ComboboxClear>
104 )}
105 </div>
106 );
107}
108
109function ComboboxSearchInput(props: React.ComponentProps<typeof ComboboxInput>) {
110 return (
111 <div className="min-w-0 shrink-0 px-3 pt-2.5">
112 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
113 <SearchIcon
114 aria-hidden="true"
115 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
116 />
117 <ComboboxInput
118 {...props}
119 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
120 showTrigger={false}
121 size="sm"
122 unstyled
123 />
124 </div>
125 </div>
126 );
127}
128
129function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
130 return (
131 <ComboboxPrimitive.Trigger className={className} data-slot="combobox-trigger" {...props}>
132 {children}
133 </ComboboxPrimitive.Trigger>
134 );
135}
136
137function ComboboxPopup({
138 className,
139 children,
140 side = "bottom",
141 sideOffset = 4,
142 alignOffset,
143 align = "start",
144 anchor: anchorProp,
145 ...props
146}: ComboboxPrimitive.Popup.Props & {
147 align?: ComboboxPrimitive.Positioner.Props["align"];
148 sideOffset?: ComboboxPrimitive.Positioner.Props["sideOffset"];
149 alignOffset?: ComboboxPrimitive.Positioner.Props["alignOffset"];
150 side?: ComboboxPrimitive.Positioner.Props["side"];
151 anchor?: ComboboxPrimitive.Positioner.Props["anchor"];
152}) {
153 const { chipsRef, multiple } = React.use(ComboboxContext);
154 const anchor = anchorProp ?? (multiple ? chipsRef : undefined);
155
156 return (
157 <ComboboxPrimitive.Portal>
158 <ComboboxPrimitive.Positioner
159 align={align}
160 alignOffset={alignOffset}
161 anchor={anchor}
162 className="z-[130] select-none"
163 data-slot="combobox-positioner"
164 side={side}
165 sideOffset={sideOffset}
166 >
167 <span
168 className={cn(
169 "dropdown-glass relative flex max-h-full min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-lg shadow-[0_16px_40px_-18px_rgb(0_0_0/55%)] transition-[scale,opacity] dark:shadow-[0_18px_44px_-18px_rgb(0_0_0/80%)]",
170 className,
171 )}
172 >
173 <ComboboxPrimitive.Popup
174 className="flex min-w-0 max-h-[min(var(--available-height),23rem)] flex-1 flex-col overflow-hidden text-foreground"
175 data-slot="combobox-popup"
176 {...props}
177 >
178 {children}
179 </ComboboxPrimitive.Popup>
180 </span>
181 </ComboboxPrimitive.Positioner>
182 </ComboboxPrimitive.Portal>
183 );
184}
185
186function ComboboxItem({
187 className,
188 children,
189 hideIndicator: _hideIndicator = false,
190 ...props
191}: ComboboxPrimitive.Item.Props & {
192 hideIndicator?: boolean;
193}) {
194 return (
195 <ComboboxPrimitive.Item
196 className={cn(
197 "flex min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center rounded-sm px-2 py-1 text-base outline-none not-data-disabled:hover:bg-accent data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-selected:bg-foreground/[0.08] data-selected:text-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground [&[data-highlighted][data-selected]]:bg-accent [&[data-highlighted][data-selected]]:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
198 className,
199 )}
200 data-slot="combobox-item"
201 {...props}
202 >
203 {/* Children lay out as one row: a label that truncates, then any trailing meta. */}
204 <div
205 className="flex min-w-0 flex-1 items-center gap-2 [&_svg:not([class*='text-'])]:text-muted-foreground"
206 data-slot="combobox-item-content"
207 >
208 {children}
209 </div>
210 </ComboboxPrimitive.Item>
211 );
212}
213
214function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
215 return (
216 <ComboboxPrimitive.Empty
217 className={cn(
218 "not-empty:p-2 text-center text-base text-muted-foreground sm:text-sm",
219 className,
220 )}
221 data-slot="combobox-empty"
222 {...props}
223 />
224 );
225}
226
227function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
228 return (
229 <ScrollArea scrollbarGutter scrollFade>
230 <ComboboxPrimitive.List
231 className={cn("not-empty:scroll-py-1 not-empty:px-1 not-empty:py-1", className)}
232 data-slot="combobox-list"
233 {...props}
234 />
235 </ScrollArea>
236 );
237}
238
239/**
240 * A variant of `ComboboxList` without `ScrollArea`, for use when
241 * an external virtualizer (e.g. LegendList) owns the scroll container.
242 */
243function ComboboxListVirtualized({ className, ...props }: ComboboxPrimitive.List.Props) {
244 return (
245 <ComboboxPrimitive.List
246 className={cn("size-full min-w-0 not-empty:px-1 not-empty:py-1", className)}
247 data-slot="combobox-list"
248 {...props}
249 />
250 );
251}
252
253function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
254 return <ComboboxPrimitive.Clear className={className} data-slot="combobox-clear" {...props} />;
255}
256
257function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props) {
258 return (
259 <ComboboxPrimitive.Status
260 className={cn(
261 "px-3 py-2 font-medium text-muted-foreground text-xs empty:m-0 empty:p-0",
262 className,
263 )}
264 data-slot="combobox-status"
265 {...props}
266 />
267 );
268}
269
270const useComboboxFilter = ComboboxPrimitive.useFilter;
271
272export {
273 Combobox,
274 ComboboxInput,
275 ComboboxSearchInput,
276 ComboboxTrigger,
277 ComboboxPopup,
278 ComboboxItem,
279 ComboboxEmpty,
280 ComboboxList,
281 ComboboxListVirtualized,
282 ComboboxClear,
283 ComboboxStatus,
284 useComboboxFilter,
285};