Skip to content

Menu

In review
Live · web

Click a trigger to drop its menu, anchored and clamped to the stage

Open with a click or Enter / Space / ↓. In the menu: ↑ ↓ move, → opens a submenu, ← / Esc back, Enter runs.

Session · gateway-refactor
Overflow

Last action: none yet · wrap on · sort recent. Pointer + keyboard both work; the island is live.

// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,
// not yet exported from @dizyx/nockerl-react; promotion is tracked.)
import { NockerlMenu, MenuItem } from '@dizyx/nockerl-react';
export function SessionOverflow() {
const [wrap, setWrap] = useState(true);
return (
<NockerlMenu trigger={<NockerlIconButton icon="kebab" label="Session actions" />} align="end">
<MenuItem icon="rename" shortcut="⌘R" onSelect={rename}>Rename</MenuItem>
<MenuItem icon="settings" onSelect={editModel}>Edit provider / model</MenuItem>
<MenuItem icon="fork" onSelect={fork}>Fork</MenuItem>
<MenuSection label="View" />
<MenuItem icon="wrap" checked={wrap} onSelect={() => setWrap((v) => !v)}>Wrap lines</MenuItem>
<MenuSub icon="export" label="Export as">
<MenuItem onSelect={() => exportAs('md')}>Markdown (.md)</MenuItem>
<MenuItem onSelect={() => exportAs('json')}>JSON (.json)</MenuItem>
</MenuSub>
<MenuSeparator />
<MenuItem icon="trash" tone="danger" onSelect={confirmDelete}>Delete</MenuItem>
</NockerlMenu>
);
}
PropTypeDefaultDescription
trigger *ReactNodeThe control that opens the menu on click / Enter / Space / ↓. Gets aria-haspopup + aria-expanded.
align'start' | 'end''start'Which trigger edge the menu aligns to. The menu flips up + clamps horizontally to stay on-screen.
children *MenuItem | MenuSeparator | MenuSection | MenuSubThe menu contents: item rows, full-width separators, section labels, and nested submenus.
MenuItem.iconstringOptional leading glyph. The icon column is reserved menu-wide so every label aligns.
MenuItem.shortcutstringTrailing keyboard-shortcut hint (e.g. ⌘R), rendered in the mono font, muted.
MenuItem.checkedbooleanRenders the row as menuitemcheckbox (a leading cyan check). Toggling keeps the menu open.
MenuItem.tone'default' | 'danger''default'A danger item uses the error token and is divider-separated as the tail.
MenuItem.onSelect() => voidActivation handler. Action items close the menu; checkable items toggle in place.
Menu vs. long-press-pop vs. command-palette

Three sibling surfaces, three triggers. Don’t mix them:

  • Menu (this page) is click-triggered and anchored to a control (a kebab ⋯ or actions button). Non-modal, no dim, positioned under/beside its trigger.
  • Long-press pop is press-and-hold ON content (a bubble, a row): it dims the ground, lifts the target, and pops the same item vocabulary beside it.
  • Command palette is the modal ⌘K centered overlay with type-to-search, a global launcher, not a per-control action list. (The combobox is the type-to-filter select, not a menu.)
Cross-platform drift (tracked for reconciliation)

The item grammar is shared; the real divergences between the shipped platforms:

  • Surface ownership: web draws the menu surface itself (the canon overlay card: surface + hairline + top catch-light + a neutral shadow). Android’s DropdownMenu and Voice’s SwiftUI Menu (NSMenu-backed) use system-owned surfaces. Per Law 9 (honor the platform) they keep native elevation, motion, and dismissal, so the web card recipe deliberately does not port 1:1.
  • Item vocabulary: web ships menuitemcheckbox / menuitemradio rows, section labels, nested submenus, and mono shortcut hints. Android’s shipped usage is plain action rows (DropdownMenuItem); Voice gets submenus + destructive roles from SwiftUI but has no checkbox/radio item variant in use. Adopting the richer row kinds natively is the open item.
  • Point anchoring: web’s openAt(x, y) (context-menu positioning) has no shipped native counterpart yet; Android/Voice menus anchor to their trigger control only.