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.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/theme-menu
Open binding, highlight index, typeahead buffer, committed theme, no DOM.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/theme-menu
Menu role, menuitemradio rows, aria-activedescendant, no visual recipe.
Vanilla
@grassroot/ui-headless-core/theme-menu (mountThemeMenu)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's own theme-switcher visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/theme-menu
Ships behaviour
Open binding, highlight index, typeahead buffer, committed theme, no DOM.
Layer
Headless
Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/theme-menu
Ships behaviour
Menu role, menuitemradio rows, 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/ defaultOpen shape 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.theme is the machine's own record of which theme is currently selected. Reading or writing localStorage, and applying document.documentElement.dataset.theme, both happen only when the connector resolves a THEME_CHANGE_REQUEST command, and neither the DOM nor storage is machine state.
  • SELECT commits the highlighted item's value as the new theme, closes the menu, cancels any pending typeahead-buffer reset, and emits exactly one THEME_CHANGE_REQUEST plus one open-change request: one user action, two commands, one state transition.
  • SYNC_THEME changes 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 activateMenuLayer seam DropdownMenu uses, pushLayer({ modal: false }), with Trigger and Content as its containment set. The layer sends the machine's own CLOSE event; 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-activedescendant pointing 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.cancel contract 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