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 menu itself follows the APG Menu
pattern. The region's role and name are yours: here a named group, which
advertises its opening key with aria-keyshortcuts. 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"
role="group"
aria-label="<%= t("components.context_menu.demo.region_label") %>"
aria-keyshortcuts="Shift+F10"
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>
<%# 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 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()}`);
});
});
});
// 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--context-menu']");
for (const name of ["open", "close"]) {
stateRoot?.addEventListener(`stimeo--context-menu:${name}`, (event) => {
const reason = event.detail?.reason;
if (stateReadout) {
stateReadout.textContent = reason
? `stimeo--context-menu:${name} · reason: ${reason}`
: `stimeo--context-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--context-menu"
Targets
| Name | Description | Attribute |
|---|---|---|
region
required
|
The focusable element that opens the menu via contextmenu/Shift+F10, with an authored role and name (e.g. role="group" + aria-label), aria-keyshortcuts="Shift+F10" and aria-controls; carries 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 |
Events
| Name | Description | Event |
|---|---|---|
close
|
Fires right after the menu closes; detail.reason says what moved it. |
stimeo--context-menu:close |
open
|
Fires right after the menu opens; detail.reason says what moved it. |
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. |