1"use client";
2
3import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";
4import { ChevronsUpDownIcon, XIcon } from "lucide-react";
5
6import { cn } from "~/lib/utils";
7import { Input } from "~/components/ui/input";
8import { ScrollArea } from "~/components/ui/scroll-area";
9
10const Autocomplete = AutocompletePrimitive.Root;
11
12function AutocompleteInput({
13 className,
14 showTrigger = false,
15 showClear = false,
16 startAddon,
17 size,
18 ...props
19}: Omit<AutocompletePrimitive.Input.Props, "size"> & {
20 showTrigger?: boolean;
21 showClear?: boolean;
22 startAddon?: React.ReactNode;
23 size?: "sm" | "default" | "lg" | number;
24 ref?: React.Ref<HTMLInputElement>;
25}) {
26 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
27
28 return (
29 <div className="relative not-has-[>*.w-full]:w-fit w-full text-foreground has-disabled:opacity-64">
30 {startAddon && (
31 <div
32 aria-hidden="true"
33 className="[&_svg]:-mx-0.5 pointer-events-none has-[button]:pointer-events-auto 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"
34 data-slot="autocomplete-start-addon"
35 >
36 {startAddon}
37 </div>
38 )}
39 <AutocompletePrimitive.Input
40 className={cn(
41 startAddon &&
42 "data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=autocomplete-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(8)-1px)]",
43 sizeValue === "sm"
44 ? "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-6.5"
45 : "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-7",
46 className,
47 )}
48 data-slot="autocomplete-input"
49 render={<Input nativeInput size={sizeValue} />}
50 {...props}
51 />
52 {showTrigger && (
53 <AutocompleteTrigger
54 className={cn(
55 "-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-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-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",
56 sizeValue === "sm" ? "end-0" : "end-0.5",
57 )}
58 >
59 <AutocompletePrimitive.Icon data-slot="autocomplete-icon">
60 <ChevronsUpDownIcon />
61 </AutocompletePrimitive.Icon>
62 </AutocompleteTrigger>
63 )}
64 {showClear && (
65 <AutocompleteClear
66 className={cn(
67 "-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-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-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",
68 sizeValue === "sm" ? "end-0" : "end-0.5",
69 )}
70 >
71 <XIcon />
72 </AutocompleteClear>
73 )}
74 </div>
75 );
76}
77
78function AutocompletePopup({
79 className,
80 children,
81 side = "bottom",
82 sideOffset = 4,
83 alignOffset,
84 align = "start",
85 anchor,
86 ...props
87}: AutocompletePrimitive.Popup.Props & {
88 align?: AutocompletePrimitive.Positioner.Props["align"];
89 sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"];
90 alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"];
91 side?: AutocompletePrimitive.Positioner.Props["side"];
92 anchor?: AutocompletePrimitive.Positioner.Props["anchor"];
93}) {
94 return (
95 <AutocompletePrimitive.Portal>
96 <AutocompletePrimitive.Positioner
97 align={align}
98 alignOffset={alignOffset}
99 anchor={anchor}
100 className="z-[130] select-none"
101 data-slot="autocomplete-positioner"
102 side={side}
103 sideOffset={sideOffset}
104 >
105 <span
106 className={cn(
107 "relative flex max-h-full min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
108 className,
109 )}
110 >
111 <AutocompletePrimitive.Popup
112 className="flex max-h-[min(var(--available-height),23rem)] flex-1 flex-col text-foreground"
113 data-slot="autocomplete-popup"
114 {...props}
115 >
116 {children}
117 </AutocompletePrimitive.Popup>
118 </span>
119 </AutocompletePrimitive.Positioner>
120 </AutocompletePrimitive.Portal>
121 );
122}
123
124function AutocompleteItem({ className, children, ...props }: AutocompletePrimitive.Item.Props) {
125 return (
126 <AutocompletePrimitive.Item
127 className={cn(
128 "flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base outline-none hover:bg-accent data-disabled:pointer-events-none data-selected:bg-accent/50 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*='text-'])]:text-muted-foreground",
129 className,
130 )}
131 data-slot="autocomplete-item"
132 {...props}
133 >
134 {children}
135 </AutocompletePrimitive.Item>
136 );
137}
138
139function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {
140 return (
141 <AutocompletePrimitive.Group
142 className={cn("[[role=group]+&]:mt-1.5", className)}
143 data-slot="autocomplete-group"
144 {...props}
145 />
146 );
147}
148
149const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
150
151function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) {
152 return (
153 <AutocompletePrimitive.GroupLabel
154 className={cn(GROUP_LABEL_CLASS_NAME, className)}
155 data-slot="autocomplete-group-label"
156 {...props}
157 />
158 );
159}
160
161// A group label for virtualized lists, where rows cannot be wrapped in a group.
162function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) {
163 return (
164 <div
165 className={cn(GROUP_LABEL_CLASS_NAME, className)}
166 data-slot="autocomplete-group-label"
167 role="presentation"
168 {...props}
169 />
170 );
171}
172
173function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) {
174 return (
175 <AutocompletePrimitive.List
176 className={cn("size-full min-w-0", className)}
177 data-slot="autocomplete-list"
178 {...props}
179 />
180 );
181}
182
183function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty.Props) {
184 return (
185 <AutocompletePrimitive.Empty
186 className={cn(
187 "not-empty:p-2 text-center text-base text-muted-foreground sm:text-sm",
188 className,
189 )}
190 data-slot="autocomplete-empty"
191 {...props}
192 />
193 );
194}
195
196function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {
197 return (
198 <ScrollArea scrollbarGutter scrollFade>
199 <AutocompletePrimitive.List
200 className={cn("not-empty:scroll-py-1 not-empty:p-1", className)}
201 data-slot="autocomplete-list"
202 {...props}
203 />
204 </ScrollArea>
205 );
206}
207
208function AutocompleteClear({ className, ...props }: AutocompletePrimitive.Clear.Props) {
209 return (
210 <AutocompletePrimitive.Clear
211 className={cn(
212 "-translate-y-1/2 absolute end-0.5 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-[color,background-color,box-shadow,opacity] pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
213 className,
214 )}
215 data-slot="autocomplete-clear"
216 {...props}
217 >
218 <XIcon />
219 </AutocompletePrimitive.Clear>
220 );
221}
222
223function AutocompleteCollection({ ...props }: AutocompletePrimitive.Collection.Props) {
224 return <AutocompletePrimitive.Collection data-slot="autocomplete-collection" {...props} />;
225}
226
227function AutocompleteTrigger({
228 className,
229 children,
230 ...props
231}: AutocompletePrimitive.Trigger.Props) {
232 return (
233 <AutocompletePrimitive.Trigger
234 className={className}
235 data-slot="autocomplete-trigger"
236 {...props}
237 >
238 {children}
239 </AutocompletePrimitive.Trigger>
240 );
241}
242
243export {
244 Autocomplete,
245 AutocompleteInput,
246 AutocompleteTrigger,
247 AutocompletePopup,
248 AutocompleteItem,
249 AutocompleteGroup,
250 AutocompleteGroupLabel,
251 AutocompleteEmpty,
252 AutocompleteList,
253 AutocompleteListHeading,
254 AutocompleteListVirtualized,
255 AutocompleteClear,
256 AutocompleteCollection,
257};