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

ts
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 textValue
changes.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 too
service.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 one OPEN_CHANGE_REQUEST; controlled variants leave the authoritative state unchanged and emit the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces a binding-mode-change diagnostic, the same rule every other layered component follows. Legacy DropdownMenu was uncontrolled-only, so defaultOpen IS the legacy path; controlled open is 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: highlightedIndex indexes the ACTIONABLE set (skipping disabled rows), never items directly, the same model the oracle's active field 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's onSelect), plus the scheduled-delivery commands statechart.schedule and statechart.cancel that carry the pending typeahead buffer reset. The machine never owns a timer itself. A Scheduler outside 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: OPEN while already open and CLOSE while already closed are true no-ops. MOVE on 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-activedescendant on 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.

Trigger
When
Effect
ArrowDown / ArrowUp
trigger, closed
Opens and highlights the first/last actionable row (VERSIONED, per APG; legacy arrows did nothing while closed).
ArrowDown / ArrowUp
open
Moves the highlight, wrapping across the actionable set (dividers and disabled rows skipped).
Enter / Space
trigger, closed
Opens and highlights the first actionable row (VERSIONED, per APG).
Enter
open
Selects the highlighted row, emitting SELECT_ITEM and closing with reason "select" (legacy-compatible).
Home / End
open
Highlights the first/last actionable row (VERSIONED).
Escape
open
Closes immediately. Owned by the non-modal layer, top-layer-only (legacy-compatible).
Printable character
open
Appends to the typeahead buffer and highlights the first match by textValue, cycling from the current highlight; the buffer resets after a delay (VERSIONED).
Click outside
open
Closes with reason "outside-click". Owned by the non-modal layer, top-layer-only (legacy-compatible).
Trigger
ArrowDown / ArrowUp
When
trigger, closed
Effect
Opens and highlights the first/last actionable row (VERSIONED, per APG; legacy arrows did nothing while closed).
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