Menu
A menu button and its menu, on the platform's own Popover API: the top layer, light dismiss and focus return are the browser's, and a submenu is a popover nested inside its menu.
Import
JSX
import Menu from '@box-kite/react/components/menu';Usage
JSX
<Menu trigger={(t) => <Button {...t}>Actions</Button>}>
<Menu.Item onSelect={duplicate}>Duplicate</Menu.Item>
<Menu.Item disabled>Move</Menu.Item>
<Menu.Separator />
<Menu.Sub label="Share">
<Menu.Item onSelect={copyLink}>Copy link</Menu.Item>
<Menu.Item onSelect={email}>Email</Menu.Item>
</Menu.Sub>
</Menu>What the platform does, so this does not
A menu is a floating layer with a keyboard on it. The layer half is the browser's — this component is the Popover API plus APG's roles and keys, and every other library's portal, focus trap and
z-index strategy is simply absent:The top layer, and no portal
The menu paints above every stacking context and outside every clipped ancestor, while staying where it was declared — so it inherits the theme, the custom properties and the text direction around it, and the tab order runs trigger → menu with nothing to arrange.
Light dismiss, one layer per press
Escape and a press outside are the browser's, and Escape closes the innermost menu first: a submenu, then the menu it came out of. Choosing an item closes the lot, which is the component's own doing.
A submenu is a nested popover
Its panel is declared beside its item, inside the menu it belongs to, which is what makes the two nest: opening a submenu leaves its parent open, a press inside it is a press inside the parent, and closing the menu closes every submenu with it.
What is left is the half APG asks for
The roles,
aria-checked, the arrow keys, Home and End, typeahead, and the two focus moves the platform does not make: into the first item on open, and back onto a submenu's item when that submenu closes — the browser returns focus for the outermost layer only.The items a menu owns
A command is a
Menu.Item; the two that carry a state are Menu.CheckboxItem and Menu.RadioItem inside a Menu.RadioGroup. A Menu.Group is a titled section — its label names the group through aria-labelledby, so the items are read as a set — and a Menu.Separator is the line between two of them.A state does not close the menu, a command does
Menu.Item closes on select, because choosing a command is the end of the visit. A checkbox or a radio item does not, so several boxes can be ticked in one go — closeOnSelect is the prop that swaps either default.Rows are
roomy, sorted by name.JSX
<Menu trigger={(t) => <Button {...t}>View</Button>}>
<Menu.Group label="Rows">
<Menu.CheckboxItem checked={compact} onCheckedChange={setCompact}>Compact rows</Menu.CheckboxItem>
</Menu.Group>
<Menu.Separator />
<Menu.RadioGroup label="Sort by" value={sort} onValueChange={setSort}>
<Menu.RadioItem value="name">Name</Menu.RadioItem>
<Menu.RadioItem value="date">Date added</Menu.RadioItem>
</Menu.RadioGroup>
</Menu>A disabled item is aria-disabled, and stays focusable
APG asks that a disabled menu item stay reachable, so that a keyboard user finds out it is there at all. The
disabled prop therefore writes aria-disabled rather than the attribute, which would take the item out of the keyboard's reach and silence it: the arrows still land on it, the item announces itself as unavailable, and activating it does nothing.Controlled, and the reason it changed
Leave
open out and the menu owns its state. Pass it and you own it, with the asymmetry the platform imposes: a close cannot be refused, because the browser has already done it by the time onOpenChange runs.Every change names its reason.
select is an item being chosen — the one a consumer most often wants to tell apart — beside trigger, escape, outside-pointer, tab and imperative.Last reason:
—The menu is always rendered
Closed means
display: none, not unmounted — the same shape as <Popover> and <Dialog>, and what lets the browser own showing and hiding. The entrance is startingStyle and the exit is transitionBehavior="allow-discrete", both already in the component's styles, so there is no <Presence> anywhere near it.The cost is that the items render whether or not anyone has opened the menu. Gate an expensive one yourself:
JSX
const [open, setOpen] = useState(false);
<Menu open={open} onOpenChange={setOpen} trigger={(t) => <Button {...t}>Reports</Button>}>
{open ? <Menu.Item>The expensive part</Menu.Item> : null}
</Menu>Where it goes
side, align, offset and flip are the four placement props every floating layer in the library takes, and Menu.Sub takes them too — its defaults being side="end" and offset={0}, so a submenu abuts the menu it came out of. The browser places both with CSS anchor positioning, so nothing is measured and no scroll listener exists.One caveat the top layer costs, measured in Chrome 152: a menu keeps the side it chose when it opened, because Chrome never re-evaluates
position-try-fallbacks for an element in the top layer. Every open picks the right side; only a scroll while the menu is open can leave it hanging off the viewport, so close a menu if the page can scroll far underneath it.Styling
The menu and every part of it are Boxes, so every prop is available, and the defaults live in
Box.components('menu') — with menu.item, menu.group, menu.label, menu.separator, and menu.indicator, menu.check, menu.dot and menu.arrow for the four marks a menu draws. All four are borders and a radius rather than an asset: this library ships no icons.JSX
<Menu p={2} borderRadius={3} minWidth={56} trigger={(t) => <Button {...t}>Actions</Button>}>
<Menu.Item py={2.5} focus={{ bgColor: 'indigo-50' }}>Duplicate</Menu.Item>
</Menu>