Context Menu
stimeo--context-menu
A right-click menu that opens at the pointer and can be called up from the keyboard.
A menu opened by right-clicking. Inside, it behaves like any menu; only how you call it and where it appears differ. From the keyboard it answers to Shift+F10 or the menu key. The browser's own menu is suppressed. The coordinate you clicked is handed to CSS, so the menu can be placed from CSS alone without loading the placement module. When you do want it to turn around at a screen edge, that module can take over. Opening moves focus to the first item, the arrow keys wrap around, and Home and End jump to the ends. Activating an item or pressing Esc closes it and returns focus where it came from. Tabbing out closes it without pulling focus back.
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="demo-menuitem" 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="demo-menuitem" 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="demo-menuitem" 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 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. |