APG Patterns
English GitHub
English GitHub

Menu Button

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

🤖 AI 実装ガイド

デモ

基本的なメニューボタン

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

Last action: None

無効な項目を含むメニュー

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

Last action: None

Note: "Export" is disabled and will be skipped during keyboard navigation

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA menu role (opens in new tab)

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

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

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

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

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

キーボードサポート

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

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

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

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

フォーカス管理

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

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

非表示状態

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

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

ソースコード

MenuButton.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

MenuButton プロパティ

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

MenuItem インターフェース

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

リソース