- Keys
Escape- When
- open
- Effect
- Closes and returns focus to the trigger (legacy-compatible).
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
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 versionedHOME,END,TYPEAHEAD,RESET. -
Value binding: uncontrolled
SELECTcommits and emits oneVALUE_CHANGE_REQUESTwith the committed value and reason; controlledSELECTleaves the authoritative value unchanged (still movesactiveIndexand closes) and emits the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces abinding-mode-changediagnostic 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), andANNOUNCE(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
RESETevent (uncontrolled restoresdefaultValuewithout emitting a change request; controlled is a no-op). -
Focus: opening never moves DOM focus; it stays on the
trigger, and
aria-activedescendanttracks 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
EscapeArrowDown / ArrowUpEnter / SpaceEnter / Space / ArrowDown / ArrowUpHome / EndPrintable charactersAny key, with loop input- 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
- Accessibility & conformance evidence , 360/360 browser-contract tests and axe scans across all five adapters × three engines; current screen-reader gaps (VoiceOver manual pass pending, NVDA parked by decision).
-
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Select, applied to a component that isn't one of the shipped machines. -
select.spec.md, the reviewable anatomy/ARIA/keyboard/focus/forms/SSR contract; code must match it, and where they disagree the spec wins. - Select in the component catalogue, a live cross-framework demo and full prop reference.
- Tabs: the four-layer walkthrough, the same documentation contract, worked through Tabs' own anatomy.
- Accordion: the four-layer walkthrough, the same documentation contract, worked through Accordion's own anatomy (array-valued, no roving tabindex).
- Switch: three-layer walkthrough, the platform's smallest teaching example, one layer currently real.