- Trigger
ArrowDown / ArrowUp- When
- trigger, closed
- Effect
- Opens and highlights the first/last actionable row (VERSIONED, per APG; legacy arrows did nothing while closed).
Docs Platform
DropdownMenu
One behaviour contract, four layers. Pick a layer, and a framework where that applies, and the page swaps what it shows you: the state machine, its accessible DOM and ARIA translation, the styled component, or the framework-free mount. What it means stays put. The behaviour contract further down holds whichever layer you picked, which is why it's written out once instead of four times.
Explore the layers
Machine is pure state: no DOM, no framework, no styling. Headless adds accessible DOM/ARIA on top of the same machine, per framework. Styled is Grassroot's own visual recipe built on the headless layer. Vanilla is the framework-free mount helper, for script islands and anywhere else with no framework to hand.
Install
npm i @grassroot/ui-machines
import { createService } from "@grassroot/statechart"import { dropdownMenuMachine, dropdownMenuSelectors, type DropdownMenuCommand } from "@grassroot/ui-machines/dropdown-menu" const items = [{ id: "rename" }, { id: "duplicate" }, { id: "delete", disabled: true, textValue: "Delete" }]const changes: DropdownMenuCommand[] = []const service = createService(dropdownMenuMachine, { input: { defaultOpen: false, items }, runCommand: (change) => { changes.push(change) },}) service.start()service.send({ type: "OPEN", reason: "trigger" })dropdownMenuSelectors.highlightedIndex(service.getSnapshot()) // 0, the first actionable row service.send({ type: "MOVE", direction: 1 })dropdownMenuSelectors.highlightedIndex(service.getSnapshot()) // 1, wraps across the actionable set service.send({ type: "TYPEAHEAD", char: "d" }) // matches "duplicate" or "delete" by textValuechanges.at(-1) // { type: "statechart.schedule", key: "menu.typeahead", event: { type: "TYPEAHEAD", char: "" }, delayMs: 1000 } service.send({ type: "SELECT" })changes.at(-2) // { type: "SELECT_ITEM", id: "duplicate" }dropdownMenuSelectors.open(service.getSnapshot()) // false, SELECT closes tooservice.stop()Behaviour contract
This is the one thing every layer agrees on. The machine decides it, the headless layer exposes it as DOM/ARIA, and the styled layer inherits it unchanged.
-
Events:
TOGGLE{ reason: "trigger" },OPEN{ reason, highlight? },CLOSE{ reason: "escape" | "outside-click" | "select" | "programmatic" },MOVE{ direction: -1 | 1 },HIGHLIGHT{ index },HOME/END,SELECT,TYPEAHEAD{ char }. -
Open binding: one state-backed binding,
open/defaultOpen, the same shape Dialog and Tooltip use. Uncontrolled interaction commits and emits oneOPEN_CHANGE_REQUEST; controlled variants leave the authoritative state unchanged and emit the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces abinding-mode-changediagnostic, the same rule every other layered component follows. Legacy DropdownMenu was uncontrolled-only, sodefaultOpenIS the legacy path; controlledopenis a versioned addition. -
The item model:
items: { id, disabled?, textValue? }[]is the machine's own list. Dividers are NOT machine items; the actionable set is computed straight from this list, and the connector keeps divider rows presentational, preserving the frozen oracle's indexing model exactly. -
Highlight indexing:
highlightedIndexindexes the ACTIONABLE set (skipping disabled rows), neveritemsdirectly, the same model the oracle'sactivefield used. It is never-1: a closed menu keeps its last highlight, and toggling resets it to the first actionable row (oracle semantics, preserved exactly). -
Commands:
OPEN_CHANGE_REQUEST{ open, reason },SELECT_ITEM{ id } (resolved by the connector to the consumer'sonSelect), plus the scheduled-delivery commandsstatechart.scheduleandstatechart.cancelthat carry the pending typeahead buffer reset. The machine never owns a timer itself. AScheduleroutside the machine (native in adapters, virtual in tests) is what actually waits and delivers the event, exactly as Tooltip's hover delays work. - Forms: none.
-
No-ops:
OPENwhile already open andCLOSEwhile already closed are true no-ops.MOVEon an empty actionable set is also a no-op, the same oracle rule that applies to a menu with every row disabled.
Dismissal & typeahead
This slice is where dismissal for non-modal overlays formally moves INTO headless core, rather than being reimplemented per adapter. The connector pushes a non-modal layer the instant the menu opens, and releases it on close or unmount.
- The layer owns Escape and outside-click, top-layer-only. Both are document-level listeners registered once by the layer, not a per-adapter outside-click hook, and the five legacy per-framework dismissal implementations are deleted once the styled layer ships on top of this. With more than one menu open, Escape and an outside pointerdown only ever dismiss the topmost one.
- It's a non-modal layer, so there is no scroll lock and no focus trap, unlike Dialog. Opening a menu doesn't touch the page's scroll or grab keyboard focus away from anything.
-
Focus stays on the trigger the entire time the menu is
open. That is the legacy focus model, preserved deliberately. What
row is "current" is communicated through
aria-activedescendanton Content pointing at the highlighted item's id, never a real DOM focus move onto a row, the same approach Select uses for its options. - Type-to-highlight works by accumulating printable characters into a buffer and highlighting the first row whose text starts with it, cycling forward from the current highlight if you keep typing the same letter. The buffer clears itself automatically after a short pause with no further typing, which is a scheduled event under the hood, not a DOM timer some adapter owns, so it behaves identically everywhere. Opening, closing or selecting also clears whatever was typed so far.
Anatomy
The headless layer's parts, with identical part names across every framework adapter:
Menu.Trigger data-part="trigger" (aria-haspopup="menu", aria-expanded, aria-controls -> content id)
Menu.Positioner data-part="positioner" (anchored wrapper; data-align="start" | "end")
Menu.Content data-part="content" (role="menu", aria-activedescendant -> highlighted item id, tabIndex=-1)
Menu.Item data-part="item" (role="menuitem"; aria-disabled; data-highlighted when highlighted)
Menu.Separator data-part="separator" (role="separator")
Menu.GroupLabel data-part="group-label" (the legacy header slot; id -> aria-labelledby on Content when present)
All rendered parts carry data-scope="dropdown-menu",
data-part, and data-state="open" | "closed".
There is no Menu.Root DOM element. Root is purely the
context/service owner and renders nothing itself.
Positioner and Content, plus every part mounted inside Content, render only while open. This is the same
unmount-on-close model as Dialog's overlay parts and Accordion's closed
panels, with no exit animation, preserving legacy behaviour exactly.
Trigger is the one part that stays mounted regardless of open state.
aria-controls and aria-labelledby wiring on
Trigger/Content are versioned accessibility additions. Legacy carried
aria-haspopup/aria-expanded but no
aria-controls link, and tracked the highlighted row
visually with no ARIA linkage at all.
Unlike Dialog's Positioner, this one does not portal anywhere. The legacy styled menu rendered its overlay inline wherever the consumer placed it in the tree, and this slice preserves that placement exactly. Positioning itself stays CSS, as it always has; there is no floating or measure machinery in this slice.
Submenus are out of this slice's anatomy: no SubTrigger/
SubContent parts ship yet. The machine's item model and the
layer stack are already built so nesting composes cleanly (each submenu
would be its own service and its own layer), but the parts themselves
wait for ContextMenu/Menubar to need them.
Keyboard
Legacy-compatible while open: arrow keys move with wraparound, Enter selects the highlighted row, Escape closes, and a click toggles. Opening a closed trigger with the keyboard is the platform's versioned addition, since legacy arrow keys did nothing on a closed trigger at all.
ArrowDown / ArrowUpArrowDown / ArrowUpEnter / SpaceEnterHome / EndEscapePrintable characterClick outside- Trigger
ArrowDown / ArrowUp- When
- open
- Effect
- Moves the highlight, wrapping across the actionable set (dividers and disabled rows skipped).
- Trigger
Enter / Space- When
- trigger, closed
- Effect
- Opens and highlights the first actionable row (VERSIONED, per APG).
- Trigger
Enter- When
- open
- Effect
- Selects the highlighted row, emitting SELECT_ITEM and closing with reason "select" (legacy-compatible).
- Trigger
Home / End- When
- open
- Effect
- Highlights the first/last actionable row (VERSIONED).
- Trigger
Escape- When
- open
- Effect
- Closes immediately. Owned by the non-modal layer, top-layer-only (legacy-compatible).
- Trigger
Printable character- When
- open
- Effect
- Appends to the typeahead buffer and highlights the first match by textValue, cycling from the current highlight; the buffer resets after a delay (VERSIONED).
- Trigger
Click outside- When
- open
- Effect
- Closes with reason "outside-click". Owned by the non-modal layer, top-layer-only (legacy-compatible).
Accessibility
Trigger carries aria-haspopup="menu", aria-expanded
and (a versioned addition over legacy) aria-controls
pointing at Content's id. Content is role="menu" with
tabIndex=-1 and aria-activedescendant
pointing at whichever row is highlighted, since focus itself never
leaves the trigger. Items are role="menuitem" with
aria-disabled when disabled and data-highlighted
as a pure styling hook. Menu.GroupLabel's id becomes
Content's aria-labelledby only when a group label is
actually rendered, never pointed at an id that doesn't exist.
Everything about how dismissal actually behaves (Escape, outside-click, and the non-modal layer that owns both) is covered
above under "Dismissal & typeahead", since none of it is ARIA
attributes so much as manager-owned DOM behaviour.
Evidence & further reading
-
dropdown-menu.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/dropdown-menu.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount, including axe checks for "no violations while closed" and "no violations while open". -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for DropdownMenu, applied to a component that isn't one of the shipped machines. -
dropdown-menu.spec.md, the reviewable anatomy/ARIA/keyboard/dismissal/typeahead/forms/SSR contract; code must match it, and where they disagree the spec wins. - Dialog: the four-layer walkthrough, the closest sibling documentation contract, worked through Dialog's own modal-overlay anatomy and its focus trap/scroll lock managers.
- Tooltip: the four-layer walkthrough, the same documentation contract, worked through Tooltip's own anatomy and its scheduled-delay timers.
- Accordion: the four-layer walkthrough, the same documentation contract, worked through Accordion's own anatomy.
- Select: the four-layer walkthrough, the same documentation contract, worked through Select's own anatomy.
- Switch: three-layer walkthrough, the platform's smallest teaching example, one layer currently real.