Open History to see the last commit
1import { Menu as MenuPrimitive } from "@base-ui/react/menu";
2import { CheckIcon, ChevronRightIcon, SearchIcon } from "lucide-react";
3import type * as React from "react";
4
5import { cn } from "~/lib/utils";
6import { buttonVariants } from "./button";
7import { renderClosingPositioner } from "./closing-positioner";
8import { Input, type InputProps } from "./input";
9import { Spinner } from "./spinner";
10
11const Menu = MenuPrimitive.Root;
12
13function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) {
14 return (
15 <MenuPrimitive.Trigger className={className} data-slot="menu-trigger" {...props}>
16 {children}
17 </MenuPrimitive.Trigger>
18 );
19}
20
21function MenuPopup({
22 children,
23 className,
24 sideOffset = 4,
25 align = "center",
26 alignOffset,
27 side = "bottom",
28 collisionAvoidance,
29 anchor,
30 keepMounted = false,
31 width = "default",
32 ...props
33}: MenuPrimitive.Popup.Props & {
34 align?: MenuPrimitive.Positioner.Props["align"];
35 sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
36 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
37 side?: MenuPrimitive.Positioner.Props["side"];
38 collisionAvoidance?: MenuPrimitive.Positioner.Props["collisionAvoidance"];
39 anchor?: MenuPrimitive.Positioner.Props["anchor"];
40 keepMounted?: boolean;
41 width?: "default" | "content";
42}) {
43 return (
44 <MenuPrimitive.Portal keepMounted={keepMounted}>
45 <MenuPrimitive.Positioner
46 align={align}
47 alignOffset={alignOffset}
48 anchor={anchor}
49 collisionAvoidance={collisionAvoidance}
50 className="z-[130]"
51 data-slot="menu-positioner"
52 render={renderClosingPositioner}
53 side={side}
54 sideOffset={sideOffset}
55 >
56 <MenuPrimitive.Popup
57 className={cn(
58 "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%)]",
59 // Electron drag hit-testing ignores z-index; opt portaled menus out to keep overlapping rows
60 // hoverable.
61 "[-webkit-app-region:no-drag]",
62 "max-w-[calc(100vw-2rem)]",
63 width === "content" ? "w-max min-w-0" : "min-w-[min(10rem,calc(100vw-2rem))]",
64 className,
65 )}
66 data-slot="menu-popup"
67 {...props}
68 >
69 <div className="max-h-(--available-height) w-full overflow-y-auto p-1">{children}</div>
70 </MenuPrimitive.Popup>
71 </MenuPrimitive.Positioner>
72 </MenuPrimitive.Portal>
73 );
74}
75
76/** A menu's filter field, drawn like `ComboboxSearchInput` so every searchable popup matches. */
77function MenuSearchInput({
78 busy = false,
79 onKeyDown,
80 ...props
81}: Omit<InputProps, "className" | "size" | "unstyled" | "nativeInput"> & { busy?: boolean }) {
82 return (
83 <div className="min-w-0 shrink-0 px-2 pt-1.5 pb-1">
84 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
85 {busy ? (
86 <Spinner
87 aria-hidden="true"
88 size="md"
89 tone="muted"
90 className="pointer-events-none absolute top-1.5 left-0"
91 />
92 ) : (
93 <SearchIcon
94 aria-hidden="true"
95 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
96 />
97 )}
98 <Input
99 {...props}
100 className="rounded-none bg-transparent text-sm [&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
101 size="sm"
102 unstyled
103 onKeyDown={(event) => {
104 // Menu typeahead would otherwise take the letters; arrows still reach the items.
105 if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation();
106 onKeyDown?.(event);
107 }}
108 />
109 </div>
110 </div>
111 );
112}
113
114function MenuGroup(props: MenuPrimitive.Group.Props) {
115 return <MenuPrimitive.Group data-slot="menu-group" {...props} />;
116}
117
118function MenuItem({
119 className,
120 inset,
121 density = "default",
122 variant = "default",
123 ...props
124}: MenuPrimitive.Item.Props & {
125 inset?: boolean;
126 density?: "default" | "touch";
127 variant?: "default" | "destructive" | "ghost";
128}) {
129 return (
130 <MenuPrimitive.Item
131 className={cn(
132 "[&>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",
133 variant === "ghost" &&
134 buttonVariants({
135 variant: "ghost",
136 size: "compact",
137 className: "h-auto min-h-7 w-full sm:text-xs",
138 }),
139 density === "touch" && "min-h-10 sm:min-h-10",
140 className,
141 )}
142 data-density={density}
143 data-inset={inset}
144 data-slot="menu-item"
145 data-variant={variant}
146 {...props}
147 />
148 );
149}
150
151function MenuItemLabel({ className, ...props }: React.ComponentProps<"span">) {
152 return (
153 <span
154 data-slot="menu-item-label"
155 className={cn(
156 "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]",
157 className,
158 )}
159 {...props}
160 />
161 );
162}
163
164function MenuCheckboxItem({
165 className,
166 children,
167 checked,
168 variant = "default",
169 ...props
170}: MenuPrimitive.CheckboxItem.Props & {
171 variant?: "default" | "icon" | "switch";
172}) {
173 return (
174 <MenuPrimitive.CheckboxItem
175 checked={checked}
176 className={cn(
177 "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",
178 variant === "switch"
179 ? "grid-cols-[1fr_auto] gap-4 pe-1.5"
180 : "grid-cols-[1rem_minmax(0,1fr)] pe-4",
181 variant === "icon" && "grid-cols-[0.625rem_minmax(0,1fr)]",
182 className,
183 )}
184 data-slot="menu-checkbox-item"
185 {...props}
186 >
187 {variant === "switch" ? (
188 <>
189 <span className="col-start-1">{children}</span>
190 <MenuPrimitive.CheckboxItemIndicator
191 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)]"
192 keepMounted
193 >
194 <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]" />
195 </MenuPrimitive.CheckboxItemIndicator>
196 </>
197 ) : (
198 <>
199 <MenuPrimitive.CheckboxItemIndicator
200 className={cn("col-start-1", variant === "icon" && "-mx-0.5")}
201 >
202 <svg
203 className={variant === "icon" ? "size-3.5" : undefined}
204 fill="none"
205 height="24"
206 stroke="currentColor"
207 strokeLinecap="round"
208 strokeLinejoin="round"
209 strokeWidth="1.75"
210 viewBox="0 0 24 24"
211 width="24"
212 xmlns="http://www.w3.org/2000/svg"
213 >
214 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
215 </svg>
216 </MenuPrimitive.CheckboxItemIndicator>
217 <span className="col-start-2">{children}</span>
218 </>
219 )}
220 </MenuPrimitive.CheckboxItem>
221 );
222}
223
224function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
225 return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />;
226}
227
228function MenuRadioItem({
229 className,
230 children,
231 hideIndicator: _hideIndicator = false,
232 ...props
233}: MenuPrimitive.RadioItem.Props & {
234 hideIndicator?: boolean;
235}) {
236 return (
237 <MenuPrimitive.RadioItem
238 className={cn(
239 "[&_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",
240 className,
241 )}
242 data-slot="menu-radio-item"
243 {...props}
244 >
245 <span className="min-w-0 flex-1">{children}</span>
246 </MenuPrimitive.RadioItem>
247 );
248}
249
250function MenuRadioItemIndicator({
251 className,
252 children,
253 ...props
254}: MenuPrimitive.RadioItemIndicator.Props) {
255 return (
256 <MenuPrimitive.RadioItemIndicator
257 aria-hidden
258 className={cn("flex shrink-0", className)}
259 data-slot="menu-radio-item-indicator"
260 {...props}
261 >
262 {children ?? <CheckIcon className="size-3.5" />}
263 </MenuPrimitive.RadioItemIndicator>
264 );
265}
266
267function MenuGroupLabel({
268 className,
269 inset,
270 ...props
271}: MenuPrimitive.GroupLabel.Props & {
272 inset?: boolean;
273}) {
274 return (
275 <MenuPrimitive.GroupLabel
276 className={cn(
277 "px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:ps-9 sm:data-inset:ps-8",
278 className,
279 )}
280 data-inset={inset}
281 data-slot="menu-label"
282 {...props}
283 />
284 );
285}
286
287function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
288 return (
289 <MenuPrimitive.Separator
290 className={cn("mx-2 my-1 h-px bg-border", className)}
291 data-slot="menu-separator"
292 {...props}
293 />
294 );
295}
296
297function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
298 return (
299 <kbd
300 className={cn(
301 "ms-auto font-medium font-sans text-secondary-label text-xs tracking-widest",
302 className,
303 )}
304 data-slot="menu-shortcut"
305 {...props}
306 />
307 );
308}
309
310// Trailing value on a menu row, such as the time a snooze preset wakes at.
311// Reads as text at the row's size; MenuShortcut is only for key chords.
312function MenuItemDetail({ className, ...props }: React.ComponentProps<"span">) {
313 return (
314 <span
315 className={cn("ms-auto shrink-0 ps-4 text-secondary-label tabular-nums", className)}
316 data-slot="menu-item-detail"
317 {...props}
318 />
319 );
320}
321
322function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
323 return <MenuPrimitive.SubmenuRoot data-slot="menu-sub" {...props} />;
324}
325
326function MenuSubTrigger({
327 className,
328 inset,
329 density = "default",
330 children,
331 ...props
332}: MenuPrimitive.SubmenuTrigger.Props & {
333 inset?: boolean;
334 density?: "default" | "touch";
335}) {
336 return (
337 <MenuPrimitive.SubmenuTrigger
338 className={cn(
339 // Exclude the last SVG so leading-icon margin rules cannot remove the chevron's ms-auto alignment.
340 "[&>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",
341 density === "touch" && "min-h-10 sm:min-h-10",
342 className,
343 )}
344 data-density={density}
345 data-inset={inset}
346 data-slot="menu-sub-trigger"
347 {...props}
348 >
349 {children}
350 <ChevronRightIcon className="-me-0.5 ms-auto opacity-80" />
351 </MenuPrimitive.SubmenuTrigger>
352 );
353}
354
355function MenuSubPopup({
356 className,
357 sideOffset = 0,
358 alignOffset,
359 align = "start",
360 ...props
361}: MenuPrimitive.Popup.Props & {
362 align?: MenuPrimitive.Positioner.Props["align"];
363 sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
364 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
365}) {
366 const defaultAlignOffset = align !== "center" ? -5 : undefined;
367
368 return (
369 <MenuPopup
370 align={align}
371 alignOffset={alignOffset ?? defaultAlignOffset}
372 className={className}
373 data-slot="menu-sub-content"
374 side="inline-end"
375 sideOffset={sideOffset}
376 {...props}
377 />
378 );
379}
380
381export {
382 Menu,
383 Menu as DropdownMenu,
384 MenuTrigger,
385 MenuTrigger as DropdownMenuTrigger,
386 MenuPopup,
387 MenuPopup as DropdownMenuContent,
388 MenuSearchInput,
389 MenuGroup,
390 MenuGroup as DropdownMenuGroup,
391 MenuItem,
392 MenuItemLabel,
393 MenuItem as DropdownMenuItem,
394 MenuCheckboxItem,
395 MenuCheckboxItem as DropdownMenuCheckboxItem,
396 MenuRadioGroup,
397 MenuRadioGroup as DropdownMenuRadioGroup,
398 MenuRadioItem,
399 MenuRadioItem as DropdownMenuRadioItem,
400 MenuRadioItemIndicator,
401 MenuGroupLabel,
402 MenuGroupLabel as DropdownMenuLabel,
403 MenuSeparator,
404 MenuSeparator as DropdownMenuSeparator,
405 MenuItemDetail,
406 MenuShortcut,
407 MenuShortcut as DropdownMenuShortcut,
408 MenuSub,
409 MenuSub as DropdownMenuSub,
410 MenuSubTrigger,
411 MenuSubTrigger as DropdownMenuSubTrigger,
412 MenuSubPopup,
413 MenuSubPopup as DropdownMenuSubContent,
414};