メニューボタン
stimeo--menu
コマンドを並べる本物のメニュー。矢印キーで項目を渡り歩けます。開閉だけの dropdown とは別物です。
ボタンから開くコマンドのメニューです。WAI-ARIA の Menu Button パターンの実装で、矢印キーで項目を渡り歩き、Home と End で両端へ飛べます。Esc、Tab、外側のクリック、項目の実行で閉じます。使えない項目の扱いは 2 通りです。完全に無効な項目は矢印キーの移動先から外れ、無効だと伝えたい項目はフォーカスは当たるが実行はされません。クリックもキーもメニュー全体で受けるので、項目ごとの設定は要らず、あとから足した項目もそのまま動きます。位置はこのデモの CSS で決めています。
実行中
キーボード操作
| キー | 動作 |
|---|---|
| Enter / Space / ↓ | メニューを開き、最初の項目にフォーカスする。 |
| ↑ | メニューを開き、最後の項目にフォーカスする。 |
| ↓ / ↑ | 項目間でフォーカスを移動する(末尾で循環)。 |
| Home / End | 最初 / 最後の項目にフォーカスする。 |
| 項目上の Enter / Space | 有効な項目を実行する。aria-disabled の項目は実行しない。 |
| Esc | メニューを閉じ、フォーカスをトリガーへ戻す。 |
<%# 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}`;
}
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--menu"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
trigger
必須
|
メニューを開閉するボタン。aria-haspopup/aria-expanded を持ち、閉じるとフォーカスが戻る。 |
data-stimeo--menu-target="trigger" |
menu
必須
|
hidden で表示制御する名前付きの role="menu" 要素。aria-labelledby でトリガーを参照するか、aria-label を付与する。 |
data-stimeo--menu-target="menu" |
item
|
ロービングフォーカスを持つ role="menuitem" コマンド。aria-disabled=true を持つ項目と、aria-disabled=true を持つ要素の中にある項目は、矢印キーで到達できるが実行できない。hidden とネイティブ disabled の項目は移動先から外れ、実行もできない。 |
data-stimeo--menu-target="item" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
activate
|
有効な項目の実行後にメニューを閉じ、フォーカスをトリガーへ戻す。aria-disabled の実行は抑止する。 | stimeo--menu#activate |
close
|
メニューを閉じ、トリガーに折りたたみ状態を反映する。 | stimeo--menu#close |
onItemKeydown
|
メニュー内のロービングフォーカスと閉じるキー。矢印キー(循環)、Home/End、Escape(閉じてトリガーへ)、Tab(閉じる)。 | stimeo--menu#onItemKeydown |
onTriggerKeydown
|
キーボードでトリガーからメニューを開く。ArrowDown で先頭、ArrowUp で末尾の項目にフォーカス。 | stimeo--menu#onTriggerKeydown |
open
|
メニューを開き、トリガーに展開状態を反映する。 | stimeo--menu#open |
toggle
|
メニューを開閉する。開くときは先頭の項目にフォーカスする。 | stimeo--menu#toggle |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
close
|
メニューを閉じた直後に発火。detail の reason に、何がそうさせたかを載せる。 | stimeo--menu:close |
open
|
メニューを開いた直後に発火。detail の reason に、何がそうさせたかを載せる。 | stimeo--menu:open |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-expanded |
トリガー | メニューが開いている間は "true"、閉じると "false"。 |
hidden |
メニュー | 開くと外れ、閉じると付与される。 |