Docs Platform

Select

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/forms 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 { selectMachine, type SelectCommand } from "@grassroot/ui-machines/select"
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "angular", label: "Angular" },
]
const changes: SelectCommand[] = []
const service = createService(selectMachine, {
input: { options, defaultValue: ["react"] },
runCommand: (change) => { changes.push(change) },
})
service.start()
service.send({ type: "OPEN" })
service.send({ type: "MOVE", direction: 1 })
service.send({ type: "SELECT", value: "vue", reason: "keyboard" })
service.getSnapshot().context.value // ["vue"]
changes[0] // VALUE_CHANGE_REQUEST for ["vue"], reason "keyboard"
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, OPEN, CLOSE, MOVE, SELECT, ACTIVATE (legacy-compatible); plus the versioned HOME, END, TYPEAHEAD, RESET.
  • Value binding: uncontrolled SELECT commits and emits one VALUE_CHANGE_REQUEST with the committed value and reason; controlled SELECT leaves the authoritative value unchanged (still moves activeIndex and closes) and emits the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces a binding-mode-change diagnostic rather than silently changing behaviour.
  • Reasons: every change request carries "pointer" | "keyboard" | "programmatic", so consumers can distinguish how a value changed.
  • Commands: VALUE_CHANGE_REQUEST (the single change event, fired exactly once per user selection), FOCUS_PART / FOCUS_ITEM / SCROLL_ITEM_INTO_VIEW (resolved through the part scope, silent no-op when unmounted), and ANNOUNCE (semantic key + params, so there is no hardcoded English in core).
  • Forms: the hidden input reflects the committed value; a native form reset dispatches the machine's RESET event (uncontrolled restores defaultValue without emitting a change request; controlled is a no-op).
  • Focus: opening never moves DOM focus; it stays on the trigger, and aria-activedescendant tracks the active item.

Options travel in machine input, not event payloads: value / defaultValue pick controlled vs. uncontrolled mode at first start, and loop opts into wrap-around MOVE.

Anatomy

The headless layer's parts, with identical part names across every framework adapter:

Select.Root          data-scope="select" data-part="root"
Select.Trigger       data-part="trigger"
Select.Positioner    data-part="positioner"
Select.Content       data-part="content"     (the listbox)
Select.Item          data-part="item"        (keyed by option value)
Select.ItemText      data-part="item-text"
Select.HiddenInput   data-part="hidden-input"

Every part carries data-scope="select", its data-part, and data-state="open" | "closed". Items additionally carry data-active, data-selected and data-disabled when true. The trigger is role="combobox" with aria-haspopup="listbox"; the content is role="listbox"; each item is role="option".

Keyboard

Keys
When
Effect
Escape
open
Closes and returns focus to the trigger (legacy-compatible).
ArrowDown / ArrowUp
open
Moves the active item; disabled items are skipped, movement clamps by default (legacy-compatible).
Enter / Space
open
Selects the active item and closes (legacy-compatible).
Enter / Space / ArrowDown / ArrowUp
closed trigger
Opens the listbox (versioned).
Home / End
open
Moves to the first/last enabled item (versioned).
Printable characters
open
Feeds typeahead: case-insensitive prefix match on labels, starts after the active index, wraps; the connector owns a 1s query-reset timer (versioned).
Any key, with loop input
open
MOVE wraps past either end instead of clamping (versioned, opt-in via the `loop` input).
Keys
Escape
When
open
Effect
Closes and returns focus to the trigger (legacy-compatible).
Keys
ArrowDown / ArrowUp
When
open
Effect
Moves the active item; disabled items are skipped, movement clamps by default (legacy-compatible).
Keys
Enter / Space
When
open
Effect
Selects the active item and closes (legacy-compatible).
Keys
Enter / Space / ArrowDown / ArrowUp
When
closed trigger
Effect
Opens the listbox (versioned).
Keys
Home / End
When
open
Effect
Moves to the first/last enabled item (versioned).
Keys
Printable characters
When
open
Effect
Feeds typeahead: case-insensitive prefix match on labels, starts after the active index, wraps; the connector owns a 1s query-reset timer (versioned).
Keys
Any key, with loop input
When
open
Effect
MOVE wraps past either end instead of clamping (versioned, opt-in via the `loop` input).

Accessibility

Escape closes and returns focus to the trigger; disabled items are skipped by movement but remain perceivable (rendered, excluded only from activedescendant focus). The hidden input never receives focus (tabindex="-1", aria-hidden="true") and exists purely for native form participation. connectSelect and every prop getter are callable with no DOM present. Part-scope registration happens on mount, and IDs are deterministic per scope seed.

Evidence & further reading