APG Patterns
English GitHub
English GitHub

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"を持つ
  • 矢印キーでアイテム間のフォーカス移動(ラップあり)
  • 無効なアイテムはナビゲーション中にスキップされる
  • メニューが閉じると、フォーカスはボタンに戻る

非表示状態

閉じているとき、メニューはhiddeninert属性の両方を使用して以下を実現します:

  • 視覚的な表示からメニューを隠す
  • アクセシビリティツリーからメニューを削除する
  • 非表示のアイテムに対するキーボードとマウスの操作を防ぐ

ソースコード

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経由)

テストツール

詳細なドキュメントについては testing-strategy.md (opens in new tab) を参照してください。

リソース