Menu

Manual until a YAML contract lands in monochrome/spec/components/.

Dropdown menu with optional submenus, checkbox items, and radio items. Uses the Popover API (popover="manual"). Ids: mct:menu:<id> / mcc:menu:<id>. Trigger declares aria-haspopup="menu".

Composition

Root
  Trigger
  Popover (ul role="menu" popover="manual")
    Label | Separator | Item | CheckboxItem | RadioItem | Group
      Group → nested Trigger + Popover (submenu)

Usage

HTML

html
<div>
  <button type="button" id="mct:menu:account" aria-controls="mcc:menu:account" aria-expanded="false" aria-haspopup="menu">
    Account
  </button>
  <ul role="menu" id="mcc:menu:account" aria-labelledby="mct:menu:account" aria-hidden="true" popover="manual">
    <li role="presentation">Settings</li>
    <li role="none"><button type="button" role="menuitem" tabindex="-1">Profile</button></li>
    <li role="none"><button type="button" role="menuitem" tabindex="-1">Preferences</button></li>
    <li role="separator"></li>
    <li role="none"><button type="button" role="menuitem" tabindex="-1">Sign Out</button></li>
  </ul>
</div>

React

jsx
import { Menu } from "@monochrome-ui/react"

<Menu.Root>
  <Menu.Trigger>Account</Menu.Trigger>
  <Menu.Popover>
    <Menu.Label>Settings</Menu.Label>
    <Menu.Item>Profile</Menu.Item>
    <Menu.Item>Preferences</Menu.Item>
    <Menu.Separator />
    <Menu.Item>Sign Out</Menu.Item>
  </Menu.Popover>
</Menu.Root>

Vue

vue
<script setup lang="ts">
import { Menu } from "@monochrome-ui/vue"
</script>

<template>
  <Menu.Root>
    <Menu.Trigger>Account</Menu.Trigger>
    <Menu.Popover>
      <Menu.Label>Settings</Menu.Label>
      <Menu.Item>Profile</Menu.Item>
      <Menu.Item>Preferences</Menu.Item>
      <Menu.Separator />
      <Menu.Item>Sign Out</Menu.Item>
    </Menu.Popover>
  </Menu.Root>
</template>

Parts

Root

Required for wrappers (context). Element: fragment/provider only in React (MenuContext); no obligatory wrapper element in HTML beyond Trigger + Popover.

Trigger

Required. Element: button.

Kind Name Type / value Default Description
attr type "button"
attr id mct:menu:<id>
attr aria-controls mcc:menu:<id>
attr aria-expanded "true" | "false" "false" Open state.
attr aria-haspopup "menu"
attr tabindex 0 (root) / -1 (submenu trigger)
attr role "button" (root) / "menuitem" (submenu) Wrappers set this from context.
attr aria-disabled "true" | absent absent Blocks open when "true".
prop disabled boolean false Emits aria-disabled.

Popover

Required. Element: ul.

Kind Name Type / value Default Description
attr role "menu"
attr id mcc:menu:<id>
attr aria-labelledby mct:menu:<id>
attr aria-hidden "true" | "false" "true"
attr popover "manual" Popover API; core calls showPopover / hidePopover.

Item

Kind Name Type Default Description
prop disabled boolean false Renders span with aria-disabled instead of button/link.
prop href string When set, renders <a role="menuitem"> instead of button.

Markup: li role="none" wrapping button|a|span with role="menuitem" and tabindex="-1".

CheckboxItem

Kind Name Type Default Description
prop checked boolean false Seeds aria-checked. Core toggles on activate.
prop disabled boolean false

Inner role: menuitemcheckbox.

HTML:

html
<li role="none">
  <button type="button" role="menuitemcheckbox" aria-checked="false" tabindex="-1">Bold</button>
</li>

RadioItem

Kind Name Type Default Description
prop checked boolean false Seeds aria-checked. Activating clears adjacent radios in the group, then sets this item.
prop disabled boolean false

Inner role: menuitemradio. Radio groups are DOM-adjacent; a Separator or non-radio item breaks the group.

HTML:

html
<li role="none">
  <button type="button" role="menuitemradio" aria-checked="true" tabindex="-1">Left</button>
</li>
<li role="none">
  <button type="button" role="menuitemradio" aria-checked="false" tabindex="-1">Center</button>
</li>

Label

li role="presentation". Non-interactive section heading inside the menu.

Separator

li role="separator". Also acts as a radio group boundary.

Group

Submenu container. Wrappers: li role="none" providing a new menu id context; nest Trigger + Popover inside.

HTML submenu pattern:

html
<li role="none">
  <button type="button" role="menuitem" id="mct:menu:more" aria-controls="mcc:menu:more"
    aria-expanded="false" aria-haspopup="menu" tabindex="-1">More</button>
  <ul role="menu" id="mcc:menu:more" aria-labelledby="mct:menu:more" aria-hidden="true" popover="manual">
    <!-- items -->
  </ul>
</li>

Notes

  • Open: Trigger aria-expanded="true", Popover aria-hidden="false", popover shown. Core positions via CSS vars on the content element.
  • Outside click / scroll / Escape: Closes (Escape closes one layer per press; submenu first).
  • Activation: Plain menuitem closes all menus. Checkbox toggles aria-checked. Radio sweeps the group then checks the activated item.
  • Mouse vs keyboard open: Keyboard open focuses first (or last on ArrowUp) item; mouse open leaves focus on the trigger (event.detail).
  • Safety triangle: Submenu hover uses CSS clip-path + pointermove vars so diagonal travel does not dismiss the submenu. See Architecture.
  • Ids: Unique <id> per Trigger/Popover pair. Items need no monochrome ids (role is enough).

Keyboard

Key Context Behavior
Enter / Space Trigger Open (keyboard open focuses first item)
ArrowDown Closed root trigger Open, focus first item
ArrowUp Closed root trigger Open, focus last item
ArrowDown / ArrowUp Inside menu Roving focus among items
ArrowRight Submenu trigger Open submenu, focus first item
ArrowLeft Inside submenu Close submenu, focus its trigger
Home / End Inside menu First / last item
a-z Inside menu Typeahead to next matching item
Escape Open menu Close topmost layer; focus returns to its trigger
Tab Open menu Close all; leave focus path for next Tab

See also