APG Patterns
English GitHub
English GitHub

Menu Button

アクションまたはオプションのメニューを開くボタン。

🤖 AI 実装ガイド

デモ

基本的な Menu Button

ボタンをクリックするか、キーボードを使用してメニューを開きます。

Last action: None

無効な項目を含む

無効な項目は、キーボードナビゲーション中にスキップされます。

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.svelte
<script lang="ts">
  import { onDestroy, tick } from 'svelte';

  export interface MenuItem {
    id: string;
    label: string;
    disabled?: boolean;
  }

  interface MenuButtonProps {
    items: MenuItem[];
    label: string;
    defaultOpen?: boolean;
    onItemSelect?: (itemId: string) => void;
    class?: string;
  }

  let {
    items = [],
    label,
    defaultOpen = false,
    onItemSelect = () => {},
    class: className = '',
    ...restProps
  }: MenuButtonProps = $props();

  // State - capture defaultOpen value to avoid reactivity warning
  const initialOpen = defaultOpen;
  let isOpen = $state(initialOpen);
  let focusedIndex = $state(-1);
  // Generate ID immediately for SSR-safe aria-controls/aria-labelledby
  const instanceId = `menu-button-${Math.random().toString(36).slice(2, 11)}`;
  let typeAheadBuffer = $state('');
  let typeAheadTimeoutId: number | null = null;
  const typeAheadTimeout = 500;

  // Refs
  let containerElement: HTMLDivElement;
  let buttonElement: HTMLButtonElement;
  let menuItemRefs = new Map<string, HTMLLIElement>();

  // Derived
  let availableItems = $derived(items.filter((item) => !item.disabled));

  // Map of item id to index in availableItems for O(1) lookup
  let availableIndexMap = $derived.by(() => {
    const map = new Map<string, number>();
    availableItems.forEach(({ id }, index) => map.set(id, index));
    return map;
  });

  let buttonId = $derived(`${instanceId}-button`);
  let menuId = $derived(`${instanceId}-menu`);

  onDestroy(() => {
    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
    }
    if (typeof document !== 'undefined') {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Focus effect - explicitly track dependencies with stale check
  $effect(() => {
    const open = isOpen;
    const index = focusedIndex;
    const items = availableItems;

    if (open && index >= 0) {
      const targetItem = items[index];
      if (targetItem) {
        const targetId = targetItem.id;
        tick().then(() => {
          // Guard against stale focus: check menu is still open
          if (isOpen && focusedIndex >= 0) {
            menuItemRefs.get(targetId)?.focus();
          }
        });
      }
    }
  });

  // Click outside effect (browser-only)
  $effect(() => {
    if (typeof document === 'undefined') return;

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    } else {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Action to track menu item refs
  function trackItemRef(node: HTMLLIElement, itemId: string) {
    menuItemRefs.set(itemId, node);
    return {
      destroy() {
        menuItemRefs.delete(itemId);
      },
    };
  }

  function getTabIndex(item: MenuItem): number {
    if (item.disabled) return -1;
    const availableIndex = availableIndexMap.get(item.id) ?? -1;
    return availableIndex === focusedIndex ? 0 : -1;
  }

  function closeMenu() {
    isOpen = false;
    focusedIndex = -1;
    // Clear type-ahead state
    typeAheadBuffer = '';
    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
      typeAheadTimeoutId = null;
    }
  }

  function openMenu(focusPosition: 'first' | 'last') {
    if (availableItems.length === 0) {
      isOpen = true;
      return;
    }

    isOpen = true;
    const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
    focusedIndex = targetIndex;
  }

  function toggleMenu() {
    if (isOpen) {
      closeMenu();
    } else {
      openMenu('first');
    }
  }

  function handleClickOutside(event: MouseEvent) {
    if (containerElement && !containerElement.contains(event.target as Node)) {
      closeMenu();
    }
  }

  async function handleItemClick(item: MenuItem) {
    if (item.disabled) return;
    onItemSelect(item.id);
    closeMenu();
    await tick();
    buttonElement?.focus();
  }

  function handleItemFocus(item: MenuItem) {
    if (item.disabled) return;
    const availableIndex = availableIndexMap.get(item.id) ?? -1;
    if (availableIndex >= 0) {
      focusedIndex = availableIndex;
    }
  }

  function 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;
    }
  }

  function handleTypeAhead(char: string) {
    if (availableItems.length === 0) return;

    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
    }

    typeAheadBuffer += char.toLowerCase();

    const buffer = typeAheadBuffer;
    const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

    let startIndex: number;
    let searchStr: string;

    if (isSameChar) {
      typeAheadBuffer = buffer[0];
      searchStr = buffer[0];
      startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
    } else if (buffer.length === 1) {
      searchStr = buffer;
      startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
    } else {
      searchStr = buffer;
      startIndex = focusedIndex >= 0 ? focusedIndex : 0;
    }

    for (let i = 0; i < availableItems.length; i++) {
      const index = (startIndex + i) % availableItems.length;
      const option = availableItems[index];
      if (option.label.toLowerCase().startsWith(searchStr)) {
        focusedIndex = index;
        break;
      }
    }

    typeAheadTimeoutId = window.setTimeout(() => {
      typeAheadBuffer = '';
      typeAheadTimeoutId = null;
    }, typeAheadTimeout);
  }

  async function handleMenuKeyDown(event: KeyboardEvent, item: MenuItem) {
    const itemsLength = availableItems.length;

    // Guard: no available items
    if (itemsLength === 0) {
      if (event.key === 'Escape') {
        event.preventDefault();
        closeMenu();
        await tick();
        buttonElement?.focus();
      }
      return;
    }

    const currentIndex = availableIndexMap.get(item.id) ?? -1;

    // Guard: disabled item received focus
    if (currentIndex < 0) {
      if (event.key === 'Escape') {
        event.preventDefault();
        closeMenu();
        await tick();
        buttonElement?.focus();
      }
      return;
    }

    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault();
        const nextIndex = (currentIndex + 1) % itemsLength;
        focusedIndex = nextIndex;
        break;
      }
      case 'ArrowUp': {
        event.preventDefault();
        const prevIndex = currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
        focusedIndex = prevIndex;
        break;
      }
      case 'Home': {
        event.preventDefault();
        focusedIndex = 0;
        break;
      }
      case 'End': {
        event.preventDefault();
        focusedIndex = itemsLength - 1;
        break;
      }
      case 'Escape': {
        event.preventDefault();
        closeMenu();
        await tick();
        buttonElement?.focus();
        break;
      }
      case 'Tab': {
        closeMenu();
        break;
      }
      case 'Enter':
      case ' ': {
        event.preventDefault();
        if (!item.disabled) {
          onItemSelect(item.id);
          closeMenu();
          await tick();
          buttonElement?.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);
        }
      }
    }
  }
</script>

<div bind:this={containerElement} class="apg-menu-button {className}">
  <button
    bind:this={buttonElement}
    id={buttonId}
    type="button"
    class="apg-menu-button-trigger"
    aria-haspopup="menu"
    aria-expanded={isOpen}
    aria-controls={menuId}
    onclick={toggleMenu}
    onkeydown={handleButtonKeyDown}
    {...restProps}
  >
    {label}
  </button>
  <!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
  <ul
    id={menuId}
    role="menu"
    aria-labelledby={buttonId}
    class="apg-menu-button-menu"
    hidden={!isOpen ? true : undefined}
    inert={!isOpen ? true : undefined}
  >
    {#each items as item}
      <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
      <li
        use:trackItemRef={item.id}
        role="menuitem"
        tabindex={getTabIndex(item)}
        aria-disabled={item.disabled || undefined}
        class="apg-menu-button-item"
        onclick={() => handleItemClick(item)}
        onkeydown={(e) => handleMenuKeyDown(e, item)}
        onfocus={() => handleItemFocus(item)}
      >
        {item.label}
      </li>
    {/each}
  </ul>
</div>

使い方

使用例
<script lang="ts">
  import MenuButton from './MenuButton.svelte';

  const items = [
    { id: 'cut', label: 'Cut' },
    { id: 'copy', label: 'Copy' },
    { id: 'paste', label: 'Paste' },
    { id: 'delete', label: 'Delete', disabled: true },
  ];

  function handleItemSelect(itemId: string) {
    console.log('Selected:', itemId);
  }
</script>

<!-- 基本的な使用法 -->
<MenuButton {items} label="Actions" onItemSelect={handleItemSelect} />

<!-- デフォルトの開いた状態 -->
<MenuButton {items} label="Actions" defaultOpen onItemSelect={handleItemSelect} />

API

MenuButton Props

プロパティ デフォルト 説明
items MenuItem[] required メニュー項目の配列
label string required ボタンのラベルテキスト
defaultOpen boolean false メニューが初期状態で開いているかどうか
onItemSelect (id: string) => void - 項目が選択されたときのコールバック
class string '' コンテナの追加 CSS クラス

MenuItem Interface

Types
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) を参照してください。

リソース