APG Patterns
English
English

Menu Button

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

デモ

基本的なメニューボタン

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

Last action: None

無効な項目を含む場合

無効な項目はキーボード操作時にスキップされます。

Last action: None

注: "Export" は無効化されており、キーボード操作時にスキップされます

デモのみ表示 →

アクセシビリティ

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.astro
---
/**
 * APG Menu Button Pattern - Astro Implementation
 *
 * A button that opens a menu of actions or functions.
 * Uses Web Components for enhanced control and proper focus management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
 */

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

export interface Props {
  /** Array of menu items */
  items: MenuItem[];
  /** Button label */
  label: string;
  /** Whether menu is initially open */
  defaultOpen?: boolean;
  /** Additional CSS class */
  class?: string;
}

const { items = [], label, defaultOpen = false, class: className = '' } = Astro.props;

// Generate unique ID for this instance
const instanceId = `menu-button-${Math.random().toString(36).slice(2, 11)}`;
const buttonId = `${instanceId}-button`;
const menuId = `${instanceId}-menu`;

// Calculate available items and initial focus
const availableItems = items.filter((item) => !item.disabled);
const initialFocusIndex = defaultOpen && availableItems.length > 0 ? 0 : -1;
---

<apg-menu-button
  data-default-open={defaultOpen ? 'true' : undefined}
  data-initial-focus-index={initialFocusIndex}
>
  <div class={`apg-menu-button ${className}`.trim()}>
    <button
      id={buttonId}
      type="button"
      class="apg-menu-button-trigger"
      aria-haspopup="menu"
      aria-expanded={defaultOpen}
      aria-controls={menuId}
      data-menu-trigger
    >
      {label}
    </button>
    <ul
      id={menuId}
      role="menu"
      aria-labelledby={buttonId}
      class="apg-menu-button-menu"
      hidden={!defaultOpen || undefined}
      inert={!defaultOpen || undefined}
      data-menu-list
    >
      {
        items.map((item) => {
          const availableIndex = availableItems.findIndex((i) => i.id === item.id);
          const isFocusTarget = availableIndex === initialFocusIndex;
          const tabIndex = item.disabled ? -1 : isFocusTarget ? 0 : -1;

          return (
            <li
              role="menuitem"
              data-item-id={item.id}
              tabindex={tabIndex}
              aria-disabled={item.disabled || undefined}
              class="apg-menu-button-item"
            >
              {item.label}
            </li>
          );
        })
      }
    </ul>
  </div>
</apg-menu-button>

<script>
  class ApgMenuButton extends HTMLElement {
    private container: HTMLDivElement | null = null;
    private button: HTMLButtonElement | null = null;
    private menu: HTMLUListElement | null = null;
    private rafId: number | null = null;
    private isOpen = false;
    private focusedIndex = -1;
    private typeAheadBuffer = '';
    private typeAheadTimeoutId: number | null = null;
    private typeAheadTimeout = 500;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.container = this.querySelector('.apg-menu-button');
      this.button = this.querySelector('[data-menu-trigger]');
      this.menu = this.querySelector('[data-menu-list]');

      if (!this.button || !this.menu) {
        console.warn('apg-menu-button: required elements not found');
        return;
      }

      // Initialize state from data attributes
      this.isOpen = this.dataset.defaultOpen === 'true';
      this.focusedIndex = parseInt(this.dataset.initialFocusIndex || '-1', 10);

      // Attach event listeners
      this.button.addEventListener('click', this.handleButtonClick);
      this.button.addEventListener('keydown', this.handleButtonKeyDown);
      this.menu.addEventListener('keydown', this.handleMenuKeyDown);
      this.menu.addEventListener('click', this.handleMenuClick);
      this.menu.addEventListener('focusin', this.handleMenuFocusIn);

      // Click outside listener (only when open)
      if (this.isOpen) {
        document.addEventListener('pointerdown', this.handleClickOutside);
        // Initialize roving tabindex and focus first item for APG compliance
        this.updateTabIndices();
        const availableItems = this.getAvailableItems();
        if (this.focusedIndex >= 0 && availableItems[this.focusedIndex]) {
          availableItems[this.focusedIndex].focus();
        }
      }
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
        this.typeAheadTimeoutId = null;
      }
      document.removeEventListener('pointerdown', this.handleClickOutside);
      this.button?.removeEventListener('click', this.handleButtonClick);
      this.button?.removeEventListener('keydown', this.handleButtonKeyDown);
      this.menu?.removeEventListener('keydown', this.handleMenuKeyDown);
      this.menu?.removeEventListener('click', this.handleMenuClick);
      this.menu?.removeEventListener('focusin', this.handleMenuFocusIn);
    }

    private getItems(): HTMLLIElement[] {
      if (!this.menu) return [];
      return Array.from(this.menu.querySelectorAll<HTMLLIElement>('[role="menuitem"]'));
    }

    private getAvailableItems(): HTMLLIElement[] {
      return this.getItems().filter((item) => item.getAttribute('aria-disabled') !== 'true');
    }

    private openMenu(focusPosition: 'first' | 'last') {
      if (!this.button || !this.menu) return;

      const availableItems = this.getAvailableItems();

      this.isOpen = true;
      this.button.setAttribute('aria-expanded', 'true');
      this.menu.removeAttribute('hidden');
      this.menu.removeAttribute('inert');

      document.addEventListener('pointerdown', this.handleClickOutside);

      if (availableItems.length === 0) {
        this.focusedIndex = -1;
        return;
      }

      const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
      this.focusedIndex = targetIndex;
      this.updateTabIndices();
      availableItems[targetIndex]?.focus();
    }

    private closeMenu() {
      if (!this.button || !this.menu) return;

      this.isOpen = false;
      this.focusedIndex = -1;
      this.button.setAttribute('aria-expanded', 'false');
      this.menu.setAttribute('hidden', '');
      this.menu.setAttribute('inert', '');

      // Clear type-ahead state
      this.typeAheadBuffer = '';
      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
        this.typeAheadTimeoutId = null;
      }

      document.removeEventListener('pointerdown', this.handleClickOutside);

      // Reset tabindex
      this.updateTabIndices();
    }

    private toggleMenu() {
      if (this.isOpen) {
        this.closeMenu();
      } else {
        this.openMenu('first');
      }
    }

    private updateTabIndices() {
      const items = this.getItems();
      const availableItems = this.getAvailableItems();

      items.forEach((item) => {
        if (item.getAttribute('aria-disabled') === 'true') {
          item.tabIndex = -1;
          return;
        }
        const availableIndex = availableItems.indexOf(item);
        item.tabIndex = availableIndex === this.focusedIndex ? 0 : -1;
      });
    }

    private focusItem(index: number) {
      const availableItems = this.getAvailableItems();
      if (index >= 0 && index < availableItems.length) {
        this.focusedIndex = index;
        this.updateTabIndices();
        availableItems[index]?.focus();
      }
    }

    private handleTypeAhead(char: string) {
      const availableItems = this.getAvailableItems();
      if (availableItems.length === 0) return;

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

      this.typeAheadBuffer += char.toLowerCase();

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

      let startIndex: number;
      let searchStr: string;

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

      for (let i = 0; i < availableItems.length; i++) {
        const index = (startIndex + i) % availableItems.length;
        const item = availableItems[index];
        const label = item.textContent?.trim().toLowerCase() || '';
        if (label.startsWith(searchStr)) {
          this.focusItem(index);
          break;
        }
      }

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

    private handleButtonClick = () => {
      this.toggleMenu();
    };

    private handleButtonKeyDown = (event: KeyboardEvent) => {
      switch (event.key) {
        case 'Enter':
        case ' ':
          event.preventDefault();
          this.openMenu('first');
          break;
        case 'ArrowDown':
          event.preventDefault();
          this.openMenu('first');
          break;
        case 'ArrowUp':
          event.preventDefault();
          this.openMenu('last');
          break;
      }
    };

    private handleMenuClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
      if (!item || item.getAttribute('aria-disabled') === 'true') return;

      const itemId = item.dataset.itemId;
      if (itemId) {
        this.dispatchEvent(
          new CustomEvent('itemselect', {
            detail: { itemId },
            bubbles: true,
          })
        );
      }
      this.closeMenu();
      this.button?.focus();
    };

    private handleMenuFocusIn = (event: FocusEvent) => {
      const target = event.target as HTMLElement;
      const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
      if (!item || item.getAttribute('aria-disabled') === 'true') return;

      const availableItems = this.getAvailableItems();
      const index = availableItems.indexOf(item);
      if (index >= 0 && index !== this.focusedIndex) {
        this.focusedIndex = index;
        this.updateTabIndices();
      }
    };

    private handleMenuKeyDown = (event: KeyboardEvent) => {
      const availableItems = this.getAvailableItems();

      // Handle Escape even with no available items
      if (event.key === 'Escape') {
        event.preventDefault();
        this.closeMenu();
        this.button?.focus();
        return;
      }

      if (availableItems.length === 0) return;

      const target = event.target as HTMLElement;
      const currentItem = target.closest('[role="menuitem"]') as HTMLLIElement | null;
      const currentIndex = currentItem ? availableItems.indexOf(currentItem) : -1;

      // If focus is on disabled item, only handle Escape (already handled above)
      if (currentIndex < 0) return;

      switch (event.key) {
        case 'ArrowDown': {
          event.preventDefault();
          const nextIndex = (currentIndex + 1) % availableItems.length;
          this.focusItem(nextIndex);
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          const prevIndex = currentIndex === 0 ? availableItems.length - 1 : currentIndex - 1;
          this.focusItem(prevIndex);
          break;
        }
        case 'Home': {
          event.preventDefault();
          this.focusItem(0);
          break;
        }
        case 'End': {
          event.preventDefault();
          this.focusItem(availableItems.length - 1);
          break;
        }
        case 'Tab': {
          this.closeMenu();
          break;
        }
        case 'Enter':
        case ' ': {
          event.preventDefault();
          const item = availableItems[currentIndex];
          const itemId = item?.dataset.itemId;
          if (itemId) {
            this.dispatchEvent(
              new CustomEvent('itemselect', {
                detail: { itemId },
                bubbles: true,
              })
            );
          }
          this.closeMenu();
          this.button?.focus();
          break;
        }
        default: {
          // Type-ahead: single printable character
          if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
            event.preventDefault();
            this.handleTypeAhead(event.key);
          }
        }
      }
    };

    private handleClickOutside = (event: PointerEvent) => {
      if (this.container && !this.container.contains(event.target as Node)) {
        this.closeMenu();
      }
    };
  }

  if (!customElements.get('apg-menu-button')) {
    customElements.define('apg-menu-button', ApgMenuButton);
  }
</script>

使い方

Example
---
import MenuButton from './MenuButton.astro';

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" />

<script>
  // Handle selection via custom event
  document.querySelectorAll('apg-menu-button').forEach((menuButton) => {
    menuButton.addEventListener('itemselect', (e) => {
      const event = e as CustomEvent<{ itemId: string }>;
      console.log('Selected:', event.detail.itemId);
    });
  });
</script>

API

プロパティ型デフォルト説明
itemsMenuItem[]requiredメニュー項目の配列
labelstringrequiredボタンのラベルテキスト
defaultOpenbooleanfalseメニューが初期状態で開いているかどうか
classstring''コンテナの追加 CSS クラス

Custom Events

イベントDetail説明
itemselect{ itemId: string }メニュー項目が選択されたときに発行される

テスト

テストでは、キーボード操作、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 characters500ms以内に入力された文字がプレフィックス検索文字列を形成する
Wrap around検索が終わりから始まりにラップする
Buffer reset500msの非アクティブ後にバッファがリセットされる

高優先度: 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 violationsWCAG 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;
};

const getAllDisabledMenuButton = (page: import('@playwright/test').Page) => {
  return page.getByRole('button', { name: 'Tools' });
};

const getEmptyMenuButton = (page: import('@playwright/test').Page) => {
  return page.getByRole('button', { name: 'Bookmarks' });
};

// Wait for hydration to complete: Astro removes the `ssr` attribute from
// each <astro-island> once its component has hydrated.
const waitForHydration = async (page: import('@playwright/test').Page) => {
  await expect(page.locator('astro-island[ssr]')).toHaveCount(0);
};

// ============================================
// 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();
      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 is named by its own button when several menu buttons are on the page', async ({
        page,
      }) => {
        await page.getByRole('button', { name: /file/i }).click();

        await expect(page.getByRole('menu', { name: 'File' })).toBeVisible();
      });

      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();
        await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Menus without enabled items
    // ------------------------------------------
    test.describe('Menus without enabled items', () => {
      test('Enter opens a menu whose items are all disabled and keeps focus on the button', async ({
        page,
      }) => {
        const button = getAllDisabledMenuButton(page);
        await button.focus();
        await button.press('Enter');

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

      test('items of a menu whose items are all disabled keep tabindex="-1" when opened', async ({
        page,
      }) => {
        const button = getAllDisabledMenuButton(page);
        await button.click();

        await expect(page.getByRole('menuitem', { name: 'Undo' })).toHaveAttribute(
          'tabindex',
          '-1'
        );
        await expect(page.getByRole('menuitem', { name: 'Redo' })).toHaveAttribute(
          'tabindex',
          '-1'
        );
      });

      test('Enter opens an empty menu and keeps focus on the button', async ({ page }) => {
        const button = getEmptyMenuButton(page);
        await button.focus();
        await button.press('Enter');

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

    // ------------------------------------------
    // 🔴 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 on a disabled item keeps the menu open', async ({ page }) => {
        const fileButton = page.getByRole('button', { name: /file/i });
        await fileButton.click();
        await getMenu(page).waitFor({ state: 'visible' });

        await page.getByRole('menuitem', { name: 'Export' }).click({ force: true });

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

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

      test('repeated same character cycles through matching items', async ({ page }) => {
        await openMenu(page);
        const cutItem = page.getByRole('menuitem', { name: 'Cut' });
        const copyItem = page.getByRole('menuitem', { name: 'Copy' });
        await expect(cutItem).toBeFocused();

        await cutItem.press('c');
        await expect(copyItem).toBeFocused();

        await copyItem.press('c');
        await expect(cutItem).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🟢 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([]);
      });
    });
  });
}

// ============================================
// Astro-specific: itemselect CustomEvent
// ============================================

test.describe('Menu Button (astro) - itemselect event', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('patterns/menu-button/astro/demo/');
    await getMenuButton(page).waitFor();
    await page.evaluate(() => {
      const selected: string[] = [];
      (window as unknown as { __selectedItems: string[] }).__selectedItems = selected;
      document.addEventListener('itemselect', (e) => {
        selected.push((e as CustomEvent<{ itemId: string }>).detail.itemId);
      });
    });
  });

  test('dispatches itemselect with the item id on item click', async ({ page }) => {
    await openMenu(page);
    await page.getByRole('menuitem', { name: 'Copy' }).click();

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).toEqual(['copy']);
  });

  test('dispatches itemselect with the item id on Enter', async ({ page }) => {
    await openMenu(page);
    await page.getByRole('menuitem', { name: 'Cut' }).press('Enter');

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).toEqual(['cut']);
  });

  test('dispatches itemselect with the item id on Space', async ({ page }) => {
    await openMenu(page);
    await page.getByRole('menuitem', { name: 'Cut' }).press('Space');

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).toEqual(['cut']);
  });

  test('does not dispatch itemselect on disabled item click', async ({ page }) => {
    await page.getByRole('button', { name: /file/i }).click();
    await getMenu(page).waitFor({ state: 'visible' });
    await page.getByRole('menuitem', { name: 'Export' }).click({ force: true });

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).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();
      await waitForHydration(page);

      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();
      await waitForHydration(page);

      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 waitForHydration(page);

      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();
      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)

詳細はテスト戦略ガイドを参照してください。

MenuButton.test.astro.ts
/**
 * MenuButton Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of MenuButton.astro.
 * Web Component behavior (opening/closing, keyboard navigation, roving
 * tabindex updates, type-ahead, click outside and the itemselect event)
 * is covered by E2E tests in e2e/menu-button.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import MenuButton from './MenuButton.astro';

describe('MenuButton (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderMenuButton(props: Record<string, unknown>): Promise<Document> {
    const html = await container.renderToString(MenuButton, { props });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-menu-button custom element wrapper', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('apg-menu-button')).not.toBeNull();
    });

    it('renders the trigger as a button with type="button" and the label as its text', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const button = doc.querySelector('button');
      expect(button?.getAttribute('type')).toBe('button');
      expect(button?.textContent?.trim()).toBe('Actions');
    });

    it('renders a list with role="menu"', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('ul[role="menu"]')).not.toBeNull();
    });

    it('renders each item as role="menuitem" with its label', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
          { id: 'paste', label: 'Paste' },
        ],
        label: 'Actions',
      });
      const labels = Array.from(doc.querySelectorAll('[role="menuitem"]')).map((item) =>
        item.textContent?.trim()
      );
      expect(labels).toEqual(['Cut', 'Copy', 'Paste']);
    });

    it('renders an empty menu when items is empty', async () => {
      const doc = await renderMenuButton({ items: [], label: 'Actions' });
      expect(doc.querySelector('[role="menu"]')).not.toBeNull();
      expect(doc.querySelectorAll('[role="menuitem"]')).toHaveLength(0);
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has aria-haspopup="menu" on the button', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('button')?.getAttribute('aria-haspopup')).toBe('menu');
    });

    it('has aria-expanded="false" on the button by default', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('button')?.getAttribute('aria-expanded')).toBe('false');
    });

    it('has aria-expanded="true" on the button when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        defaultOpen: true,
      });
      expect(doc.querySelector('button')?.getAttribute('aria-expanded')).toBe('true');
    });

    it('has aria-controls on the button referencing the menu id', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const menuId = doc.querySelector('button')?.getAttribute('aria-controls') ?? '';
      expect(doc.getElementById(menuId)?.getAttribute('role')).toBe('menu');
    });

    it('has aria-labelledby on the menu referencing the button', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const buttonId = doc.querySelector('[role="menu"]')?.getAttribute('aria-labelledby') ?? '';
      expect(doc.getElementById(buttonId)?.textContent?.trim()).toBe('Actions');
    });

    it('has aria-disabled="true" only on disabled items', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy', disabled: true },
          { id: 'paste', label: 'Paste' },
        ],
        label: 'Actions',
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].hasAttribute('aria-disabled')).toBe(false);
      expect(items[1].getAttribute('aria-disabled')).toBe('true');
      expect(items[2].hasAttribute('aria-disabled')).toBe(false);
    });
  });

  // 🔴 High Priority: Menu Visibility
  describe('Menu Visibility', () => {
    it('renders the menu hidden and inert by default', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const menu = doc.querySelector('[role="menu"]');
      expect(menu?.hasAttribute('hidden')).toBe(true);
      expect(menu?.hasAttribute('inert')).toBe(true);
    });

    it('renders the menu visible and not inert when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        defaultOpen: true,
      });
      const menu = doc.querySelector('[role="menu"]');
      expect(menu?.hasAttribute('hidden')).toBe(false);
      expect(menu?.hasAttribute('inert')).toBe(false);
    });
  });

  // 🔴 High Priority: Focus Management (initial roving tabindex)
  describe('Focus Management', () => {
    it('has tabindex="-1" on all items when the menu is closed', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('-1');
      expect(items[1].getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="0" only on the first item when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
        defaultOpen: true,
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('0');
      expect(items[1].getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="0" on the first enabled item when the first item is disabled and defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut', disabled: true },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
        defaultOpen: true,
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('-1');
      expect(items[1].getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" on all items when all items are disabled and defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut', disabled: true },
          { id: 'copy', label: 'Copy', disabled: true },
        ],
        label: 'Actions',
        defaultOpen: true,
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('-1');
      expect(items[1].getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟡 Medium Priority: Web Component Hydration Data
  describe('Web Component Data Attributes', () => {
    it('sets data-default-open="true" when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        defaultOpen: true,
      });
      expect(doc.querySelector('apg-menu-button')?.getAttribute('data-default-open')).toBe('true');
    });

    it('does not set data-default-open when defaultOpen is false', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('apg-menu-button')?.hasAttribute('data-default-open')).toBe(false);
    });

    it('sets data-item-id on each item', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
      });
      const ids = Array.from(doc.querySelectorAll('[role="menuitem"]')).map((item) =>
        item.getAttribute('data-item-id')
      );
      expect(ids).toEqual(['cut', 'copy']);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('applies a custom class to the container', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        class: 'custom-class',
      });
      expect(doc.querySelector('.apg-menu-button')?.classList.contains('custom-class')).toBe(true);
    });

    it('generates unique ids for each instance', async () => {
      const first = await renderMenuButton({ items: [{ id: 'cut', label: 'Cut' }], label: 'A' });
      const second = await renderMenuButton({ items: [{ id: 'cut', label: 'Cut' }], label: 'B' });
      expect(first.querySelector('[role="menu"]')?.id).not.toBe(
        second.querySelector('[role="menu"]')?.id
      );
    });
  });
});

リソース