Dropdown Menu

@sushindustries/ui

A menu hung off a button: the popover API for the layer, arrow keys for the list, and light-dismiss for free.

7 min read
View as MarkdownEdit on GitHub

Composing it

Items are data, not children. onSelect is handed the item's id and is not called for a disabled one, so a caller never has to check. buttonClassName replaces the button's classes outright, which is how the same menu becomes an icon in a table cell.

<DropdownMenu
	label="Actions"
	buttonClassName="btn btn-quiet btn-icon"
	align="end"
	items={rows.length ? actions : []}
	empty="Select a row first."
	onSelect={run}
/>
tsx

An empty items says so in the menu rather than by leaving a button that does nothing, or by a button that is not there at all.

Which edge it lines up with

Position is measured on beforetoggle, while the popover is still closed and already measurable, so the menu is placed before the first frame it is visible in. It flips above the button when there is no room below and is clamped into the viewport sideways.

alignLines upFor
start (default)The menu's left edge with the button's left edgeA menu under a control on the left of its row
endThe two right edgesThe last column of a table, where a left-aligned menu would hang off the page

What the platform does, and what is left

Four of the things a menu needs are already in the browser, and are done better there than a component can do them:

The browser'sWhat it means here
The top layerOver every stacking context, without a z-index war
Light dismissA click anywhere else closes it
EscapeCloses it, and returns focus to the invoker
One at a timeOpening another popover="auto" closes this one

What is left is placement and the arrow keys. That is the whole component, and it is why it has no dependency beyond IconIconThe glyph set, generated from a Markdown table where every drawing carries its reason. One component, typed names, no icon font.@sushindustries/ui · content. Not CSS anchor positioning, which would delete the placement code and is Chromium-only - a menu that lands in the top left corner in Safari is not a progressive enhancement.

When not to use it

ContextMenu is the other half of this idea and is not interchangeable with it: that one opens at a pointer from a right-click or a long press. This one opens from a control somebody clicked on purpose, which is what makes it reachable by keyboard from that control.

destructive is colour only and confirms nothing. An action that needs a second look needs a DialogDialogA native dialog driven by props: top layer, focus trap and Escape from the element, click-outside from here.@sushindustries/ui · layout, and this menu is not one.

UsesIconIconThe glyph set, generated from a Markdown table where every drawing carries its reason. One component, typed names, no icon font.@sushindustries/ui · content