Menu Button
stimeo--menu
A real menu of commands you walk with the arrow keys. Not the same thing as the dropdown.
A menu of commands opened from a button. It implements the WAI-ARIA Menu Button pattern: the arrow keys walk the items and Home and End jump to the ends. Esc, Tab, a click outside, or activating an item closes it. Unavailable items come in two kinds. A fully disabled item is skipped by the arrow keys, while an item marked unavailable stays reachable so it can be found, but cannot be activated. Clicks and keys are handled for the menu as a whole, so items need no setup of their own and items added later work straight away. Placement here is this demo's CSS.
Keyboard
| Key | Action |
|---|---|
| Enter / Space / ↓ | Open the menu and focus the first item. |
| ↑ | Open the menu and focus the last item. |
| ↓ / ↑ | Move focus between items (wrapping). |
| Home / End | Focus the first / last item. |
| Enter / Space on an item | Activate an enabled item; an aria-disabled item remains inactive. |
| Esc | Close the menu and return focus to the trigger. |
<%# Markup for the menu (APG Menu Button) demo.
stimeo--menu is a full role="menu"/"menuitem" menu providing arrow-key roving
focus, Esc, and outside click (distinct from the disclosure dropdown).
Positioning (static placement) is the Playground's CSS. %>
<div class="menu" data-controller="stimeo--menu">
<button
id="menu-trigger"
type="button"
class="demo-trigger"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="menu-list"
data-stimeo--menu-target="trigger"
data-action="click->stimeo--menu#toggle keydown->stimeo--menu#onTriggerKeydown">
<%= t("components.menu.demo.trigger") %>
</button>
<%# The trigger labels the menu; each item is a role="menuitem" button (tabindex=-1).
Items need no data-action: the controller handles their clicks and keys from its own
element, so items added or moved in later work without being wired up. %>
<ul id="menu-list" class="menu__list" role="menu" aria-labelledby="menu-trigger"
data-stimeo--menu-target="menu" hidden>
<li role="none">
<button type="button" role="menuitem" class="demo-menuitem" tabindex="-1"
data-stimeo--menu-target="item"><%= t("components.menu.demo.edit") %></button>
</li>
<li role="none">
<button type="button" role="menuitem" class="demo-menuitem" tabindex="-1"
data-stimeo--menu-target="item"><%= t("components.menu.demo.duplicate") %></button>
</li>
<li role="none">
<button type="button" role="menuitem" class="demo-menuitem" tabindex="-1"
data-stimeo--menu-target="item"><%= t("components.menu.demo.delete") %></button>
</li>
</ul>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.events") %></span>
<span class="demo-readout__value" data-readout>—</span>
</p>
/*
* Presentation-only styles for the menu (Menu Button) demo.
* This demo uses static CSS placement below the trigger. Consumers that need
* viewport-aware collision handling can compose the menu with the opt-in
* stimeo-ui/positioning entrypoint.
*/
.menu {
position: relative;
display: inline-block;
}
.menu__list {
position: absolute;
left: 0;
margin: 0.25rem 0 0;
padding: 0.25rem;
list-style: none;
min-width: 12rem;
background: var(--surface-card);
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
// Every move of the open state is reported, with a `reason` saying what drove it
// — a trigger, Escape, a click outside, or a call from your own code. A real app
// subscribes to mirror the state elsewhere or to send an analytics event; this
// demo writes the event name and its reason into the readout so both are visible
// while you interact. `reason: "api"` is the one to watch for: a close you asked
// for comes back to your own listener.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const stateReadout = demoStage.querySelector("[data-readout]");
const stateRoot = demoStage.querySelector("[data-controller~='stimeo--menu']");
for (const name of ["open", "close"]) {
stateRoot?.addEventListener(`stimeo--menu:${name}`, (event) => {
const reason = event.detail?.reason;
if (stateReadout) {
stateReadout.textContent = reason
? `stimeo--menu:${name} · reason: ${reason}`
: `stimeo--menu:${name}`;
}
});
}
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--menu"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The menu button that toggles the menu; carries aria-haspopup/aria-expanded and receives focus back on close. |
data-stimeo--menu-target="trigger" |
menu
required
|
The named role="menu" element shown/hidden via hidden; use aria-labelledby to reference the trigger or provide aria-label. |
data-stimeo--menu-target="menu" |
item
|
A role="menuitem" command with roving focus. An item that carries aria-disabled=true, or sits inside an element that does, stays reachable by the arrow keys but cannot activate; hidden and natively disabled items are skipped and cannot activate either. |
data-stimeo--menu-target="item" |
Actions
| Name | Description | Action |
|---|---|---|
activate
|
Closes the menu after an enabled item is activated and returns focus to the trigger; aria-disabled activation is blocked. | stimeo--menu#activate |
close
|
Closes the menu and reflects the collapsed state on the trigger. | stimeo--menu#close |
onItemKeydown
|
Roving focus and closing keys inside the menu: Arrow keys (wrapping), Home/End, Escape (close+focus trigger), Tab (close). | stimeo--menu#onItemKeydown |
onTriggerKeydown
|
Opens the menu from the trigger via keyboard: ArrowDown focuses the first item, ArrowUp the last. | stimeo--menu#onTriggerKeydown |
open
|
Opens the menu and reflects the expanded state on the trigger. | stimeo--menu#open |
toggle
|
Toggles the menu; when opening, focuses the first item. | stimeo--menu#toggle |
Events
| Name | Description | Event |
|---|---|---|
close
|
Fires right after the menu closes; detail.reason says what moved it. |
stimeo--menu:close |
open
|
Fires right after the menu opens; detail.reason says what moved it. |
stimeo--menu:open |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Trigger | "true" while the menu is open, "false" when closed. |
hidden |
Menu | Removed when open, present when closed. |