import { Menu as Primitive } from "@base-ui/react/menu";
import type { ReactNode } from "react";
import { mergeClassName } from "./class-name.ts";
import { CheckGlyph } from "./glyphs.tsx";
// The parts — every one of them EXCEPT `GroupLabel`, and every one except `Group` and
// `RadioGroup`, which are wrapped below. That omission is not tidying: it is the only thing
// that actually prevents the bug. `Menu.GroupLabel` reads its group's context, so a caption
// written as a SIBLING of the group throws at render, and a production build reports that as
// `Base UI error #31` plus a link — a number to go look up, naming neither the file, nor the
// part, nor the component it came from. A comment cannot stop that; not shipping the part
// can. The caption is a prop on the group instead, so the wrong arrangement has nowhere to be
// written. `Select.GroupLabel` and `Combobox.GroupLabel` throw the same way (#56 and #18) and
// ARE exported — see the note beside them in index.ts for why that is the right call there.
export const Menu = Primitive.Root;
export const MenuTrigger = Primitive.Trigger;
export const MenuPortal = Primitive.Portal;
export const MenuPositioner = Primitive.Positioner;
export const MenuPopup = Primitive.Popup;
export const MenuBackdrop = Primitive.Backdrop;
export const MenuArrow = Primitive.Arrow;
export const MenuLinkItem = Primitive.LinkItem;
export const MenuCheckboxItem = Primitive.CheckboxItem;
export const MenuCheckboxItemIndicator = Primitive.CheckboxItemIndicator;
export const MenuRadioItemIndicator = Primitive.RadioItemIndicator;
export const MenuSubmenuRoot = Primitive.SubmenuRoot;
export const MenuSubmenuTrigger = Primitive.SubmenuTrigger;
export type MenuContentProps = Primitive.Popup.Props & {
/**
* Accessible name for the menu. Usually leave this out: Base UI already points the popup's
* `aria-labelledby` at whichever trigger opened it, so a name here REPLACES the words on
* the button a screen reader just read. Pass it only when the menu is about something the
* trigger does not say.
*/
label?: string;
/** Where the portal mounts. Defaults to `
`. */
container?: Primitive.Portal.Props["container"];
/**
* Positioner props: `side`, `align`, `sideOffset`, `collisionPadding` and the stacking order. The
* default `z-[60]` puts the menu above the overlay layer (z-50: dialog, drawer) so a menu opened
* inside a dialog is not painted behind it; raise it with `positionerProps={{ className: "z-[80]"
* }}` if the app stacks something higher.
*/
positionerProps?: Primitive.Positioner.Props;
};
/**
* A dropdown for choices, such as the account menu, a row's actions or a picker. Portal, positioner
* and popup composed once, for the same reason the dialog's are: the arrangement is not optional
* and getting it wrong throws a number.
*
* Roving focus, typeahead, escape, outside-dismiss and focus return all come from Base UI.
* Nothing here re-implements any of them.
*/
export function MenuContent({
className,
children,
label,
container,
positionerProps,
...rest
}: MenuContentProps) {
return (
{/* `outline-none` on the popup and nowhere else in this file: the popup takes focus
programmatically the moment it opens, and the ring is meant to answer a keyboard,
not to frame a panel that just appeared. The items keep theirs — see below. */}
{children}
);
}
/**
* Base UI gives the highlighted item a real `tabIndex: 0` and moves DOM focus to it, so a
* keyboard user gets the app's `:focus-visible` outline for free and `data-highlighted` draws
* the pointer's highlight. Nothing here suppresses either — a menu row is one of the places
* the ring is most needed and most often deleted.
*
* The coarse-pointer minimum is a 44px row: a thumb misses anything smaller, and a menu is
* usually a list of one-way actions.
*/
const itemClasses =
"flex cursor-default items-center gap-2 px-3 py-1.5 select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground [@media(pointer:coarse)]:min-h-11";
export function MenuItem({ className, ...rest }: Primitive.Item.Props) {
return ;
}
export function MenuSeparator({ className, ...rest }: Primitive.Separator.Props) {
return (
);
}
/** The caption a group is named by, or nothing. Never a part you place yourself. */
type MenuGroupCaption = { label?: string };
const captionClasses = "px-3 pt-2 pb-1 text-muted-foreground";
// The caption block below is written out TWICE, once per group kind, and that is deliberate.
// Lifting it into a `caption(label)` helper is the obvious cleanup and it was tried: it moves
// the `` out of the `` body it is written inside, and
// `menu.test.ts` fails immediately — correctly. That guard proves placement by READING THE
// SOURCE, so an indirection it cannot follow does not just break the test, it retires the
// guarantee: after the extraction nothing stops a later edit calling `caption()` somewhere that
// is not inside a group, which is the render-time throw this whole file exists to prevent.
// Six duplicated lines are the price of a rule a machine can still check. Leave them.
/**
* A set of related items with an optional caption.
*
* The caption is a PROP because that is the only arrangement that cannot be got wrong: it
* lands inside the group, which is both what stops Base UI throwing and what gives the set
* its `aria-labelledby`. It is also not focusable, so it never eats an arrow-key stop.
* Optional, because a group whose trigger already says what it is would only repeat itself.
*/
export function MenuGroup({ label, children, ...rest }: Primitive.Group.Props & MenuGroupCaption) {
return (
{label === undefined ? null : (
{label}
)}
{children}
);
}
/** A set of choices for a theme, locale or one axis of a filter. Same caption rule as `MenuGroup`. */
export function MenuRadioGroup({
label,
children,
...rest
}: Primitive.RadioGroup.Props & MenuGroupCaption) {
return (
{label === undefined ? null : (
{label}
)}
{children}
);
}
export type MenuRadioItemProps = Primitive.RadioItem.Props & {
/** Mark for the chosen item. */
indicator?: ReactNode;
};
/**
* One choice in a set. The mark keeps a fixed leading column whether or not it is showing, so
* the labels do not shift sideways when the selection moves.
*/
export function MenuRadioItem({ className, children, indicator, ...rest }: MenuRadioItemProps) {
return (
{indicator ?? }
{children}
);
}