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