Skip to content

Menus & Popovers

Four widgets for content or actions that stay hidden until the user asks for them. MenuButton and SplitButton reuse the <menu>/<menuitem> vocabulary from the menu bar; Popover and Expander host an arbitrary child tree.

Section titled “MenuButton (<menubutton>) and SplitButton (<splitbutton>)”

<menubutton> is a single button that opens a dropdown menu. <splitbutton> fuses two actions into one control: a primary click action plus a chevron opening a dropdown for secondary actions. It is AdwSplitButton on GTK, an NSButton with an attached NSMenu on macOS.

Both take <menuitem> children, plus <menu> for a nested submenu. Same elements as <menubar>, built into an NSMenu/GMenuModel instead of the app’s main menu:

<menubutton label="Actions" iconName="open-menu">
<menuitem label="Duplicate" onSelect={duplicate} />
<menuitem label="Rename" onSelect={rename} />
<menuitem role="separator" />
<menuitem label="Delete" iconName="edit-delete" onSelect={remove} />
</menubutton>
<splitbutton label="Save" iconName="document-save" onClick={save}>
<menuitem label="Save As…" onSelect={saveAs} />
<menuitem label="Save a Copy" onSelect={saveCopy} />
</splitbutton>
Widget Props Events
<menubutton> label, iconName (both createAndUpdate) none; each <menuitem> fires its own onSelect
<splitbutton> label, iconName (both createAndUpdate) clickedonClick (the primary action; the dropdown’s items fire their own onSelect)

An anchored transient surface (GtkPopover, NSPopover) for a small piece of arbitrary content attached to a trigger. Its child is a full widget tree rather than <menuitem>s, so it holds anything a <box> could.

const [open, setOpen] = useState(false);
<box orientation="horizontal" spacing={8}>
<button label="Open Popover" onClick={() => setOpen(true)} />
<popover open={open} position="bottom" onClosed={() => setOpen(false)}>
<box orientation="vertical" spacing={8} style={{ padding: 12 }}>
<label text="Popover content" />
<button label="Close" onClick={() => setOpen(false)} />
</box>
</popover>
</box>;
Prop Type Applied Notes
open bool createAndUpdate Controlled. Set it from onClosed when the user dismisses the popover by clicking outside or pressing Escape.
anchorRef ref to any intrinsic createAndUpdate The widget to present from. Without it the popover anchors on its tree parent.
position top | bottom | left | right createAndUpdate Default top.

closedonClosed fires with no payload.

By default a popover presents from its tree parent (gtk_widget_set_parent on GTK, NSPopover.show(relativeTo:of:) against the parent view on macOS), which is why the example above puts it in the same <box> as its button. anchorRef names the trigger instead, and then where the popover sits in the tree decides nothing:

const trigger = useRef<NdNodeRef<"button">>(null);
const [open, setOpen] = useState(false);
<button ref={trigger} label="Open" onClick={() => setOpen(true)} />
{/* anywhere in the tree, including a createPortal pool */}
<popover anchorRef={trigger} open={open} position="bottom" onClosed={() => setOpen(false)}>
<label text="Popover content" />
</popover>;

The ref is a plain host-element ref (Ref<NdNodeRef>), the same handle sendCommand addresses, and it may point at any intrinsic. Two things follow from React’s commit order:

  • React attaches host refs after the commit that mounts them, so a ref to a widget mounting in the same render still reads null and the anchor lands on the next render that updates the popover. Both backends resolve the anchor when the popover presents, not when the prop arrives, so an anchor created later in the same batch still works.
  • Dropping anchorRef resets the anchor and the popover falls back to its tree parent. A popover with no tree parent (rendered through createPortal into the off-window pool) then has nowhere to present, which is what scripts/popover-anchor-drive.ts asserts.

Under the hood the prop crosses the wire as anchor, the target’s node id; anchorRef is the reconciler resolving that id for you, so an app never handles node ids itself.

An inline disclosure widget (AdwExpanderRow-style on GTK, SwiftUI DisclosureGroup on macOS) for content that stays in the layout flow instead of floating above it like a Popover. The whole label row is a click target on macOS, matching GTK’s expander.

const [open, setOpen] = useState(false);
<expander label="More options" expanded={open} onToggled={(e) => setOpen(e.checked)}>
<box orientation="vertical" spacing={6} style={{ padding: 8 }}>
<checkbox label="An option inside the expander" checked={/* ... */} onToggled={/* ... */} />
</box>
</expander>;
Prop Type Applied Notes
label string createAndUpdate
expanded bool createAndUpdate Controlled. Set it from onToggled.

toggledonToggled fires { checked }.

See examples/gallery/main.tsx’s Popovers & Menus tab for all four wired together, and the Widget Reference for the generated prop tables.