コンテキストメニュー
stimeo--context-menu
ポインタ位置に開く右クリックメニュー。ロービングフォーカスとキーボード操作を担う。
stimeo--context-menu コントローラは、WAI-ARIA の Menu パターンを実装する。メニューボタンとの違いは起動方法(クリックではなく contextmenu イベントや Shift+F10/ContextMenu キー)と、ポインタ位置への表示だけ。ブラウザ標準メニューを抑止し、クリック座標を --stimeo-context-menu-x/-y という CSS カスタムプロパティとしてメニューに反映するため、 positioning モジュール未接続でも consumer の CSS だけで配置できる。画面端でのフリップ/シフトは opt-in の stimeo-ui/positioning に委譲する。開いたとき先頭項目へフォーカスし、矢印キーでループ移動、Home/End で先頭/末尾、項目実行または Escape で閉じて領域へフォーカスを戻し、Tab では戻さず閉じる。
キーボード操作
| キー | 動作 |
|---|---|
| Shift+F10 / ContextMenu | フォーカス中の領域からメニューを開く(領域の中央に表示)。 |
| ↑ / ↓ | 前 / 次の項目へフォーカス移動(ループ)。 |
| Home / End | 先頭 / 末尾の項目へフォーカス移動。 |
| Enter / Space | フォーカス中の項目を実行して閉じる(ネイティブ button)。 |
| Esc | メニューを閉じて領域へフォーカスを戻す。 |
| Tab | メニューを閉じる。フォーカスは自然に次へ移動する。 |
<%# 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()}`);
});
});
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--context-menu"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
region
必須
|
contextmenu/Shift+F10 でメニューを開くコンテナ。aria-haspopup と data-state を持ち、閉じるとフォーカスが戻る。 |
data-stimeo--context-menu-target="region" |
menu
必須
|
ポインタ位置(CSS カスタムプロパティ経由)に表示し hidden で隠す role="menu" 要素。 |
data-stimeo--context-menu-target="menu" |
item
必須
|
ロービングフォーカスを持つ role="menuitem" コマンド。実行するとメニューを閉じてフォーカスを戻す。 |
data-stimeo--context-menu-target="item" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
activate
|
項目実行後にメニューを閉じ、フォーカスをリージョンへ戻す。 | stimeo--context-menu#activate |
onItemKeydown
|
メニュー内のロービングフォーカスと閉じるキー。矢印キー(循環)、Home/End、Escape(閉じて戻す)、Tab(閉じる)。 | stimeo--context-menu#onItemKeydown |
onRegionKeydown
|
Shift+F10 または ContextMenu キーでリージョン中央にメニューを開く。 | stimeo--context-menu#onRegionKeydown |
open
|
ネイティブの contextmenu を抑止し、ポインタ座標にメニューを開いて最初の項目へフォーカスする。 | stimeo--context-menu#open |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-state |
領域 | "open" / "closed"。開閉状態の CSS フック。 |
hidden |
メニュー | 開で属性なし、閉で付与。 |
--stimeo-context-menu-x / -y |
メニュー | クリック座標(px)。consumer の CSS が配置に用いる。 |