combobox.tsx raw
1 "use client";
2
3 import * as React from "react";
4 import { Combobox as ComboboxPrimitive } from "@base-ui/react";
5 import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
6
7 import { cn } from "src/lib/utils";
8 import { Button } from "src/components/ui/button";
9 import {
10 InputGroup,
11 InputGroupAddon,
12 InputGroupButton,
13 InputGroupInput,
14 } from "src/components/ui/input-group";
15
16 const Combobox = ComboboxPrimitive.Root;
17
18 function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
19 return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
20 }
21
22 function ComboboxTrigger({
23 className,
24 children,
25 ...props
26 }: ComboboxPrimitive.Trigger.Props) {
27 return (
28 <ComboboxPrimitive.Trigger
29 data-slot="combobox-trigger"
30 className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
31 {...props}
32 >
33 {children}
34 <ChevronDownIcon
35 data-slot="combobox-trigger-icon"
36 className="pointer-events-none size-4 text-muted-foreground"
37 />
38 </ComboboxPrimitive.Trigger>
39 );
40 }
41
42 function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
43 return (
44 <ComboboxPrimitive.Clear
45 data-slot="combobox-clear"
46 render={<InputGroupButton variant="ghost" size="icon-xs" />}
47 className={cn(className)}
48 {...props}
49 >
50 <XIcon className="pointer-events-none" />
51 </ComboboxPrimitive.Clear>
52 );
53 }
54
55 function ComboboxInput({
56 className,
57 children,
58 disabled = false,
59 showTrigger = true,
60 showClear = false,
61 ...props
62 }: ComboboxPrimitive.Input.Props & {
63 showTrigger?: boolean;
64 showClear?: boolean;
65 }) {
66 return (
67 <InputGroup className={cn("w-auto", className)}>
68 <ComboboxPrimitive.Input
69 render={<InputGroupInput disabled={disabled} />}
70 {...props}
71 />
72 <InputGroupAddon align="inline-end">
73 {showTrigger && (
74 <InputGroupButton
75 size="icon-xs"
76 variant="ghost"
77 asChild
78 data-slot="input-group-button"
79 className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
80 disabled={disabled}
81 >
82 <ComboboxTrigger />
83 </InputGroupButton>
84 )}
85 {showClear && <ComboboxClear disabled={disabled} />}
86 </InputGroupAddon>
87 {children}
88 </InputGroup>
89 );
90 }
91
92 function ComboboxContent({
93 className,
94 side = "bottom",
95 sideOffset = 6,
96 align = "start",
97 alignOffset = 0,
98 anchor,
99 ...props
100 }: ComboboxPrimitive.Popup.Props &
101 Pick<
102 ComboboxPrimitive.Positioner.Props,
103 "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
104 >) {
105 return (
106 <ComboboxPrimitive.Portal>
107 <ComboboxPrimitive.Positioner
108 side={side}
109 sideOffset={sideOffset}
110 align={align}
111 alignOffset={alignOffset}
112 anchor={anchor}
113 className="isolate z-50"
114 >
115 <ComboboxPrimitive.Popup
116 data-slot="combobox-content"
117 data-chips={!!anchor}
118 className={cn(
119 "group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
120 className
121 )}
122 {...props}
123 />
124 </ComboboxPrimitive.Positioner>
125 </ComboboxPrimitive.Portal>
126 );
127 }
128
129 function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
130 return (
131 <ComboboxPrimitive.List
132 data-slot="combobox-list"
133 className={cn(
134 "max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
135 className
136 )}
137 {...props}
138 />
139 );
140 }
141
142 function ComboboxItem({
143 className,
144 children,
145 ...props
146 }: ComboboxPrimitive.Item.Props) {
147 return (
148 <ComboboxPrimitive.Item
149 data-slot="combobox-item"
150 className={cn(
151 "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
152 className
153 )}
154 {...props}
155 >
156 {children}
157 <ComboboxPrimitive.ItemIndicator
158 data-slot="combobox-item-indicator"
159 render={
160 <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
161 }
162 >
163 <CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
164 </ComboboxPrimitive.ItemIndicator>
165 </ComboboxPrimitive.Item>
166 );
167 }
168
169 function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
170 return (
171 <ComboboxPrimitive.Group
172 data-slot="combobox-group"
173 className={cn(className)}
174 {...props}
175 />
176 );
177 }
178
179 function ComboboxLabel({
180 className,
181 ...props
182 }: ComboboxPrimitive.GroupLabel.Props) {
183 return (
184 <ComboboxPrimitive.GroupLabel
185 data-slot="combobox-label"
186 className={cn(
187 "px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
188 className
189 )}
190 {...props}
191 />
192 );
193 }
194
195 function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
196 return (
197 <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
198 );
199 }
200
201 function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
202 return (
203 <ComboboxPrimitive.Empty
204 data-slot="combobox-empty"
205 className={cn(
206 "hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
207 className
208 )}
209 {...props}
210 />
211 );
212 }
213
214 function ComboboxSeparator({
215 className,
216 ...props
217 }: ComboboxPrimitive.Separator.Props) {
218 return (
219 <ComboboxPrimitive.Separator
220 data-slot="combobox-separator"
221 className={cn("-mx-1 my-1 h-px bg-border", className)}
222 {...props}
223 />
224 );
225 }
226
227 function ComboboxChips({
228 className,
229 ...props
230 }: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
231 ComboboxPrimitive.Chips.Props) {
232 return (
233 <ComboboxPrimitive.Chips
234 data-slot="combobox-chips"
235 className={cn(
236 "flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
237 className
238 )}
239 {...props}
240 />
241 );
242 }
243
244 function ComboboxChip({
245 className,
246 children,
247 showRemove = true,
248 ...props
249 }: ComboboxPrimitive.Chip.Props & {
250 showRemove?: boolean;
251 }) {
252 return (
253 <ComboboxPrimitive.Chip
254 data-slot="combobox-chip"
255 className={cn(
256 "flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
257 className
258 )}
259 {...props}
260 >
261 {children}
262 {showRemove && (
263 <ComboboxPrimitive.ChipRemove
264 render={<Button variant="ghost" size="icon-xs" />}
265 className="-ml-1 opacity-50 hover:opacity-100"
266 data-slot="combobox-chip-remove"
267 >
268 <XIcon className="pointer-events-none" />
269 </ComboboxPrimitive.ChipRemove>
270 )}
271 </ComboboxPrimitive.Chip>
272 );
273 }
274
275 function ComboboxChipsInput({
276 className,
277 children,
278 ...props
279 }: ComboboxPrimitive.Input.Props) {
280 return (
281 <ComboboxPrimitive.Input
282 data-slot="combobox-chip-input"
283 className={cn("min-w-16 flex-1 outline-none", className)}
284 {...props}
285 />
286 );
287 }
288
289 // eslint-disable-next-line react-refresh/only-export-components
290 export function useComboboxAnchor() {
291 return React.useRef<HTMLDivElement | null>(null);
292 }
293
294 export {
295 Combobox,
296 ComboboxInput,
297 ComboboxContent,
298 ComboboxList,
299 ComboboxItem,
300 ComboboxGroup,
301 ComboboxLabel,
302 ComboboxCollection,
303 ComboboxEmpty,
304 ComboboxSeparator,
305 ComboboxChips,
306 ComboboxChip,
307 ComboboxChipsInput,
308 ComboboxTrigger,
309 ComboboxValue,
310 };
311