Context Menu
stimeo--context-menu
A right-click menu at the pointer with roving focus and keyboard activation.
The stimeo--context-menu controller implements the WAI-ARIA Menu pattern; the only differences from a menu button are the trigger (a contextmenu event or Shift+F10 / ContextMenu key, not a click) and showing the menu at the pointer. It suppresses the native browser menu and reflects the click coordinate as the CSS custom properties --stimeo-context-menu-x/-y on the menu, so the consumer's CSS can place it standalone (no positioning module required); viewport-edge flip/shift is delegated to the opt-in stimeo-ui/positioning module. On open, focus moves to the first item; arrow keys rove (wrapping), Home/End jump, activating an item or Escape closes and restores focus to the region, and Tab closes without restoring.
Keyboard
| Key | Action |
|---|---|
| Shift+F10 / ContextMenu | Open the menu from the focused region (at its center). |
| ↑ / ↓ | Move focus to the previous / next item (wrapping). |
| Home / End | Move focus to the first / last item. |
| Enter / Space | Activate the focused item and close (native button). |
| Esc | Close the menu and return focus to the region. |
| Tab | Close the menu; focus moves on naturally. |
<%# Markup for the context_menu demo.
A right-click on the region (or the ContextMenu key / Shift+F10) opens a
role="menu" at the pointer position. The interaction model is the same as a Menu
Button. The click coordinate is reflected on the menu as
--stimeo-context-menu-x / -y, and the Playground CSS uses it for top/left
placement (works even without the positioning module). %>
<div class="context-menu" data-controller="stimeo--context-menu">
<div
class="context-menu__region"
data-stimeo--context-menu-target="region"
tabindex="0"
aria-haspopup="menu"
aria-controls="context-menu-list"
data-action="
contextmenu->stimeo--context-menu#open
keydown->stimeo--context-menu#onRegionKeydown">
<%= t("components.context_menu.demo.region") %>
</div>
<ul
class="context-menu__list"
id="context-menu-list"
role="menu"
aria-label="<%= t("components.context_menu.demo.label") %>"
data-stimeo--context-menu-target="menu"
hidden>
<li role="none">
<button type="button" class="context-menu__item" role="menuitem" tabindex="-1"
data-stimeo--context-menu-target="item"
data-action="
click->stimeo--context-menu#activate
keydown->stimeo--context-menu#onItemKeydown">
<%= t("components.context_menu.demo.copy") %>
</button>
</li>
<li role="none">
<button type="button" class="context-menu__item" role="menuitem" tabindex="-1"
data-stimeo--context-menu-target="item"
data-action="
click->stimeo--context-menu#activate
keydown->stimeo--context-menu#onItemKeydown">
<%= t("components.context_menu.demo.paste") %>
</button>
</li>
<li role="none">
<button type="button" class="context-menu__item" role="menuitem" tabindex="-1"
data-stimeo--context-menu-target="item"
data-action="
click->stimeo--context-menu#activate
keydown->stimeo--context-menu#onItemKeydown">
<%= t("components.context_menu.demo.delete") %>
</button>
</li>
</ul>
</div>
/*
* Presentation-only styles for the context_menu demo.
* Open/close is the library toggling the menu's hidden. The library only reflects the
* click coordinate on the menu as --stimeo-context-menu-x / -y (viewport coords);
* placement is the consumer's CSS responsibility. Here we place it at that coordinate
* with position: fixed.
*/
.context-menu__region {
display: grid;
place-items: center;
min-height: 8rem;
padding: 1rem;
border: 2px dashed var(--border-strong);
border-radius: 0.5rem;
color: var(--color-text-muted);
cursor: context-menu;
user-select: none;
}
.context-menu__region[data-state="open"] {
border-color: var(--accent, var(--color-primary));
}
.context-menu__list {
position: fixed;
top: var(--stimeo-context-menu-y, 0);
left: var(--stimeo-context-menu-x, 0);
z-index: 20;
min-width: 10rem;
margin: 0;
padding: 0.25rem;
list-style: none;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.18);
}
.context-menu__item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
text-align: left;
font: inherit;
color: var(--fg);
cursor: pointer;
}
.context-menu__item:hover,
.context-menu__item:focus {
background: var(--color-primary-soft);
outline: none;
}
// context_menu opt-in positioning demo (consumer-side JS).
//
// The core controller (stimeo--context-menu) only reflects the click coordinate
// as the CSS custom properties --stimeo-context-menu-x / -y, and works on its own
// with no positioning module. Here we pass that coordinate as a "virtual anchor" to
// the opt-in stimeo-ui/positioning module to add viewport-edge flip/shift
// (collision avoidance) on top.
import { attachPositioning } from 'stimeo-ui/positioning';
document.querySelectorAll('[data-controller~="stimeo--context-menu"]').forEach((root) => {
const menu = root.querySelector('[data-stimeo--context-menu-target="menu"]');
if (!menu) return;
// Read the click coordinate (px) the library reflected and build a 0-width virtual anchor.
const virtualAnchor = () => {
const style = getComputedStyle(menu);
const x = Number.parseFloat(style.getPropertyValue('--stimeo-context-menu-x')) || 0;
const y = Number.parseFloat(style.getPropertyValue('--stimeo-context-menu-y')) || 0;
const rect = { x, y, top: y, left: x, right: x, bottom: y, width: 0, height: 0 };
return { getBoundingClientRect: () => rect };
};
let detach = null;
const sync = () => {
if (!menu.hidden && !detach) {
// strategy: fixed matches the menu's position: fixed (click coords are viewport-based).
detach = attachPositioning(virtualAnchor(), menu, {
strategy: 'fixed',
placement: 'right-start',
offset: 2,
padding: 8,
});
} else if (menu.hidden && detach) {
detach();
detach = null;
menu.style.position = menu.style.left = menu.style.top = '';
}
};
new MutationObserver(sync).observe(menu, { attributes: true, attributeFilter: ['hidden'] });
sync();
// Demo aid: log to the console when an item is activated. Items are native
// buttons, so Enter / Space fire as a click — this single listener captures
// click, Enter, and Space activations (a consumer-side example, not the library's job).
menu.querySelectorAll('[role="menuitem"]').forEach((item) => {
item.addEventListener('click', () => {
console.log(`[context-menu] activated: ${item.textContent.trim()}`);
});
});
});
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--context-menu"
Targets
| Name | Description | Attribute |
|---|---|---|
region
required
|
The container that triggers the menu via contextmenu/Shift+F10; carries aria-haspopup and data-state, and receives focus back on close. |
data-stimeo--context-menu-target="region" |
menu
required
|
The role="menu" element shown at the pointer (via CSS custom properties) and hidden via hidden. |
data-stimeo--context-menu-target="menu" |
item
required
|
A role="menuitem" command with roving focus; activating it closes the menu and restores focus. |
data-stimeo--context-menu-target="item" |
Actions
| Name | Description | Action |
|---|---|---|
activate
|
Closes the menu after an item is activated and restores focus to the region. | stimeo--context-menu#activate |
onItemKeydown
|
Roving focus and closing keys inside the menu: Arrow keys (wrapping), Home/End, Escape (close+restore), Tab (close). | stimeo--context-menu#onItemKeydown |
onRegionKeydown
|
Opens the menu at the region's center on Shift+F10 or the ContextMenu key. | stimeo--context-menu#onRegionKeydown |
open
|
Suppresses the native contextmenu and opens this menu at the pointer coordinate, focusing the first item. | stimeo--context-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 |
|---|---|---|
data-state |
Region | "open" / "closed" — a CSS hook for the open state. |
hidden |
Menu | Removed when open, present when closed. |
--stimeo-context-menu-x / -y |
Menu | The click coordinate (px); the consumer's CSS uses it to place the menu. |