1"use client";
2
3import { Menu as MenuPrimitive } from "@base-ui/react/menu";
4import { CheckIcon, ChevronRightIcon } from "lucide-react";
5import type * as React from "react";
6
7import { cn } from "~/lib/utils";
8import { buttonVariants } from "./button";
9
10const Menu = MenuPrimitive.Root;
11
12function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) {
13 return (
14 <MenuPrimitive.Trigger className={className} data-slot="menu-trigger" {...props}>
15 {children}
16 </MenuPrimitive.Trigger>
17 );
18}
19
20function MenuPopup({
21 children,
22 className,
23 sideOffset = 4,
24 align = "center",
25 alignOffset,
26 side = "bottom",
27 anchor,
28 keepMounted = false,
29 ...props
30}: MenuPrimitive.Popup.Props & {
31 align?: MenuPrimitive.Positioner.Props["align"];
32 sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
33 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
34 side?: MenuPrimitive.Positioner.Props["side"];
35 anchor?: MenuPrimitive.Positioner.Props["anchor"];
36 keepMounted?: boolean;
37}) {
38 return (
39 <MenuPrimitive.Portal keepMounted={keepMounted}>
40 <MenuPrimitive.Positioner
41 align={align}
42 alignOffset={alignOffset}
43 anchor={anchor}
44 className="z-[130]"
45 data-slot="menu-positioner"
46 side={side}
47 sideOffset={sideOffset}
48 >
49 <MenuPrimitive.Popup
50 className={cn(
51 "dropdown-glass relative flex origin-(--transform-origin) rounded-lg shadow-[0_16px_40px_-18px_rgb(0_0_0/55%)] outline-none focus:outline-none dark:shadow-[0_18px_44px_-18px_rgb(0_0_0/80%)]",
52 // Portaled menus can overlap an Electron window drag region (e.g.
53 // the Review panel header). Drag hit-testing ignores z-index, so
54 // the topmost row would stay unhoverable without this opt-out.
55 "[-webkit-app-region:no-drag]",
56 // Menus size to their content from one minimum, never past the viewport.
57 "min-w-[min(10rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)]",
58 className,
59 )}
60 data-slot="menu-popup"
61 {...props}
62 >
63 <div className="max-h-(--available-height) w-full overflow-y-auto p-1">{children}</div>
64 </MenuPrimitive.Popup>
65 </MenuPrimitive.Positioner>
66 </MenuPrimitive.Portal>
67 );
68}
69
70function MenuGroup(props: MenuPrimitive.Group.Props) {
71 return <MenuPrimitive.Group data-slot="menu-group" {...props} />;
72}
73
74function MenuItem({
75 className,
76 inset,
77 density = "default",
78 variant = "default",
79 ...props
80}: MenuPrimitive.Item.Props & {
81 inset?: boolean;
82 density?: "default" | "touch";
83 variant?: "default" | "destructive" | "ghost";
84}) {
85 return (
86 <MenuPrimitive.Item
87 className={cn(
88 "[&>svg]:-mx-0.5 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:[&>svg:not([class*='text-'])]:text-current [&>svg]:pointer-events-none [&>svg]:shrink-0",
89 variant === "ghost" &&
90 buttonVariants({
91 variant: "ghost",
92 size: "compact",
93 className: "h-auto min-h-7 w-full sm:text-xs",
94 }),
95 density === "touch" && "min-h-10 sm:min-h-10",
96 className,
97 )}
98 data-density={density}
99 data-inset={inset}
100 data-slot="menu-item"
101 data-variant={variant}
102 {...props}
103 />
104 );
105}
106
107// Trim font leading so visible letters center with the icons in touch rows.
108// Symmetric padding keeps accents and descenders inside truncated labels.
109function MenuItemLabel({ className, ...props }: React.ComponentProps<"span">) {
110 return (
111 <span
112 data-slot="menu-item-label"
113 className={cn(
114 "min-w-0 truncate in-data-[density=touch]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:in-data-[density=touch]:py-[0.5em]",
115 className,
116 )}
117 {...props}
118 />
119 );
120}
121
122function MenuCheckboxItem({
123 className,
124 children,
125 checked,
126 variant = "default",
127 ...props
128}: MenuPrimitive.CheckboxItem.Props & {
129 variant?: "default" | "switch";
130}) {
131 return (
132 <MenuPrimitive.CheckboxItem
133 checked={checked}
134 className={cn(
135 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center gap-2 rounded-sm py-1 ps-2 text-base text-foreground outline-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-highlighted: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",
136 variant === "switch"
137 ? "grid-cols-[1fr_auto] gap-4 pe-1.5"
138 : "grid-cols-[1rem_minmax(0,1fr)] pe-4",
139 className,
140 )}
141 data-slot="menu-checkbox-item"
142 {...props}
143 >
144 {variant === "switch" ? (
145 <>
146 <span className="col-start-1">{children}</span>
147 <MenuPrimitive.CheckboxItemIndicator
148 className="inset-shadow-[0_1px_--theme(--color-black/4%)] inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(4)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-64 sm:[--thumb-size:--spacing(3)]"
149 keepMounted
150 >
151 <span className="pointer-events-none block aspect-square h-full in-[[data-slot=menu-checkbox-item][data-checked]]:origin-[var(--thumb-size)_50%] origin-left in-[[data-slot=menu-checkbox-item][data-checked]]:translate-x-[calc(var(--thumb-size)-4px)] in-[[data-slot=menu-checkbox-item]:active]:not-data-disabled:scale-x-110 in-[[data-slot=menu-checkbox-item]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.10)] rounded-(--thumb-size) bg-background shadow-sm/5 [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s]" />
152 </MenuPrimitive.CheckboxItemIndicator>
153 </>
154 ) : (
155 <>
156 <MenuPrimitive.CheckboxItemIndicator className="col-start-1">
157 <svg
158 fill="none"
159 height="24"
160 stroke="currentColor"
161 strokeLinecap="round"
162 strokeLinejoin="round"
163 strokeWidth="2"
164 viewBox="0 0 24 24"
165 width="24"
166 xmlns="http://www.w3.org/2000/svg"
167 >
168 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
169 </svg>
170 </MenuPrimitive.CheckboxItemIndicator>
171 <span className="col-start-2">{children}</span>
172 </>
173 )}
174 </MenuPrimitive.CheckboxItem>
175 );
176}
177
178function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
179 return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />;
180}
181
182function MenuRadioItem({
183 className,
184 children,
185 hideIndicator: _hideIndicator = false,
186 ...props
187}: MenuPrimitive.RadioItem.Props & {
188 hideIndicator?: boolean;
189}) {
190 return (
191 <MenuPrimitive.RadioItem
192 className={cn(
193 "[&_svg]:-mx-0.5 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 text-foreground outline-none data-checked:bg-foreground/[0.08] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-highlighted: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",
194 className,
195 )}
196 data-slot="menu-radio-item"
197 {...props}
198 >
199 <span className="min-w-0 flex-1">{children}</span>
200 </MenuPrimitive.RadioItem>
201 );
202}
203
204function MenuRadioItemIndicator({
205 className,
206 children,
207 ...props
208}: MenuPrimitive.RadioItemIndicator.Props) {
209 return (
210 <MenuPrimitive.RadioItemIndicator
211 aria-hidden
212 className={cn("flex shrink-0", className)}
213 data-slot="menu-radio-item-indicator"
214 {...props}
215 >
216 {children ?? <CheckIcon className="size-3.5" />}
217 </MenuPrimitive.RadioItemIndicator>
218 );
219}
220
221function MenuGroupLabel({
222 className,
223 inset,
224 ...props
225}: MenuPrimitive.GroupLabel.Props & {
226 inset?: boolean;
227}) {
228 return (
229 <MenuPrimitive.GroupLabel
230 className={cn(
231 "px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:ps-9 sm:data-inset:ps-8",
232 className,
233 )}
234 data-inset={inset}
235 data-slot="menu-label"
236 {...props}
237 />
238 );
239}
240
241function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
242 return (
243 <MenuPrimitive.Separator
244 className={cn("mx-2 my-1 h-px bg-border", className)}
245 data-slot="menu-separator"
246 {...props}
247 />
248 );
249}
250
251function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
252 return (
253 <kbd
254 className={cn(
255 "ms-auto font-medium font-sans text-secondary-label text-xs tracking-widest",
256 className,
257 )}
258 data-slot="menu-shortcut"
259 {...props}
260 />
261 );
262}
263
264function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
265 return <MenuPrimitive.SubmenuRoot data-slot="menu-sub" {...props} />;
266}
267
268function MenuSubTrigger({
269 className,
270 inset,
271 density = "default",
272 children,
273 ...props
274}: MenuPrimitive.SubmenuTrigger.Props & {
275 inset?: boolean;
276 density?: "default" | "touch";
277}) {
278 return (
279 <MenuPrimitive.SubmenuTrigger
280 className={cn(
281 // Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the
282 // same column as the items around it, so its icon has to align and dim
283 // with theirs. Scoped away from the last child because the chevron is
284 // also a direct svg — on a sub-trigger with no leading icon it is the
285 // only one, and these rules would take away its `ms-auto` alignment.
286 "[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open: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:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",
287 density === "touch" && "min-h-10 sm:min-h-10",
288 className,
289 )}
290 data-density={density}
291 data-inset={inset}
292 data-slot="menu-sub-trigger"
293 {...props}
294 >
295 {children}
296 <ChevronRightIcon className="-me-0.5 ms-auto opacity-80" />
297 </MenuPrimitive.SubmenuTrigger>
298 );
299}
300
301function MenuSubPopup({
302 className,
303 sideOffset = 0,
304 alignOffset,
305 align = "start",
306 ...props
307}: MenuPrimitive.Popup.Props & {
308 align?: MenuPrimitive.Positioner.Props["align"];
309 sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
310 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
311}) {
312 const defaultAlignOffset = align !== "center" ? -5 : undefined;
313
314 return (
315 <MenuPopup
316 align={align}
317 alignOffset={alignOffset ?? defaultAlignOffset}
318 className={className}
319 data-slot="menu-sub-content"
320 side="inline-end"
321 sideOffset={sideOffset}
322 {...props}
323 />
324 );
325}
326
327export {
328 Menu,
329 Menu as DropdownMenu,
330 MenuTrigger,
331 MenuTrigger as DropdownMenuTrigger,
332 MenuPopup,
333 MenuPopup as DropdownMenuContent,
334 MenuGroup,
335 MenuGroup as DropdownMenuGroup,
336 MenuItem,
337 MenuItemLabel,
338 MenuItem as DropdownMenuItem,
339 MenuCheckboxItem,
340 MenuCheckboxItem as DropdownMenuCheckboxItem,
341 MenuRadioGroup,
342 MenuRadioGroup as DropdownMenuRadioGroup,
343 MenuRadioItem,
344 MenuRadioItem as DropdownMenuRadioItem,
345 MenuRadioItemIndicator,
346 MenuGroupLabel,
347 MenuGroupLabel as DropdownMenuLabel,
348 MenuSeparator,
349 MenuSeparator as DropdownMenuSeparator,
350 MenuShortcut,
351 MenuShortcut as DropdownMenuShortcut,
352 MenuSub,
353 MenuSub as DropdownMenuSub,
354 MenuSubTrigger,
355 MenuSubTrigger as DropdownMenuSubTrigger,
356 MenuSubPopup,
357 MenuSubPopup as DropdownMenuSubContent,
358};