Menu Button
アクションやオプションのメニューを開くボタン。
🤖 AI 実装ガイドデモ
基本的な Menu Button
ボタンをクリックするか、キーボードを使用してメニューを開きます。
Last action: None
無効な項目を含む Menu Button
無効な項目は、キーボードナビゲーション中にスキップされます。
Last action: None
Note: "Export" is disabled and will be skipped during keyboard navigation
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
button | トリガー(<button>) | メニューを開くトリガー(<button>要素による暗黙的なロール) |
menu | コンテナ(<ul>) | ユーザーに選択肢のリストを提供するウィジェット |
menuitem | 各アイテム(<li>) | メニュー内のオプション |
WAI-ARIA menu role (opens in new tab)
WAI-ARIA プロパティ(ボタン)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
aria-haspopup | "menu" | はい | ボタンがメニューを開くことを示す |
aria-expanded | true | false | はい | メニューが開いているかどうかを示す |
aria-controls | ID参照 | いいえ | メニュー要素を参照する |
WAI-ARIA プロパティ(メニュー)
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-labelledby | menu | ID参照 | はい* | メニューを開くボタンを参照する |
aria-label | menu | 文字列 | はい* | メニューのアクセシブルな名前を提供する |
aria-disabled | menuitem | true | いいえ | メニューアイテムが無効であることを示す |
* aria-labelledbyまたはaria-labelのいずれかがアクセシブルな名前のために必須です
キーボードサポート
ボタン(メニューが閉じている状態)
| キー | アクション |
|---|---|
| Enter / Space | メニューを開き、最初のアイテムにフォーカスを移動する |
| Down Arrow | メニューを開き、最初のアイテムにフォーカスを移動する |
| Up Arrow | メニューを開き、最後のアイテムにフォーカスを移動する |
メニュー(開いている状態)
| キー | アクション |
|---|---|
| Down Arrow | 次のアイテムにフォーカスを移動する(最後の項目から最初にラップする) |
| Up Arrow | 前のアイテムにフォーカスを移動する(最初の項目から最後にラップする) |
| Home | 最初のアイテムにフォーカスを移動する |
| End | 最後のアイテムにフォーカスを移動する |
| Escape | メニューを閉じ、フォーカスをボタンに戻す |
| Tab | メニューを閉じ、次のフォーカス可能な要素にフォーカスを移動する |
| Enter / Space | フォーカスされたアイテムを実行し、メニューを閉じる |
| 文字を入力 | 先行入力: 入力された文字で始まるアイテムにフォーカスを移動する |
フォーカス管理
このコンポーネントは、フォーカス管理にRoving Tabindexパターンを使用します:
- 一度に1つのメニューアイテムのみが
tabindex="0"を持つ - その他のメニューアイテムは
tabindex="-1"を持つ - 矢印キーでアイテム間のフォーカス移動(ラップあり)
- 無効なアイテムはナビゲーション中にスキップされる
- メニューが閉じると、フォーカスはボタンに戻る
非表示状態
閉じているとき、メニューはhiddenとinert属性の両方を使用して以下を実現します:
- 視覚的な表示からメニューを隠す
- アクセシビリティツリーからメニューを削除する
- 非表示のアイテムに対するキーボードとマウスの操作を防ぐ
ソースコード
MenuButton.vue
<template>
<div ref="containerRef" :class="`apg-menu-button ${className}`.trim()">
<button
ref="buttonRef"
:id="buttonId"
type="button"
class="apg-menu-button-trigger"
aria-haspopup="menu"
:aria-expanded="isOpen"
:aria-controls="menuId"
v-bind="$attrs"
@click="toggleMenu"
@keydown="handleButtonKeyDown"
>
{{ label }}
</button>
<ul
:id="menuId"
role="menu"
:aria-labelledby="buttonId"
class="apg-menu-button-menu"
:hidden="!isOpen || undefined"
:inert="!isOpen || undefined"
>
<li
v-for="item in items"
:key="item.id"
:ref="(el) => setItemRef(item.id, el)"
role="menuitem"
:tabindex="getTabIndex(item)"
:aria-disabled="item.disabled || undefined"
class="apg-menu-button-item"
@click="handleItemClick(item)"
@keydown="(e) => handleMenuKeyDown(e, item)"
@focus="handleItemFocus(item)"
>
{{ item.label }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, nextTick, watch, useId } from 'vue';
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
}
export interface MenuButtonProps {
items: MenuItem[];
label: string;
defaultOpen?: boolean;
className?: string;
}
const props = withDefaults(defineProps<MenuButtonProps>(), {
defaultOpen: false,
className: '',
});
const emit = defineEmits<{
itemSelect: [itemId: string];
}>();
defineOptions({
inheritAttrs: false,
});
// Refs
const containerRef = ref<HTMLDivElement>();
const buttonRef = ref<HTMLButtonElement>();
const menuItemRefs = ref<Record<string, HTMLLIElement>>({});
// Use Vue 3.5+ useId for SSR-safe unique IDs
const instanceId = useId();
const isOpen = ref(props.defaultOpen);
const focusedIndex = ref(-1);
const typeAheadBuffer = ref('');
const typeAheadTimeoutId = ref<number | null>(null);
const typeAheadTimeout = 500;
// Computed
const buttonId = computed(() => `${instanceId}-button`);
const menuId = computed(() => `${instanceId}-menu`);
const availableItems = computed(() => props.items.filter((item) => !item.disabled));
// Map of item id to index in availableItems for O(1) lookup
const availableIndexMap = computed(() => {
const map = new Map<string, number>();
availableItems.value.forEach(({ id }, index) => map.set(id, index));
return map;
});
onUnmounted(() => {
if (typeAheadTimeoutId.value !== null) {
clearTimeout(typeAheadTimeoutId.value);
}
});
// Watch focusedIndex to focus the correct item (also react to availableItems changes)
watch([() => isOpen.value, () => focusedIndex.value, availableItems], async () => {
if (!isOpen.value || focusedIndex.value < 0) return;
const targetItem = availableItems.value[focusedIndex.value];
if (targetItem) {
await nextTick();
menuItemRefs.value[targetItem.id]?.focus();
}
});
// Helper functions
const setItemRef = (id: string, el: unknown) => {
if (el instanceof HTMLLIElement) {
menuItemRefs.value[id] = el;
} else if (el === null) {
delete menuItemRefs.value[id];
}
};
const getTabIndex = (item: MenuItem): number => {
if (item.disabled) return -1;
const availableIndex = availableIndexMap.value.get(item.id) ?? -1;
return availableIndex === focusedIndex.value ? 0 : -1;
};
// Menu control
const closeMenu = () => {
isOpen.value = false;
focusedIndex.value = -1;
// Clear type-ahead state
typeAheadBuffer.value = '';
if (typeAheadTimeoutId.value !== null) {
clearTimeout(typeAheadTimeoutId.value);
typeAheadTimeoutId.value = null;
}
};
const openMenu = (focusPosition: 'first' | 'last') => {
if (availableItems.value.length === 0) {
isOpen.value = true;
return;
}
isOpen.value = true;
const targetIndex = focusPosition === 'first' ? 0 : availableItems.value.length - 1;
focusedIndex.value = targetIndex;
};
const toggleMenu = () => {
if (isOpen.value) {
closeMenu();
} else {
openMenu('first');
}
};
// Event handlers
const handleItemClick = async (item: MenuItem) => {
if (item.disabled) return;
emit('itemSelect', item.id);
closeMenu();
await nextTick();
buttonRef.value?.focus();
};
const handleItemFocus = (item: MenuItem) => {
if (item.disabled) return;
const availableIndex = availableIndexMap.value.get(item.id) ?? -1;
if (availableIndex >= 0) {
focusedIndex.value = availableIndex;
}
};
const handleButtonKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
openMenu('first');
break;
case 'ArrowDown':
event.preventDefault();
openMenu('first');
break;
case 'ArrowUp':
event.preventDefault();
openMenu('last');
break;
}
};
const handleTypeAhead = (char: string) => {
const { value: items } = availableItems;
if (items.length === 0) return;
if (typeAheadTimeoutId.value !== null) {
clearTimeout(typeAheadTimeoutId.value);
}
typeAheadBuffer.value += char.toLowerCase();
const buffer = typeAheadBuffer.value;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
const currentFocusedIndex = focusedIndex.value;
const itemsLength = items.length;
let startIndex: number;
let searchStr: string;
if (isSameChar) {
typeAheadBuffer.value = buffer[0];
searchStr = buffer[0];
startIndex = currentFocusedIndex >= 0 ? (currentFocusedIndex + 1) % itemsLength : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = currentFocusedIndex >= 0 ? (currentFocusedIndex + 1) % itemsLength : 0;
} else {
searchStr = buffer;
startIndex = currentFocusedIndex >= 0 ? currentFocusedIndex : 0;
}
for (let i = 0; i < itemsLength; i++) {
const index = (startIndex + i) % itemsLength;
const option = items[index];
if (option.label.toLowerCase().startsWith(searchStr)) {
focusedIndex.value = index;
break;
}
}
typeAheadTimeoutId.value = window.setTimeout(() => {
typeAheadBuffer.value = '';
typeAheadTimeoutId.value = null;
}, typeAheadTimeout);
};
const handleMenuKeyDown = async (event: KeyboardEvent, item: MenuItem) => {
const { value: items } = availableItems;
const itemsLength = items.length;
// Guard: no available items
if (itemsLength === 0) {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
await nextTick();
buttonRef.value?.focus();
}
return;
}
const currentIndex = availableIndexMap.value.get(item.id) ?? -1;
// Guard: disabled item received focus
if (currentIndex < 0) {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
await nextTick();
buttonRef.value?.focus();
}
return;
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = (currentIndex + 1) % itemsLength;
focusedIndex.value = nextIndex;
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
focusedIndex.value = prevIndex;
break;
}
case 'Home': {
event.preventDefault();
focusedIndex.value = 0;
break;
}
case 'End': {
event.preventDefault();
focusedIndex.value = itemsLength - 1;
break;
}
case 'Escape': {
event.preventDefault();
closeMenu();
await nextTick();
buttonRef.value?.focus();
break;
}
case 'Tab': {
closeMenu();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
if (!item.disabled) {
emit('itemSelect', item.id);
closeMenu();
await nextTick();
buttonRef.value?.focus();
}
break;
}
default: {
// Type-ahead: single printable character
const { key, ctrlKey, metaKey, altKey } = event;
if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
event.preventDefault();
handleTypeAhead(key);
}
}
}
};
// Click outside handler
const handleClickOutside = (event: MouseEvent) => {
const { value: container } = containerRef;
if (container && !container.contains(event.target as Node)) {
closeMenu();
}
};
watch(
() => isOpen.value,
(newIsOpen) => {
if (newIsOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
}
);
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
</script> 使い方
使用例
<script setup lang="ts">
import MenuButton from './MenuButton.vue';
const items = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
{ id: 'delete', label: 'Delete', disabled: true },
];
const handleItemSelect = (itemId: string) => {
console.log('Selected:', itemId);
};
</script>
<template>
<!-- 基本的な使用法 -->
<MenuButton
:items="items"
label="Actions"
@item-select="handleItemSelect"
/>
<!-- デフォルトの開いた状態 -->
<MenuButton
:items="items"
label="Actions"
default-open
@item-select="handleItemSelect"
/>
</template> API
MenuButton プロパティ
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | MenuItem[] | 必須 | メニュー項目の配列 |
label | string | 必須 | ボタンのラベルテキスト |
defaultOpen | boolean | false | メニューが初期状態で開いているかどうか |
className | string | '' | コンテナの追加 CSS クラス |
イベント
| イベント | ペイロード | 説明 |
|---|---|---|
item-select | string | メニュー項目が選択されたときに発行されます |
MenuItem インターフェース
型定義
interface MenuItem {
id: string;
label: string;
disabled?: boolean;
} テスト
テストでは、キーボード操作、ARIA属性、アクセシビリティ要件全般にわたってAPG準拠を検証します。
テストカテゴリ
高優先度: APG マウス操作
| テスト | 説明 |
|---|---|
Button click | ボタンをクリックするとメニューが開く |
Toggle | ボタンを再度クリックするとメニューが閉じる |
Item click | メニューアイテムをクリックすると実行され、メニューが閉じる |
Disabled item click | 無効なアイテムをクリックしても何も起こらない |
Click outside | メニューの外側をクリックするとメニューが閉じる |
高優先度: APG キーボード操作(ボタン)
| テスト | 説明 |
|---|---|
Enter | メニューを開き、最初の有効なアイテムにフォーカスを移動する |
Space | メニューを開き、最初の有効なアイテムにフォーカスを移動する |
ArrowDown | メニューを開き、最初の有効なアイテムにフォーカスを移動する |
ArrowUp | メニューを開き、最後の有効なアイテムにフォーカスを移動する |
高優先度: APG キーボード操作(メニュー)
| テスト | 説明 |
|---|---|
ArrowDown | 次の有効なアイテムにフォーカスを移動する(ラップする) |
ArrowUp | 前の有効なアイテムにフォーカスを移動する(ラップする) |
Home | 最初の有効なアイテムにフォーカスを移動する |
End | 最後の有効なアイテムにフォーカスを移動する |
Escape | メニューを閉じ、フォーカスをボタンに戻す |
Tab | メニューを閉じ、フォーカスを外に移動する |
Enter/Space | アイテムを実行し、メニューを閉じる |
Disabled skip | ナビゲーション中に無効なアイテムをスキップする |
高優先度: 先行入力検索
| テスト | 説明 |
|---|---|
Single character | 入力された文字で始まる最初のアイテムにフォーカスを移動する |
Multiple characters | 500ms以内に入力された文字がプレフィックス検索文字列を形成する |
Wrap around | 検索が終わりから始まりにラップする |
Buffer reset | 500msの非アクティブ後にバッファがリセットされる |
高優先度: APG ARIA 属性
| テスト | 説明 |
|---|---|
aria-haspopup | ボタンがaria-haspopup="menu"を持つ |
aria-expanded | ボタンが開いている状態を反映する(true/false) |
aria-controls | ボタンがメニューのIDを参照する |
role="menu" | メニューコンテナがmenuロールを持つ |
role="menuitem" | 各アイテムがmenuitemロールを持つ |
aria-labelledby | メニューがアクセシブルな名前のためにボタンを参照する |
aria-disabled | 無効なアイテムがaria-disabled="true"を持つ |
高優先度: フォーカス管理(Roving Tabindex)
| テスト | 説明 |
|---|---|
tabIndex=0 | フォーカスされたアイテムがtabIndex=0を持つ |
tabIndex=-1 | フォーカスされていないアイテムがtabIndex=-1を持つ |
Initial focus | メニューが開くと最初の有効なアイテムがフォーカスを受け取る |
Focus return | メニューが閉じるとフォーカスがボタンに戻る |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反がないこと(jest-axe経由) |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントについては testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Menu Button パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボード操作、テストチェックリスト