- Layer
- Machine
- Package
@grassroot/ui-machines/theme-menu- Ships behaviour
- Open binding, highlight index, typeahead buffer, committed theme, no DOM.
Docs Platform
ThemeMenu
A single-select menu that swaps the site's theme rather than picking some generic value. The machine tracks which theme is highlighted and which one is actually committed, the headless layer turns that into an accessible menu with a checked row per theme, and the styled layer adds Grassroot's own visual recipe. The list of themes is not ours: it still comes from the frozen legacy oracle. This component only owns how the menu behaves.
Explore the layers
Machine (@grassroot/ui-machines/theme-menu)
is pure state: no DOM, no framework, no styling. Its tests replay four
named scenarios straight out of the frozen oracle:
packages/ui-core/src/theme-menu.ts and
packages/ui-core/src/theme-menu-fixtures.ts, neither of
which this machine imports. The oracle only supplies the behaviour the
tests check against. Headless
(@grassroot/ui-headless-<framework>/theme-menu) adds
an accessible role="menu" with a checked row per theme, on
top of the same machine, per framework. Styled
(@grassroot/ui-<framework>) is Grassroot's own visual
recipe built on the headless layer. Vanilla
(mountThemeMenu, from
@grassroot/ui-headless-core/theme-menu) is the
framework-free mount helper.
@grassroot/ui-machines/theme-menu@grassroot/ui-headless-{react,vue,solid,svelte,angular}/theme-menumenuitemradio rows, aria-activedescendant, no visual recipe.@grassroot/ui-headless-core/theme-menu (mountThemeMenu)@grassroot/ui-{react,vue,solid,svelte,angular}- Layer
- Headless
- Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/theme-menu- Ships behaviour
- Menu role,
menuitemradiorows,aria-activedescendant, no visual recipe.
- Layer
- Vanilla
- Package
@grassroot/ui-headless-core/theme-menu(mountThemeMenu)- Ships behaviour
- The same headless contract with no framework runtime at all.
- Layer
- Styled
- Package
@grassroot/ui-{react,vue,solid,svelte,angular}- Ships behaviour
- Grassroot's own theme-switcher visual recipe, tokens and classes only.
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. Four oracle fixtures pin the contract exactly:
toggling from the trigger opens and closes the menu, selecting a theme
commits it and closes the menu, an outside click or Escape closes
without changing the committed theme, and syncing an externally-applied
theme (say, one already sitting in localStorage) reconciles
the machine's committed value without opening anything.
ThemeMenuInput: open? / defaultOpen? (ONE state-backed open binding)
items: { id, value, disabled?, textValue? }[]
theme?: string (initial committed theme; default "neobrutalist")
storageKey?: string (default "vs-preview-theme")
StateValue: "open" | "closed"
Context: { mode, highlightedIndex, typeaheadBuffer, theme }
Events: TOGGLE { reason: "trigger" }
OPEN { reason; highlight? } / CLOSE { reason }
MOVE { direction } / HIGHLIGHT { index } / HOME / END
SELECT / TYPEAHEAD { char } / SYNC_THEME { theme }
Commands: OPEN_CHANGE_REQUEST { open, reason }
THEME_CHANGE_REQUEST { theme, storageKey }
-
Open binding: the same
open/defaultOpenshape every other layered overlay uses, fixed to controlled or uncontrolled mode at first start. A post-start mode flip reports the diagnostic"ThemeMenu cannot change open binding mode after start"and leaves state unchanged, the same rule every other layered component follows. -
The committed theme is separate context, not storage.
context.themeis the machine's own record of which theme is currently selected. Reading or writinglocalStorage, and applyingdocument.documentElement.dataset.theme, both happen only when the connector resolves aTHEME_CHANGE_REQUESTcommand, and neither the DOM nor storage is machine state. -
SELECTcommits the highlighted item'svalueas the new theme, closes the menu, cancels any pending typeahead-buffer reset, and emits exactly oneTHEME_CHANGE_REQUESTplus one open-change request: one user action, two commands, one state transition. -
SYNC_THEMEchanges only the committed theme in context. It never opens the menu and never touches the open binding. That is how an externally-applied theme (a page-load already read from storage, say) reconciles the highlighted row without the menu popping open to announce it. - Forms: none.
Dismissal & typeahead
ThemeMenu is a non-modal overlay, the same shape as DropdownMenu: no scroll lock, no focus trap, and focus never leaves the trigger.
-
The layer owns Escape and outside-click. On
open-commit the connector activates the same non-modal
activateMenuLayerseam DropdownMenu uses,pushLayer({ modal: false }), with Trigger and Content as its containment set. The layer sends the machine's ownCLOSEevent; the connector installs no document listener of its own. The handle releases on close-commit and on unmount. -
Focus stays on the trigger the entire time the menu
is open. Which row is "current" is communicated purely through
aria-activedescendantpointing at the highlighted item's id, never a real DOM focus move onto a row. -
Typeahead accumulates printable characters into a
buffer and highlights the first actionable row whose text starts with
it, case-insensitively. The buffer clears itself automatically after
a short pause (default 1000ms) with no further typing, which is a scheduled event under the hood, the same
statechart.schedule/statechart.cancelcontract Tooltip's hover delays and DropdownMenu's own typeahead use, not a DOM timer some adapter owns.
Anatomy
The headless layer's parts, with identical part names across every framework adapter:
ThemeMenu.Root context/service owner; renders nothing itself
ThemeMenu.Trigger data-part="trigger" (aria-haspopup="menu", aria-expanded, aria-controls -> content id)
ThemeMenu.Positioner data-part="positioner" (data-align="start" | "end")
ThemeMenu.Content data-part="content" (role="menu", aria-activedescendant -> highlighted item id, tabIndex=-1)
ThemeMenu.Item data-part="item" (role="menuitemradio", aria-checked, aria-disabled when disabled, data-highlighted when highlighted)
All rendered parts carry data-scope="theme-menu",
data-part, and data-state="open" | "closed".
Positioner and Content render only while open, the same unmount-on-close
model as DropdownMenu's overlay parts. Tier labels (the small caption text under each theme name in the styled recipe) are presentational
children the styled layer supplies; they are not machine items and the
headless layer knows nothing about them.
Keyboard
While closed, ArrowDown, Enter or Space open the menu and highlight the first actionable item; ArrowUp opens and highlights the last. While open, arrow keys move the highlight with wraparound, Home/End jump to the first/last actionable item, Enter selects the highlighted row, and printable characters perform typeahead. Escape is owned by the layer, described above. Focus never moves off the trigger at any point.
Accessibility
Content is role="menu" with tabIndex=-1,
since focus itself never leaves the trigger; the currently highlighted
row is communicated through aria-activedescendant. Each
Item is role="menuitemradio" with aria-checked
reflecting whether it is the committed theme. That makes it a single-select radio group inside a menu, matching how a screen reader should announce "pick
one of these themes" rather than "toggle each of these independently".
Trigger carries aria-haspopup="menu",
aria-expanded, and aria-controls pointing at
Content's id. connectThemeMenu and every prop getter are
callable with no DOM present; layer activation and the storage/DOM
persistence side effect both begin only once a mounted browser part
exists, so nothing runs during SSR.
Evidence & further reading
-
theme-menu.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/theme-menu.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. -
theme-menu.spec.md, the reviewable anatomy/ARIA/keyboard/dismissal/forms/SSR contract; code must match it, and where they disagree the spec wins. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for ThemeMenu, applied to a component that isn't one of the shipped machines. - DropdownMenu: the four-layer walkthrough, the closest sibling documentation contract; ThemeMenu's non-modal layer and typeahead reuse the same seams.
- Select: the four-layer walkthrough, the same documentation contract, worked through Select's own anatomy.
- Switch: the four-layer walkthrough, the platform's smallest teaching example.