APG Patterns
English GitHub
English GitHub

Menu Button

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

🤖 AI 実装ガイド

デモ

基本的な Menu Button

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

最後のアクション: None

無効な項目を含む

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

最後のアクション: None

注:「Export」は無効化されており、キーボードナビゲーション中にスキップされます

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
button トリガー(<button> メニューを開くトリガー(<button>要素による暗黙的なロール)
menu コンテナ(<ul> ユーザーに選択肢のリストを提供するウィジェット
menuitem 各アイテム(<li> メニュー内のオプション

WAI-ARIA menu role (opens in new tab)

WAI-ARIA プロパティ(ボタン)

属性 必須 説明
aria-haspopup "menu" はい ボタンがメニューを開くことを示す
aria-expanded true | false はい メニューが開いているかどうかを示す
aria-controls ID参照 いいえ メニュー要素を参照する

WAI-ARIA プロパティ(メニュー)

属性 対象 必須 説明
aria-labelledby menu ID参照 はい* メニューを開くボタンを参照する
aria-label menu 文字列 はい* メニューのアクセシブルな名前を提供する
aria-disabled menuitem true いいえ メニューアイテムが無効であることを示す

* aria-labelledbyまたはaria-labelのいずれかがアクセシブルな名前のために必須です

キーボードサポート

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

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

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

キー アクション
Down Arrow 次のアイテムにフォーカスを移動する(最後の項目から最初にラップする)
Up Arrow 前のアイテムにフォーカスを移動する(最初の項目から最後にラップする)
Home 最初のアイテムにフォーカスを移動する
End 最後のアイテムにフォーカスを移動する
Escape メニューを閉じ、フォーカスをボタンに戻す
Tab メニューを閉じ、次のフォーカス可能な要素にフォーカスを移動する
Enter / Space フォーカスされたアイテムを実行し、メニューを閉じる
文字を入力 先行入力: 入力された文字で始まるアイテムにフォーカスを移動する

フォーカス管理

このコンポーネントは、フォーカス管理にRoving Tabindexパターンを使用します:

  • 一度に1つのメニューアイテムのみがtabindex="0"を持つ
  • その他のメニューアイテムはtabindex="-1"を持つ
  • 矢印キーでアイテム間のフォーカス移動(ラップあり)
  • 無効なアイテムはナビゲーション中にスキップされる
  • メニューが閉じると、フォーカスはボタンに戻る

非表示状態

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

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

ソースコード

MenuButton.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, index) => {
          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

MenuButton Props

プロパティ デフォルト 説明
items MenuItem[] required メニュー項目の配列
label string required ボタンのラベルテキスト
defaultOpen boolean false メニューが初期状態で開いているかどうか
class string '' コンテナの追加 CSS クラス

カスタムイベント

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

MenuItem Interface

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

テスト

テストでは、キーボード操作、ARIA属性、アクセシビリティ要件全般にわたってAPG準拠を検証します。

テストカテゴリ

高優先度: APG マウス操作

テスト 説明
Button click ボタンをクリックするとメニューが開く
Toggle ボタンを再度クリックするとメニューが閉じる
Item click メニューアイテムをクリックすると実行され、メニューが閉じる
Disabled item click 無効なアイテムをクリックしても何も起こらない
Click outside メニューの外側をクリックするとメニューが閉じる

高優先度: APG キーボード操作(ボタン)

テスト 説明
Enter メニューを開き、最初の有効なアイテムにフォーカスを移動する
Space メニューを開き、最初の有効なアイテムにフォーカスを移動する
ArrowDown メニューを開き、最初の有効なアイテムにフォーカスを移動する
ArrowUp メニューを開き、最後の有効なアイテムにフォーカスを移動する

高優先度: APG キーボード操作(メニュー)

テスト 説明
ArrowDown 次の有効なアイテムにフォーカスを移動する(ラップする)
ArrowUp 前の有効なアイテムにフォーカスを移動する(ラップする)
Home 最初の有効なアイテムにフォーカスを移動する
End 最後の有効なアイテムにフォーカスを移動する
Escape メニューを閉じ、フォーカスをボタンに戻す
Tab メニューを閉じ、フォーカスを外に移動する
Enter/Space アイテムを実行し、メニューを閉じる
Disabled skip ナビゲーション中に無効なアイテムをスキップする

高優先度: 先行入力検索

テスト 説明
Single character 入力された文字で始まる最初のアイテムにフォーカスを移動する
Multiple characters 500ms以内に入力された文字がプレフィックス検索文字列を形成する
Wrap around 検索が終わりから始まりにラップする
Buffer reset 500msの非アクティブ後にバッファがリセットされる

高優先度: APG ARIA 属性

テスト 説明
aria-haspopup ボタンがaria-haspopup="menu"を持つ
aria-expanded ボタンが開いている状態を反映する(true/false)
aria-controls ボタンがメニューのIDを参照する
role="menu" メニューコンテナがmenuロールを持つ
role="menuitem" 各アイテムがmenuitemロールを持つ
aria-labelledby メニューがアクセシブルな名前のためにボタンを参照する
aria-disabled 無効なアイテムがaria-disabled="true"を持つ

高優先度: フォーカス管理(Roving Tabindex)

テスト 説明
tabIndex=0 フォーカスされたアイテムがtabIndex=0を持つ
tabIndex=-1 フォーカスされていないアイテムがtabIndex=-1を持つ
Initial focus メニューが開くと最初の有効なアイテムがフォーカスを受け取る
Focus return メニューが閉じるとフォーカスがボタンに戻る

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA違反がないこと(jest-axe経由)

テストツール

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

リソース