APG Patterns
English
English

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 プロパティ

aria-haspopup

ボタンがメニューを開くことを示す

menu
必須
はい

aria-controls

メニュー要素を参照する

ID参照
必須
いいえ

aria-labelledby

メニューを開くボタンを参照する

ID参照
必須
はい(またはaria-label)

aria-label

メニューのアクセシブルな名前を提供する

文字列
必須
はい(またはaria-labelledby)

aria-disabled

メニューアイテムが無効であることを示す

true
必須
いいえ

WAI-ARIA ステート

aria-expanded

対象要素
button
true | false
必須
はい
変更トリガー
メニューを開く/閉じる

キーボードサポート

ボタン(メニューが閉じている状態)

キーアクション
Enter / Spaceメニューを開き、最初のアイテムにフォーカスを移動する
Down Arrowメニューを開き、最初のアイテムにフォーカスを移動する
Up Arrowメニューを開き、最後のアイテムにフォーカスを移動する

メニュー(開いている状態)

キーアクション
Down Arrow次のアイテムにフォーカスを移動する(最後の項目から最初にラップする)
Up Arrow前のアイテムにフォーカスを移動する(最初の項目から最後にラップする)
Home最初のアイテムにフォーカスを移動する
End最後のアイテムにフォーカスを移動する
Escapeメニューを閉じ、フォーカスをボタンに戻す
Tabメニューを閉じ、次のフォーカス可能な要素にフォーカスを移動する
Enter / Spaceフォーカスされたアイテムを実行し、メニューを閉じる
Type character先行入力: 入力された文字で始まるアイテムにフォーカスを移動する
  • 閉じているとき、メニューはhiddenとinert属性の両方を使用して、視覚的な表示からメニューを隠し、アクセシビリティツリーから削除し、非表示のアイテムに対するキーボードとマウスの操作を防ぎます。

フォーカス管理

イベント振る舞い
フォーカスされたメニューアイテムtabIndex="0"
その他のメニューアイテムtabIndex="-1"
矢印キーナビゲーション最後から最初へ、またはその逆にラップする
無効なアイテムナビゲーション中にスキップされる
メニューが閉じるフォーカスがボタンに戻る

参考資料

ソースコード

MenuButton.tsx
import type { ButtonHTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';

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

export interface MenuButtonProps extends Omit<
  ButtonHTMLAttributes<HTMLButtonElement>,
  'aria-haspopup' | 'aria-expanded' | 'aria-controls' | 'type'
> {
  items: MenuItem[];
  label: string;
  onItemSelect?: (itemId: string) => void;
  defaultOpen?: boolean;
}

export function MenuButton({
  items,
  label,
  onItemSelect,
  defaultOpen = false,
  className = '',
  ...restProps
}: MenuButtonProps): ReactElement {
  const instanceId = useId();
  const buttonId = `${instanceId}-button`;
  const menuId = `${instanceId}-menu`;

  const [isOpen, setIsOpen] = useState(defaultOpen);
  const [focusedIndex, setFocusedIndex] = useState(-1);
  const containerRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const menuItemRefs = useRef<Map<string, HTMLLIElement>>(new Map());
  const typeAheadBuffer = useRef<string>('');
  const typeAheadTimeoutId = useRef<number | null>(null);
  const typeAheadTimeout = 500;

  // Get available (non-disabled) items
  const availableItems = useMemo(() => items.filter((item) => !item.disabled), [items]);

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

  const closeMenu = useCallback(() => {
    setIsOpen(false);
    setFocusedIndex(-1);
    // Clear type-ahead state to prevent stale buffer on reopen
    typeAheadBuffer.current = '';
    if (typeAheadTimeoutId.current !== null) {
      clearTimeout(typeAheadTimeoutId.current);
      typeAheadTimeoutId.current = null;
    }
  }, []);

  const openMenu = useCallback(
    (focusPosition: 'first' | 'last') => {
      if (availableItems.length === 0) {
        // All items disabled, open menu but keep focus on button
        setIsOpen(true);
        return;
      }

      setIsOpen(true);
      const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
      setFocusedIndex(targetIndex);
    },
    [availableItems]
  );

  // Focus menu item when focusedIndex changes and menu is open
  useEffect(() => {
    if (!isOpen || focusedIndex < 0) return;

    const targetItem = availableItems[focusedIndex];
    if (targetItem) {
      menuItemRefs.current.get(targetItem.id)?.focus();
    }
  }, [isOpen, focusedIndex, availableItems]);

  const toggleMenu = useCallback(() => {
    if (isOpen) {
      closeMenu();
    } else {
      openMenu('first');
    }
  }, [isOpen, closeMenu, openMenu]);

  const handleItemClick = useCallback(
    (item: MenuItem) => {
      if (item.disabled) return;
      onItemSelect?.(item.id);
      closeMenu();
      buttonRef.current?.focus();
    },
    [onItemSelect, closeMenu]
  );

  const handleButtonKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>) => {
      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;
      }
    },
    [openMenu]
  );

  const handleTypeAhead = useCallback(
    (char: string) => {
      if (availableItems.length === 0) return;

      // Clear existing timeout
      if (typeAheadTimeoutId.current !== null) {
        clearTimeout(typeAheadTimeoutId.current);
      }

      // Add character to buffer
      typeAheadBuffer.current += char.toLowerCase();

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

      // For same char repeated or single char, start from next item to cycle through matches
      // For multi-char string, start from current to allow refining the search
      let startIndex: number;
      let searchStr: string;

      if (isSameChar) {
        // Same character repeated: cycle through matches
        typeAheadBuffer.current = buffer[0];
        searchStr = buffer[0];
        startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
      } else if (buffer.length === 1) {
        // Single character: start from next item to find next match
        searchStr = buffer;
        startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
      } else {
        // Multi-character: refine search from current position
        searchStr = buffer;
        startIndex = focusedIndex >= 0 ? focusedIndex : 0;
      }

      // Search from start index, wrapping around
      for (let i = 0; i < availableItems.length; i++) {
        const index = (startIndex + i) % availableItems.length;
        const option = availableItems[index];
        if (option.label.toLowerCase().startsWith(searchStr)) {
          setFocusedIndex(index);
          break;
        }
      }

      // Set timeout to clear buffer
      typeAheadTimeoutId.current = window.setTimeout(() => {
        typeAheadBuffer.current = '';
        typeAheadTimeoutId.current = null;
      }, typeAheadTimeout);
    },
    [availableItems, focusedIndex]
  );

  const handleMenuKeyDown = useCallback(
    (event: KeyboardEvent<HTMLLIElement>, item: MenuItem) => {
      // Guard: no available items to navigate
      if (availableItems.length === 0) {
        if (event.key === 'Escape') {
          event.preventDefault();
          closeMenu();
          buttonRef.current?.focus();
        }
        return;
      }

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

      // Guard: disabled item received focus (e.g., programmatic focus)
      if (currentIndex < 0) {
        if (event.key === 'Escape') {
          event.preventDefault();
          closeMenu();
          buttonRef.current?.focus();
        }
        return;
      }

      switch (event.key) {
        case 'ArrowDown': {
          event.preventDefault();
          const nextIndex = (currentIndex + 1) % availableItems.length;
          setFocusedIndex(nextIndex);
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          const prevIndex = currentIndex === 0 ? availableItems.length - 1 : currentIndex - 1;
          setFocusedIndex(prevIndex);
          break;
        }
        case 'Home': {
          event.preventDefault();
          setFocusedIndex(0);
          break;
        }
        case 'End': {
          event.preventDefault();
          setFocusedIndex(availableItems.length - 1);
          break;
        }
        case 'Escape': {
          event.preventDefault();
          closeMenu();
          buttonRef.current?.focus();
          break;
        }
        case 'Tab': {
          // Let the browser handle Tab, but close the menu
          closeMenu();
          break;
        }
        case 'Enter':
        case ' ': {
          event.preventDefault();
          if (!item.disabled) {
            onItemSelect?.(item.id);
            closeMenu();
            buttonRef.current?.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);
          }
        }
      }
    },
    [availableIndexMap, availableItems, closeMenu, onItemSelect, handleTypeAhead]
  );

  // Cleanup type-ahead timeout on unmount
  useEffect(() => {
    return () => {
      if (typeAheadTimeoutId.current !== null) {
        clearTimeout(typeAheadTimeoutId.current);
      }
    };
  }, []);

  // Click outside to close
  useEffect(() => {
    if (!isOpen) return;

    const handleClickOutside = (event: MouseEvent) => {
      if (
        containerRef.current &&
        event.target instanceof Node &&
        !containerRef.current.contains(event.target)
      ) {
        closeMenu();
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [isOpen, closeMenu]);

  return (
    <div ref={containerRef} className={`apg-menu-button ${className}`.trim()}>
      <button
        ref={buttonRef}
        id={buttonId}
        type="button"
        className="apg-menu-button-trigger"
        aria-haspopup="menu"
        aria-expanded={isOpen}
        aria-controls={menuId}
        onClick={toggleMenu}
        onKeyDown={handleButtonKeyDown}
        {...restProps}
      >
        {label}
      </button>
      <ul
        id={menuId}
        role="menu"
        aria-labelledby={buttonId}
        className="apg-menu-button-menu"
        hidden={!isOpen || undefined}
        inert={!isOpen || undefined}
      >
        {items.map((item) => {
          const availableIndex = availableIndexMap.get(item.id) ?? -1;
          const isFocused = availableIndex === focusedIndex;
          const tabIndex = item.disabled ? -1 : isFocused ? 0 : -1;

          return (
            <li
              key={item.id}
              ref={(el) => {
                if (el) {
                  menuItemRefs.current.set(item.id, el);
                } else {
                  menuItemRefs.current.delete(item.id);
                }
              }}
              role="menuitem"
              tabIndex={tabIndex}
              aria-disabled={item.disabled || undefined}
              className="apg-menu-button-item"
              onClick={() => handleItemClick(item)}
              onKeyDown={(e) => handleMenuKeyDown(e, item)}
              onFocus={() => {
                if (!item.disabled && availableIndex >= 0) {
                  setFocusedIndex(availableIndex);
                }
              }}
            >
              {item.label}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

export default MenuButton;

使い方

Example
import { MenuButton } from './MenuButton';

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

// Basic usage
<MenuButton
  items={items}
  label="Actions"
  onItemSelect={(id) => console.log('Selected:', id)}
/>

// With default open state
<MenuButton
  items={items}
  label="Actions"
  defaultOpen
  onItemSelect={(id) => console.log('Selected:', id)}
/>

API

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

テスト

テストでは、キーボード操作、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経由)

テストコード例

以下は実際のE2Eテストファイルです (e2e/menu-button.spec.ts).

e2e/menu-button.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Menu Button Pattern
 *
 * A button that opens a menu containing menu items. The button has
 * aria-haspopup="menu" and controls a dropdown menu.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getMenuButton = (page: import('@playwright/test').Page) => {
  return page.getByRole('button', { name: /actions|file/i }).first();
};

const getMenu = (page: import('@playwright/test').Page) => {
  return page.getByRole('menu');
};

const getMenuItems = (page: import('@playwright/test').Page) => {
  return page.getByRole('menuitem');
};

const openMenu = async (page: import('@playwright/test').Page) => {
  const button = getMenuButton(page);
  await button.click();
  await getMenu(page).waitFor({ state: 'visible' });
  return button;
};

// Wait for hydration to complete
// This is necessary for frameworks like Svelte where event handlers are attached after hydration
const waitForHydration = async (page: import('@playwright/test').Page) => {
  const button = getMenuButton(page);
  // Wait for aria-controls to be set (basic check)
  await expect(button).toHaveAttribute('aria-controls', /.+/);
  // Poll until a click actually opens the menu (ensures handlers are attached)
  await expect
    .poll(async () => {
      await button.click();
      const isOpen = await getMenu(page).isVisible();
      if (isOpen) {
        await page.keyboard.press('Escape');
      }
      return isOpen;
    })
    .toBe(true);
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Menu Button (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      // Wait for hydration in frameworks that need it (Svelte)
      // This ensures event handlers are attached before tests run
      if (framework === 'svelte') {
        await waitForHydration(page);
      }
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('button has aria-haspopup="menu"', async ({ page }) => {
        const button = getMenuButton(page);
        await expect(button).toHaveAttribute('aria-haspopup', 'menu');
      });

      test('button has aria-expanded (false when closed)', async ({ page }) => {
        const button = getMenuButton(page);
        await expect(button).toHaveAttribute('aria-expanded', 'false');
      });

      test('button has aria-expanded (true when open)', async ({ page }) => {
        const button = await openMenu(page);
        await expect(button).toHaveAttribute('aria-expanded', 'true');
      });

      test('button has aria-controls referencing menu id', async ({ page }) => {
        const button = getMenuButton(page);

        // Wait for hydration - aria-controls may not be set immediately in Svelte
        await expect
          .poll(async () => {
            const id = await button.getAttribute('aria-controls');
            return id && id.length > 1 && !id.startsWith('-');
          })
          .toBe(true);

        const menuId = await button.getAttribute('aria-controls');
        expect(menuId).toBeTruthy();

        await openMenu(page);
        const menu = getMenu(page);
        await expect(menu).toHaveAttribute('id', menuId!);
      });

      test('menu has role="menu"', async ({ page }) => {
        await openMenu(page);
        const menu = getMenu(page);
        await expect(menu).toBeVisible();
        await expect(menu).toHaveRole('menu');
      });

      test('menu has accessible name via aria-labelledby', async ({ page }) => {
        await openMenu(page);
        const menu = getMenu(page);
        const labelledby = await menu.getAttribute('aria-labelledby');
        expect(labelledby).toBeTruthy();

        // Verify it references the button
        const button = getMenuButton(page);
        const buttonId = await button.getAttribute('id');
        expect(labelledby).toBe(buttonId);
      });

      test('menu items have role="menuitem"', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const count = await items.count();
        expect(count).toBeGreaterThan(0);

        for (let i = 0; i < count; i++) {
          await expect(items.nth(i)).toHaveRole('menuitem');
        }
      });

      test('disabled items have aria-disabled="true"', async ({ page }) => {
        // Use the File menu demo which must have disabled item (Export)
        const fileButton = page.getByRole('button', { name: /file/i });
        await expect(fileButton).toBeVisible();
        await fileButton.click();
        await getMenu(page).waitFor({ state: 'visible' });

        const disabledItem = page.getByRole('menuitem', { name: /export/i });
        await expect(disabledItem).toBeVisible();
        await expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction (Button)
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction (Button)', () => {
      test('Enter opens menu and focuses first item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('Enter');

        await expect(getMenu(page)).toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'true');

        // First item should be focused
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();
      });

      test('Space opens menu and focuses first item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('Space');

        await expect(getMenu(page)).toBeVisible();
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();
      });

      test('ArrowDown opens menu and focuses first item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('ArrowDown');

        await expect(getMenu(page)).toBeVisible();
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();
      });

      test('ArrowUp opens menu and focuses last enabled item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('ArrowUp');

        await expect(getMenu(page)).toBeVisible();

        // Find the last enabled item by checking focus
        const focusedItem = page.locator(':focus');
        await expect(focusedItem).toHaveRole('menuitem');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction (Menu)
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction (Menu)', () => {
      test('ArrowDown moves to next item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('ArrowDown');

        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();
      });

      test('ArrowDown wraps from last to first', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();

        // Focus the first item, then use End to go to last
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('End');

        // Get the last item and verify it's focused
        const lastItem = items.last();
        await expect(lastItem).toBeFocused();

        const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
        await lastItem.press('ArrowDown');
        const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);

        // Should have wrapped to a different item (first)
        expect(focusedAfter).not.toBe(focusedBefore);
      });

      test('ArrowUp moves to previous item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        const secondItem = items.nth(1);

        // Navigate to second item using keyboard
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('ArrowDown');
        await expect(secondItem).toBeFocused();

        await secondItem.press('ArrowUp');

        await expect(firstItem).toBeFocused();
      });

      test('ArrowUp wraps from first to last', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
        await firstItem.press('ArrowUp');
        const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);

        // Should have wrapped to last item
        expect(focusedAfter).not.toBe(focusedBefore);
      });

      test('Home moves to first enabled item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        const secondItem = items.nth(1);

        // Navigate to second item using keyboard
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('ArrowDown');
        await expect(secondItem).toBeFocused();

        await secondItem.press('Home');

        await expect(firstItem).toBeFocused();
      });

      test('End moves to last enabled item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('End');

        // Focus should be on last item (or last enabled item)
        const focusedItem = page.locator(':focus');
        await expect(focusedItem).toHaveRole('menuitem');

        // Should not be the first item anymore
        const focusedText = await focusedItem.textContent();
        const firstText = await firstItem.textContent();
        expect(focusedText).not.toBe(firstText);
      });

      test('Escape closes menu and returns focus to button', async ({ page }) => {
        const button = await openMenu(page);

        await page.keyboard.press('Escape');

        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'false');
        await expect(button).toBeFocused();
      });

      test('Enter activates item and closes menu', async ({ page }) => {
        const button = await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('Enter');

        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toBeFocused();
      });

      test('Space activates item and closes menu', async ({ page }) => {
        const button = await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('Space');

        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toBeFocused();
      });

      test('Tab closes menu', async ({ page }) => {
        await openMenu(page);

        await page.keyboard.press('Tab');

        await expect(getMenu(page)).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management (Roving Tabindex)
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('focused item has tabindex="0"', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();

        await expect(firstItem).toHaveAttribute('tabindex', '0');
      });

      test('non-focused items have tabindex="-1"', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const count = await items.count();

        if (count > 1) {
          const firstItem = items.first();
          await firstItem.focus();

          // Check second item has tabindex="-1"
          const secondItem = items.nth(1);
          await expect(secondItem).toHaveAttribute('tabindex', '-1');
        }
      });

      test('disabled items are skipped during navigation', async ({ page }) => {
        // Use the File menu demo which must have disabled items
        const fileButton = page.getByRole('button', { name: /file/i });
        await expect(fileButton).toBeVisible();
        await fileButton.click();
        await getMenu(page).waitFor({ state: 'visible' });

        // Navigate through all items
        const focusedTexts: string[] = [];
        // Get first focused item
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();

        for (let i = 0; i < 10; i++) {
          const focusedElement = page.locator(':focus');
          const text = await focusedElement.textContent();
          if (text && !focusedTexts.includes(text)) {
            focusedTexts.push(text);
          }
          await focusedElement.press('ArrowDown');
        }

        // "Export" (disabled) should not be in the focused list
        expect(focusedTexts).not.toContain('Export');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Click Interaction
    // ------------------------------------------
    test.describe('APG: Click Interaction', () => {
      test('click button opens menu', async ({ page }) => {
        const button = getMenuButton(page);
        await button.click();

        await expect(getMenu(page)).toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'true');
      });

      test('click button again closes menu (toggle)', async ({ page }) => {
        const button = getMenuButton(page);
        await button.click();
        await expect(getMenu(page)).toBeVisible();

        await button.click();
        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'false');
      });

      test('click menu item activates and closes menu', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();

        await firstItem.click();

        await expect(getMenu(page)).not.toBeVisible();
      });

      test('click outside menu closes it', async ({ page }) => {
        await openMenu(page);

        const menu = getMenu(page);
        const menuBox = await menu.boundingBox();
        expect(menuBox).not.toBeNull();

        const viewportSize = page.viewportSize();
        expect(viewportSize).not.toBeNull();

        // Find a safe position outside menu, handling edge cases
        const candidates = [
          // Above menu (if there's space)
          { x: menuBox!.x + menuBox!.width / 2, y: Math.max(1, menuBox!.y - 20) },
          // Left of menu (if there's space)
          { x: Math.max(1, menuBox!.x - 20), y: menuBox!.y + menuBox!.height / 2 },
          // Right of menu (if there's space)
          {
            x: Math.min(viewportSize!.width - 1, menuBox!.x + menuBox!.width + 20),
            y: menuBox!.y + menuBox!.height / 2,
          },
          // Below menu (if there's space)
          {
            x: menuBox!.x + menuBox!.width / 2,
            y: Math.min(viewportSize!.height - 1, menuBox!.y + menuBox!.height + 20),
          },
        ];

        // Find first candidate that's outside menu bounds
        const isOutsideMenu = (x: number, y: number) =>
          x < menuBox!.x ||
          x > menuBox!.x + menuBox!.width ||
          y < menuBox!.y ||
          y > menuBox!.y + menuBox!.height;

        const safePosition = candidates.find((pos) => isOutsideMenu(pos.x, pos.y));

        if (safePosition) {
          await page.mouse.click(safePosition.x, safePosition.y);
        } else {
          // Fallback: click at viewport corner (1,1)
          await page.mouse.click(1, 1);
        }

        await expect(getMenu(page)).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Type-Ahead
    // ------------------------------------------
    test.describe('Type-Ahead', () => {
      test('single character focuses matching item', async ({ page }) => {
        await openMenu(page);

        // Wait for first item to be focused (menu opens with focus on first item)
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();

        // Type 'p' to find "Paste" - use element.press() for single key
        await firstItem.press('p');

        // Wait for focus to move to item starting with 'p'
        // Use trim() because some frameworks may include whitespace in textContent
        await expect
          .poll(async () => {
            const text = await page.evaluate(
              () => document.activeElement?.textContent?.trim().toLowerCase() || ''
            );
            return text.startsWith('p');
          })
          .toBe(true);
      });

      test('type-ahead wraps around', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();

        // Navigate to last item using keyboard
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('End');
        const lastItem = items.last();
        await expect(lastItem).toBeFocused();

        // Type character that matches earlier item - use element.press() for single key
        await lastItem.press('c');

        // Wait for focus to wrap and find item starting with 'c'
        // Use trim() because some frameworks may include whitespace in textContent
        await expect
          .poll(async () => {
            const text = await page.evaluate(
              () => document.activeElement?.textContent?.trim().toLowerCase() || ''
            );
            return text.startsWith('c');
          })
          .toBe(true);
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations (closed)', async ({ page }) => {
        const results = await new AxeBuilder({ page })
          .include('.apg-menu-button')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });

      test('has no axe-core violations (open)', async ({ page }) => {
        await openMenu(page);

        const results = await new AxeBuilder({ page })
          .include('.apg-menu-button')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Menu Button - Cross-framework Consistency', () => {
  test('all frameworks have menu button with aria-haspopup="menu"', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      const button = getMenuButton(page);
      await expect(button).toHaveAttribute('aria-haspopup', 'menu');
    }
  });

  test('all frameworks open menu on click', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      const button = getMenuButton(page);
      await button.click();

      const menu = getMenu(page);
      await expect(menu).toBeVisible();

      // Close for next iteration
      await page.keyboard.press('Escape');
    }
  });

  test('all frameworks close menu on Escape', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      await openMenu(page);
      await expect(getMenu(page)).toBeVisible();

      await page.keyboard.press('Escape');
      await expect(getMenu(page)).not.toBeVisible();
    }
  });

  test('all frameworks have consistent keyboard navigation', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      // Wait for hydration (especially needed for Svelte)
      if (framework === 'svelte') {
        await waitForHydration(page);
      }

      const button = getMenuButton(page);
      await button.focus();
      await expect(button).toBeFocused();
      await button.press('Enter');

      const menu = getMenu(page);
      await expect(menu).toBeVisible();

      // First item should be focused
      const firstItem = getMenuItems(page).first();
      await expect(firstItem).toBeFocused();

      // Arrow navigation
      await firstItem.press('ArrowDown');
      const secondItem = getMenuItems(page).nth(1);
      await expect(secondItem).toBeFocused();

      await page.keyboard.press('Escape');
    }
  });
});

テストツール

E2Eテスト: e2e/menu-button.spec.ts (opens in new tab)
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

MenuButton.test.tsx
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MenuButton, type MenuItem } from './MenuButton';

afterEach(() => {
  vi.useRealTimers();
});

// Default test items
const defaultItems: MenuItem[] = [
  { id: 'cut', label: 'Cut' },
  { id: 'copy', label: 'Copy' },
  { id: 'paste', label: 'Paste' },
];

// Test items with disabled item
const itemsWithDisabled: MenuItem[] = [
  { id: 'cut', label: 'Cut', disabled: true },
  { id: 'copy', label: 'Copy' },
  { id: 'paste', label: 'Paste' },
];

// Test items with all disabled
const allDisabledItems: MenuItem[] = [
  { id: 'cut', label: 'Cut', disabled: true },
  { id: 'copy', label: 'Copy', disabled: true },
  { id: 'paste', label: 'Paste', disabled: true },
];

// Test items for type-ahead
const typeAheadItems: MenuItem[] = [
  { id: 'cut', label: 'Cut' },
  { id: 'copy', label: 'Copy' },
  { id: 'clear', label: 'Clear' },
  { id: 'edit', label: 'Edit' },
];

describe('MenuButton', () => {
  // 🔴 High Priority: APG Mouse Operations
  describe('APG: Mouse Operations', () => {
    it('opens menu on button click', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menu')).not.toHaveAttribute('hidden');
    });

    it('closes menu on button click when open (toggle)', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('executes and closes menu on menu item click', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(<MenuButton items={defaultItems} label="Actions" onItemSelect={onItemSelect} />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const menuItem = screen.getByRole('menuitem', { name: 'Copy' });
      await user.click(menuItem);

      expect(onItemSelect).toHaveBeenCalledWith('copy');
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('does nothing on disabled item click', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(<MenuButton items={itemsWithDisabled} label="Actions" onItemSelect={onItemSelect} />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });
      await user.click(disabledItem);

      expect(onItemSelect).not.toHaveBeenCalled();
      expect(button).toHaveAttribute('aria-expanded', 'true');
    });

    it('closes menu on click outside', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <MenuButton items={defaultItems} label="Actions" />
          <button>Outside</button>
        </div>
      );

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction (Button)
  describe('APG: Keyboard Interaction (Button)', () => {
    it('opens menu and focuses first enabled item with Enter', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard('{Enter}');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('opens menu and focuses first enabled item with Space', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard(' ');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('opens menu and focuses first enabled item with ArrowDown', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard('{ArrowDown}');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('opens menu and focuses last enabled item with ArrowUp', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard('{ArrowUp}');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction (Menu)
  describe('APG: Keyboard Interaction (Menu)', () => {
    it('moves to next enabled item with ArrowDown', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{ArrowDown}');

      expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
    });

    it('loops from last to first with ArrowDown', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);

      const lastItem = screen.getByRole('menuitem', { name: 'Paste' });
      lastItem.focus();
      await user.keyboard('{ArrowDown}');

      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('moves to previous enabled item with ArrowUp', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);

      const secondItem = screen.getByRole('menuitem', { name: 'Copy' });
      secondItem.focus();
      await user.keyboard('{ArrowUp}');

      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('loops from first to last with ArrowUp', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{ArrowUp}');

      expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
    });

    it('moves to first enabled item with Home (skips disabled)', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={itemsWithDisabled} label="Actions" defaultOpen />);

      const lastItem = screen.getByRole('menuitem', { name: 'Paste' });
      lastItem.focus();
      await user.keyboard('{Home}');

      // Cut is disabled, so focus should go to Copy
      expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
    });

    it('moves to last enabled item with End (skips disabled)', async () => {
      const user = userEvent.setup();
      const itemsWithLastDisabled: MenuItem[] = [
        { id: 'cut', label: 'Cut' },
        { id: 'copy', label: 'Copy' },
        { id: 'paste', label: 'Paste', disabled: true },
      ];
      render(<MenuButton items={itemsWithLastDisabled} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{End}');

      // Paste is disabled, so focus should go to Copy
      expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
    });

    it('skips disabled items with ArrowDown/Up', async () => {
      const user = userEvent.setup();
      const itemsWithMiddleDisabled: MenuItem[] = [
        { id: 'cut', label: 'Cut' },
        { id: 'copy', label: 'Copy', disabled: true },
        { id: 'paste', label: 'Paste' },
      ];
      render(<MenuButton items={itemsWithMiddleDisabled} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{ArrowDown}');

      // Copy is disabled, so focus should skip to Paste
      expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
    });

    it('closes menu and focuses button with Escape', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Escape}');
      expect(button).toHaveAttribute('aria-expanded', 'false');
      expect(button).toHaveFocus();
    });

    it('closes menu and moves focus with Tab', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <MenuButton items={defaultItems} label="Actions" />
          <button>Next</button>
        </div>
      );

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Tab}');
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('executes item and closes menu with Enter', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <MenuButton items={defaultItems} label="Actions" onItemSelect={onItemSelect} defaultOpen />
      );

      const item = screen.getByRole('menuitem', { name: 'Copy' });
      item.focus();
      await user.keyboard('{Enter}');

      expect(onItemSelect).toHaveBeenCalledWith('copy');
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'false'
      );
    });

    it('executes item and closes menu with Space (prevents scroll)', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <MenuButton items={defaultItems} label="Actions" onItemSelect={onItemSelect} defaultOpen />
      );

      const item = screen.getByRole('menuitem', { name: 'Copy' });
      item.focus();
      await user.keyboard(' ');

      expect(onItemSelect).toHaveBeenCalledWith('copy');
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'false'
      );
    });
  });

  // 🔴 High Priority: Type-ahead
  describe('APG: Type-ahead', () => {
    it('focuses matching item with character key', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('e');

      expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
    });

    it('matches with multiple characters (e.g., "cl" → "Clear")', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('cl');

      expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
    });

    it('cycles through matches with repeated same character', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();

      // First 'c' -> Cut (already focused, or next match)
      await user.keyboard('c');
      // Items starting with 'c': Cut, Copy, Clear
      // After first 'c' from Cut, should go to Copy
      expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();

      await user.keyboard('c');
      expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();

      await user.keyboard('c');
      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('skips disabled items in type-ahead', async () => {
      const user = userEvent.setup();
      const itemsWithDisabledMatch: MenuItem[] = [
        { id: 'cut', label: 'Cut' },
        { id: 'copy', label: 'Copy', disabled: true },
        { id: 'clear', label: 'Clear' },
      ];
      render(<MenuButton items={itemsWithDisabledMatch} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('c');

      // Copy is disabled, so should skip to Clear
      expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
    });

    it('does not change focus when no match', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('z');

      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('resets buffer after 500ms', () => {
      vi.useFakeTimers();
      render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      act(() => {
        firstItem.focus();
      });

      // Type 'c' -> moves to Copy
      act(() => {
        fireEvent.keyDown(firstItem, { key: 'c' });
      });
      expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();

      // Wait for buffer reset (500ms)
      act(() => {
        vi.advanceTimersByTime(500);
      });

      // After reset, 'e' should match 'Edit' (not 'ce')
      const copyItem = screen.getByRole('menuitem', { name: 'Copy' });
      act(() => {
        fireEvent.keyDown(copyItem, { key: 'e' });
      });
      expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: APG ARIA Attributes
  describe('APG: ARIA Attributes', () => {
    it('button has aria-haspopup="menu"', () => {
      render(<MenuButton items={defaultItems} label="Actions" />);
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-haspopup',
        'menu'
      );
    });

    it('has aria-expanded="false" when closed', () => {
      render(<MenuButton items={defaultItems} label="Actions" />);
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'false'
      );
    });

    it('has aria-expanded="true" when open', () => {
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'true'
      );
    });

    it('button always references menu with aria-controls', () => {
      render(<MenuButton items={defaultItems} label="Actions" />);
      const button = screen.getByRole('button', { name: 'Actions' });
      const menuId = button.getAttribute('aria-controls');

      expect(menuId).toBeTruthy();
      expect(document.getElementById(menuId!)).toHaveAttribute('role', 'menu');
    });

    it('menu has role="menu"', () => {
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
      expect(screen.getByRole('menu')).toBeInTheDocument();
    });

    it('menu references button with aria-labelledby', () => {
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
      const menu = screen.getByRole('menu');
      const labelledbyId = menu.getAttribute('aria-labelledby');

      expect(labelledbyId).toBeTruthy();
      expect(document.getElementById(labelledbyId!)).toHaveAttribute('aria-haspopup', 'menu');
    });

    it('items have role="menuitem"', () => {
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
      const menuItems = screen.getAllByRole('menuitem');

      expect(menuItems).toHaveLength(3);
    });

    it('disabled item has aria-disabled="true"', () => {
      render(<MenuButton items={itemsWithDisabled} label="Actions" defaultOpen />);
      const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });

      expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('focused item has tabindex="0"', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const focusedItem = screen.getByRole('menuitem', { name: 'Cut' });
      expect(focusedItem).toHaveAttribute('tabindex', '0');
    });

    it('other items have tabindex="-1"', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const otherItems = screen
        .getAllByRole('menuitem')
        .filter((item) => item.textContent !== 'Cut');
      otherItems.forEach((item) => {
        expect(item).toHaveAttribute('tabindex', '-1');
      });
    });

    it('disabled item has tabindex="-1"', () => {
      render(<MenuButton items={itemsWithDisabled} label="Actions" defaultOpen />);
      const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });

      expect(disabledItem).toHaveAttribute('tabindex', '-1');
    });

    it('returns focus to button when menu closes', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={defaultItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const item = screen.getByRole('menuitem', { name: 'Copy' });
      await user.click(item);

      expect(button).toHaveFocus();
    });

    it('menu has inert and hidden when closed', () => {
      render(<MenuButton items={defaultItems} label="Actions" />);
      const menu = screen.getByRole('menu', { hidden: true });

      expect(menu).toHaveAttribute('hidden');
      expect(menu).toHaveAttribute('inert');
    });
  });

  // 🔴 High Priority: Edge Cases
  describe('Edge Cases', () => {
    it('when all items are disabled, menu opens but focus stays on button', async () => {
      const user = userEvent.setup();
      render(<MenuButton items={allDisabledItems} label="Actions" />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(button).toHaveFocus();
    });

    it('does not crash with empty items array', () => {
      expect(() => {
        render(<MenuButton items={[]} label="Actions" />);
      }).not.toThrow();

      expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
    });

    it('IDs do not conflict with multiple instances', () => {
      render(
        <>
          <MenuButton items={defaultItems} label="Actions 1" />
          <MenuButton items={defaultItems} label="Actions 2" />
        </>
      );

      const button1 = screen.getByRole('button', { name: 'Actions 1' });
      const button2 = screen.getByRole('button', { name: 'Actions 2' });

      const menuId1 = button1.getAttribute('aria-controls');
      const menuId2 = button2.getAttribute('aria-controls');

      expect(menuId1).not.toBe(menuId2);
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('no axe violations when closed', async () => {
      const { container } = render(<MenuButton items={defaultItems} label="Actions" />);
      const results = await axe(container);

      expect(results).toHaveNoViolations();
    });

    it('no axe violations when open', async () => {
      const { container } = render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
      const results = await axe(container);

      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props
  describe('Props', () => {
    it('initially displayed when defaultOpen=true', () => {
      render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
      const button = screen.getByRole('button', { name: 'Actions' });

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menu')).not.toHaveAttribute('hidden');
    });

    it('applies className to container', () => {
      const { container } = render(
        <MenuButton items={defaultItems} label="Actions" className="custom-class" />
      );

      expect(container.querySelector('.apg-menu-button')).toHaveClass('custom-class');
    });

    it('calls onItemSelect with correct id', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(<MenuButton items={defaultItems} label="Actions" onItemSelect={onItemSelect} />);

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const item = screen.getByRole('menuitem', { name: 'Paste' });
      await user.click(item);

      expect(onItemSelect).toHaveBeenCalledWith('paste');
      expect(onItemSelect).toHaveBeenCalledTimes(1);
    });
  });
});

リソース