APG Patterns
English GitHub
English GitHub

Menubar

ドロップダウンメニュー、サブメニュー、チェックボックス、ラジオグループをサポートする、アプリケーションスタイルの水平メニューバー。

🤖 AI 実装ガイド

デモ

フル機能のメニューバー

サブメニュー、チェックボックス、ラジオグループ、区切り線、無効な項目を含みます。

Last action: None

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
menubar 水平コンテナ(<ul> トップレベルのメニューバー(常に表示)
menu 垂直コンテナ(<ul> ドロップダウンメニューまたはサブメニュー
menuitem アイテム(<span> 標準的なアクションアイテム
menuitemcheckbox チェックボックスアイテム トグル可能なオプション
menuitemradio ラジオアイテム グループ内の排他的なオプション
separator 区切り線(<hr> 視覚的な区切り(フォーカス不可)
group グループコンテナ ラジオアイテムをラベル付きでグループ化
none <li>要素 スクリーンリーダーからリストセマンティクスを隠す

WAI-ARIA menubar role (opens in new tab)

WAI-ARIA プロパティ(メニューバーアイテム)

属性 必須 説明
aria-haspopup "menu" はい* アイテムがメニューを開くことを示す("true"ではなく"menu"を使用)
aria-expanded true | false はい* メニューが開いているかどうかを示す

* サブメニューを持つアイテムのみ

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

属性 対象 必須 説明
aria-labelledby menu ID参照 はい** 親のmenuitemを参照する
aria-label menubar/menu 文字列 はい** アクセシブルな名前を提供する
aria-checked checkbox/radio true | false はい チェック状態を示す
aria-disabled menuitem true いいえ アイテムが無効であることを示す
aria-hidden menu/submenu true | false はい 閉じているときメニューをスクリーンリーダーから隠す

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

キーボードサポート

メニューバーナビゲーション

キー アクション
Right Arrow 次のメニューバーアイテムにフォーカスを移動(最後から最初にラップ)
Left Arrow 前のメニューバーアイテムにフォーカスを移動(最初から最後にラップ)
Down Arrow サブメニューを開き、最初のアイテムにフォーカス
Up Arrow サブメニューを開き、最後のアイテムにフォーカス
Enter / Space サブメニューを開き、最初のアイテムにフォーカス
Home 最初のメニューバーアイテムにフォーカスを移動
End 最後のメニューバーアイテムにフォーカスを移動
Tab すべてのメニューを閉じてフォーカスを外に移動

メニュー/サブメニューナビゲーション

キー アクション
Down Arrow 次のアイテムにフォーカスを移動(最後から最初にラップ)
Up Arrow 前のアイテムにフォーカスを移動(最初から最後にラップ)
Right Arrow サブメニューがあれば開く、またはトップレベルメニューでは次のメニューバーアイテムのメニューに移動
Left Arrow サブメニューを閉じて親に戻る、またはトップレベルメニューでは前のメニューバーアイテムのメニューに移動
Enter / Space アイテムを実行してメニューを閉じる;チェックボックス/ラジオは状態を切り替えてメニューを開いたままにする
Escape メニューを閉じてフォーカスを親(メニューバーアイテムまたは親menuitem)に戻す
Home 最初のアイテムにフォーカスを移動
End 最後のアイテムにフォーカスを移動
文字を入力 先行入力: 入力された文字で始まるアイテムにフォーカスを移動

フォーカス管理

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

  • 一度に1つのメニューバーアイテムのみがtabindex="0"を持つ
  • その他のアイテムはtabindex="-1"を持つ
  • 矢印キーでアイテム間のフォーカス移動(ラップあり)
  • 無効なアイテムはフォーカス可能だが実行不可(APG推奨)
  • 区切り線はフォーカス不可
  • メニューが閉じると、フォーカスは呼び出し元に戻る

Menu-Button との違い

機能 Menu-Button Menubar
トップレベル構造 <button>トリガー <ul role="menubar">(常に表示)
水平ナビゲーション なし メニューバーアイテム間で/
ホバー動作 なし メニューが開いているときに自動切り替え
<li>のロール 常に指定されるとは限らない すべてにrole="none"が必須

非表示状態

閉じているとき、メニューはaria-hidden="true"とCSSを使用して以下を実現します:

  • スクリーンリーダーからメニューを隠す(aria-hidden
  • 視覚的にメニューを隠す(visibility: hidden
  • ポインター操作を防ぐ(pointer-events: none
  • 開閉時のスムーズなCSSアニメーションを可能にする

開いているとき、メニューはaria-hidden="false"visibility: visibleになります。

ソースコード

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

// Menu item types
export interface MenuItemBase {
  id: string;
  label: string;
  disabled?: boolean;
}

export interface MenuItemAction extends MenuItemBase {
  type: 'item';
}

export interface MenuItemCheckbox extends MenuItemBase {
  type: 'checkbox';
  checked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
}

export interface MenuItemRadio extends MenuItemBase {
  type: 'radio';
  checked?: boolean;
}

export interface MenuItemSeparator {
  type: 'separator';
  id: string;
}

export interface MenuItemRadioGroup {
  type: 'radiogroup';
  id: string;
  name: string;
  label: string;
  items: MenuItemRadio[];
}

export interface MenuItemSubmenu extends MenuItemBase {
  type: 'submenu';
  items: MenuItem[];
}

export type MenuItem =
  | MenuItemAction
  | MenuItemCheckbox
  | MenuItemRadio
  | MenuItemSeparator
  | MenuItemRadioGroup
  | MenuItemSubmenu;

export interface MenubarItem {
  id: string;
  label: string;
  items: MenuItem[];
}

type MenubarLabelProps =
  | { 'aria-label': string; 'aria-labelledby'?: never }
  | { 'aria-label'?: never; 'aria-labelledby': string };

export type MenubarProps = Omit<
  HTMLAttributes<HTMLUListElement>,
  'role' | 'aria-label' | 'aria-labelledby'
> &
  MenubarLabelProps & {
    items: MenubarItem[];
    onItemSelect?: (itemId: string) => void;
  };

interface MenuState {
  openMenubarIndex: number;
  openSubmenuPath: string[];
  focusedItemPath: string[];
}

export function Menubar({
  items,
  onItemSelect,
  className = '',
  ...restProps
}: MenubarProps): ReactElement {
  const instanceId = useId();

  const [state, setState] = useState<MenuState>({
    openMenubarIndex: -1,
    openSubmenuPath: [],
    focusedItemPath: [],
  });

  const [checkboxStates, setCheckboxStates] = useState<Map<string, boolean>>(() => {
    const map = new Map<string, boolean>();
    const collectCheckboxStates = (menuItems: MenuItem[]) => {
      menuItems.forEach((item) => {
        if (item.type === 'checkbox') {
          map.set(item.id, item.checked ?? false);
        } else if (item.type === 'submenu') {
          collectCheckboxStates(item.items);
        } else if (item.type === 'radiogroup') {
          item.items.forEach((radio) => {
            map.set(radio.id, radio.checked ?? false);
          });
        }
      });
    };
    items.forEach((menubarItem) => collectCheckboxStates(menubarItem.items));
    return map;
  });

  const [radioStates, setRadioStates] = useState<Map<string, string>>(() => {
    const map = new Map<string, string>();
    const collectRadioStates = (menuItems: MenuItem[]) => {
      menuItems.forEach((item) => {
        if (item.type === 'radiogroup') {
          const checked = item.items.find((r) => r.checked);
          if (checked) {
            map.set(item.name, checked.id);
          }
        } else if (item.type === 'submenu') {
          collectRadioStates(item.items);
        }
      });
    };
    items.forEach((menubarItem) => collectRadioStates(menubarItem.items));
    return map;
  });

  const containerRef = useRef<HTMLUListElement>(null);
  const menubarItemRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
  const menuItemRefs = useRef<Map<string, HTMLSpanElement>>(new Map());
  const typeAheadBuffer = useRef<string>('');
  const typeAheadTimeoutId = useRef<number | null>(null);
  const typeAheadTimeout = 500;
  const [menubarFocusIndex, setMenubarFocusIndex] = useState(0);

  const isMenuOpen = state.openMenubarIndex >= 0;

  // Close all menus
  const closeAllMenus = useCallback(() => {
    setState({
      openMenubarIndex: -1,
      openSubmenuPath: [],
      focusedItemPath: [],
    });
    typeAheadBuffer.current = '';
    if (typeAheadTimeoutId.current !== null) {
      clearTimeout(typeAheadTimeoutId.current);
      typeAheadTimeoutId.current = null;
    }
  }, []);

  // Open a menubar item's menu
  const openMenubarMenu = useCallback(
    (index: number, focusPosition: 'first' | 'last' = 'first') => {
      const menubarItem = items[index];
      if (!menubarItem) return;

      // Get all focusable items including radios from radiogroups
      const getAllFocusableItems = (menuItems: MenuItem[]): MenuItem[] => {
        const result: MenuItem[] = [];
        for (const item of menuItems) {
          if (item.type === 'separator') continue;
          if (item.type === 'radiogroup') {
            result.push(...item.items.filter((r) => !r.disabled));
          } else if (!('disabled' in item && item.disabled)) {
            result.push(item);
          }
        }
        return result;
      };

      const focusableItems = getAllFocusableItems(menubarItem.items);

      let focusedId = '';
      if (focusPosition === 'first') {
        focusedId = focusableItems[0]?.id ?? '';
      } else {
        focusedId = focusableItems[focusableItems.length - 1]?.id ?? '';
      }

      setState({
        openMenubarIndex: index,
        openSubmenuPath: [],
        focusedItemPath: focusedId ? [focusedId] : [],
      });
      setMenubarFocusIndex(index);
    },
    [items]
  );

  // Get all focusable items from a menu
  const getFocusableItems = useCallback((menuItems: MenuItem[]): MenuItem[] => {
    const result: MenuItem[] = [];
    menuItems.forEach((item) => {
      if (item.type === 'separator') return;
      if (item.type === 'radiogroup') {
        result.push(...item.items);
      } else {
        result.push(item);
      }
    });
    return result;
  }, []);

  // Focus effect for menu items
  useEffect(() => {
    if (state.focusedItemPath.length === 0) return;

    const focusedId = state.focusedItemPath[state.focusedItemPath.length - 1];
    const element = menuItemRefs.current.get(focusedId);
    element?.focus();
  }, [state.focusedItemPath]);

  // Focus effect for menubar items
  useEffect(() => {
    if (!isMenuOpen) return;

    const element = menubarItemRefs.current.get(state.openMenubarIndex);
    if (state.focusedItemPath.length === 0) {
      element?.focus();
    }
  }, [isMenuOpen, state.openMenubarIndex, state.focusedItemPath.length]);

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

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

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

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

  // Handle type-ahead
  const handleTypeAhead = useCallback(
    (char: string, focusableItems: MenuItem[]) => {
      const enabledItems = focusableItems.filter((item) =>
        'disabled' in item ? !item.disabled : true
      );
      if (enabledItems.length === 0) return;

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

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

      let searchStr: string;
      let startIndex: number;

      const currentId = state.focusedItemPath[state.focusedItemPath.length - 1];
      const currentIndex = enabledItems.findIndex((item) => item.id === currentId);

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

      for (let i = 0; i < enabledItems.length; i++) {
        const index = (startIndex + i) % enabledItems.length;
        const item = enabledItems[index];
        if ('label' in item && item.label.toLowerCase().startsWith(searchStr)) {
          setState((prev) => ({
            ...prev,
            focusedItemPath: [...prev.focusedItemPath.slice(0, -1), item.id],
          }));
          break;
        }
      }

      typeAheadTimeoutId.current = window.setTimeout(() => {
        typeAheadBuffer.current = '';
        typeAheadTimeoutId.current = null;
      }, typeAheadTimeout);
    },
    [state.focusedItemPath]
  );

  // Handle menubar item keyboard navigation
  const handleMenubarKeyDown = useCallback(
    (event: KeyboardEvent<HTMLSpanElement>, index: number) => {
      switch (event.key) {
        case 'ArrowRight': {
          event.preventDefault();
          const nextIndex = (index + 1) % items.length;
          setMenubarFocusIndex(nextIndex);
          if (isMenuOpen) {
            openMenubarMenu(nextIndex, 'first');
          } else {
            menubarItemRefs.current.get(nextIndex)?.focus();
          }
          break;
        }
        case 'ArrowLeft': {
          event.preventDefault();
          const prevIndex = index === 0 ? items.length - 1 : index - 1;
          setMenubarFocusIndex(prevIndex);
          if (isMenuOpen) {
            openMenubarMenu(prevIndex, 'first');
          } else {
            menubarItemRefs.current.get(prevIndex)?.focus();
          }
          break;
        }
        case 'ArrowDown':
        case 'Enter':
        case ' ': {
          event.preventDefault();
          openMenubarMenu(index, 'first');
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          openMenubarMenu(index, 'last');
          break;
        }
        case 'Home': {
          event.preventDefault();
          setMenubarFocusIndex(0);
          menubarItemRefs.current.get(0)?.focus();
          break;
        }
        case 'End': {
          event.preventDefault();
          const lastIndex = items.length - 1;
          setMenubarFocusIndex(lastIndex);
          menubarItemRefs.current.get(lastIndex)?.focus();
          break;
        }
        case 'Escape': {
          event.preventDefault();
          closeAllMenus();
          break;
        }
        case 'Tab': {
          closeAllMenus();
          break;
        }
      }
    },
    [items.length, isMenuOpen, openMenubarMenu, closeAllMenus]
  );

  // Handle menubar item click
  const handleMenubarClick = useCallback(
    (index: number) => {
      if (state.openMenubarIndex === index) {
        closeAllMenus();
      } else {
        openMenubarMenu(index, 'first');
      }
    },
    [state.openMenubarIndex, closeAllMenus, openMenubarMenu]
  );

  // Handle menubar item hover
  const handleMenubarHover = useCallback(
    (index: number) => {
      if (isMenuOpen && state.openMenubarIndex !== index) {
        openMenubarMenu(index, 'first');
      }
    },
    [isMenuOpen, state.openMenubarIndex, openMenubarMenu]
  );

  // Get first focusable item from menu items
  const getFirstFocusableItem = useCallback((menuItems: MenuItem[]): MenuItem | null => {
    for (const item of menuItems) {
      if (item.type === 'separator') continue;
      if (item.type === 'radiogroup') {
        const enabledRadio = item.items.find((r) => !r.disabled);
        if (enabledRadio) return enabledRadio;
        continue;
      }
      if ('disabled' in item && item.disabled) continue;
      return item;
    }
    return null;
  }, []);

  // Handle menu item activation
  const activateMenuItem = useCallback(
    (item: MenuItem, radioGroupName?: string) => {
      if ('disabled' in item && item.disabled) return;

      if (item.type === 'item') {
        onItemSelect?.(item.id);
        closeAllMenus();
        menubarItemRefs.current.get(state.openMenubarIndex)?.focus();
      } else if (item.type === 'checkbox') {
        const newChecked = !checkboxStates.get(item.id);
        setCheckboxStates((prev) => new Map(prev).set(item.id, newChecked));
        item.onCheckedChange?.(newChecked);
        // Menu stays open for checkbox
      } else if (item.type === 'radio' && radioGroupName) {
        setRadioStates((prev) => new Map(prev).set(radioGroupName, item.id));
        // Menu stays open for radio
      } else if (item.type === 'submenu') {
        // Open submenu and focus first item
        const firstItem = getFirstFocusableItem(item.items);
        setState((prev) => ({
          ...prev,
          openSubmenuPath: [...prev.openSubmenuPath, item.id],
          focusedItemPath: firstItem
            ? [...prev.focusedItemPath, firstItem.id]
            : prev.focusedItemPath,
        }));
      }
    },
    [onItemSelect, closeAllMenus, checkboxStates, state.openMenubarIndex, getFirstFocusableItem]
  );

  // Handle menu item keyboard navigation
  const handleMenuKeyDown = useCallback(
    (
      event: KeyboardEvent<HTMLSpanElement>,
      item: MenuItem,
      menuItems: MenuItem[],
      isSubmenu: boolean,
      radioGroupName?: string
    ) => {
      const focusableItems = getFocusableItems(menuItems);
      const enabledItems = focusableItems.filter((i) => ('disabled' in i ? !i.disabled : true));

      const currentIndex = focusableItems.findIndex((i) => i.id === item.id);

      switch (event.key) {
        case 'ArrowDown': {
          event.preventDefault();
          // Disabled items are focusable per APG
          const nextIndex = (currentIndex + 1) % focusableItems.length;
          const nextItem = focusableItems[nextIndex];
          if (nextItem) {
            setState((prev) => ({
              ...prev,
              focusedItemPath: [...prev.focusedItemPath.slice(0, -1), nextItem.id],
            }));
          }
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          // Disabled items are focusable per APG
          const prevIndex = currentIndex === 0 ? focusableItems.length - 1 : currentIndex - 1;
          const prevItem = focusableItems[prevIndex];
          if (prevItem) {
            setState((prev) => ({
              ...prev,
              focusedItemPath: [...prev.focusedItemPath.slice(0, -1), prevItem.id],
            }));
          }
          break;
        }
        case 'ArrowRight': {
          event.preventDefault();
          if (item.type === 'submenu') {
            // Open submenu and focus first item
            const firstItem = getFirstFocusableItem(item.items);
            setState((prev) => ({
              ...prev,
              openSubmenuPath: [...prev.openSubmenuPath, item.id],
              focusedItemPath: firstItem
                ? [...prev.focusedItemPath, firstItem.id]
                : prev.focusedItemPath,
            }));
          } else if (!isSubmenu) {
            // Move to next menubar item
            const nextMenubarIndex = (state.openMenubarIndex + 1) % items.length;
            openMenubarMenu(nextMenubarIndex, 'first');
          }
          break;
        }
        case 'ArrowLeft': {
          event.preventDefault();
          if (isSubmenu) {
            // Close submenu, return to parent submenu trigger
            // The parent ID is the last entry in openSubmenuPath (the submenu trigger that opened this submenu)
            const parentId = state.openSubmenuPath[state.openSubmenuPath.length - 1];
            setState((prev) => ({
              ...prev,
              openSubmenuPath: prev.openSubmenuPath.slice(0, -1),
              focusedItemPath: parentId
                ? [...prev.focusedItemPath.slice(0, -1).filter((id) => id !== parentId), parentId]
                : prev.focusedItemPath.slice(0, -1),
            }));
            // Explicitly focus parent after state update
            if (parentId) {
              setTimeout(() => {
                menuItemRefs.current.get(parentId)?.focus();
              }, 0);
            }
          } else {
            // Move to previous menubar item
            const prevMenubarIndex =
              state.openMenubarIndex === 0 ? items.length - 1 : state.openMenubarIndex - 1;
            openMenubarMenu(prevMenubarIndex, 'first');
          }
          break;
        }
        case 'Home': {
          event.preventDefault();
          // Disabled items are focusable per APG
          const firstItem = focusableItems[0];
          if (firstItem) {
            setState((prev) => ({
              ...prev,
              focusedItemPath: [...prev.focusedItemPath.slice(0, -1), firstItem.id],
            }));
          }
          break;
        }
        case 'End': {
          event.preventDefault();
          // Disabled items are focusable per APG
          const lastItem = focusableItems[focusableItems.length - 1];
          if (lastItem) {
            setState((prev) => ({
              ...prev,
              focusedItemPath: [...prev.focusedItemPath.slice(0, -1), lastItem.id],
            }));
          }
          break;
        }
        case 'Escape': {
          event.preventDefault();
          if (isSubmenu) {
            // Close submenu, return to parent
            setState((prev) => ({
              ...prev,
              openSubmenuPath: prev.openSubmenuPath.slice(0, -1),
              focusedItemPath: prev.focusedItemPath.slice(0, -1),
            }));
          } else {
            // Close menu, return to menubar item
            closeAllMenus();
            menubarItemRefs.current.get(state.openMenubarIndex)?.focus();
          }
          break;
        }
        case 'Tab': {
          closeAllMenus();
          break;
        }
        case 'Enter':
        case ' ': {
          event.preventDefault();
          activateMenuItem(item, radioGroupName);
          break;
        }
        default: {
          const { key, ctrlKey, metaKey, altKey } = event;
          if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
            event.preventDefault();
            handleTypeAhead(key, focusableItems);
          }
        }
      }
    },
    [
      getFocusableItems,
      getFirstFocusableItem,
      state.openMenubarIndex,
      state.focusedItemPath,
      items.length,
      openMenubarMenu,
      closeAllMenus,
      activateMenuItem,
      handleTypeAhead,
    ]
  );

  // Render menu items recursively
  const renderMenuItems = useCallback(
    (
      menuItems: MenuItem[],
      parentId: string,
      isSubmenu: boolean,
      depth: number = 0
    ): ReactElement[] => {
      const elements: ReactElement[] = [];

      menuItems.forEach((item) => {
        if (item.type === 'separator') {
          elements.push(
            <li key={item.id} role="none">
              <hr role="separator" className="apg-menubar-separator" />
            </li>
          );
        } else if (item.type === 'radiogroup') {
          elements.push(
            <li key={item.id} role="none">
              <ul role="group" aria-label={item.label} className="apg-menubar-group">
                {item.items.map((radioItem) => {
                  const isChecked = radioStates.get(item.name) === radioItem.id;
                  const isFocused =
                    state.focusedItemPath[state.focusedItemPath.length - 1] === radioItem.id;

                  return (
                    <li key={radioItem.id} role="none">
                      <span
                        ref={(el) => {
                          if (el) {
                            menuItemRefs.current.set(radioItem.id, el);
                          } else {
                            menuItemRefs.current.delete(radioItem.id);
                          }
                        }}
                        role="menuitemradio"
                        aria-checked={isChecked}
                        aria-disabled={radioItem.disabled || undefined}
                        tabIndex={isFocused ? 0 : -1}
                        className="apg-menubar-menuitem apg-menubar-menuitemradio"
                        onClick={() => activateMenuItem(radioItem, item.name)}
                        onKeyDown={(e) =>
                          handleMenuKeyDown(e, radioItem, menuItems, isSubmenu, item.name)
                        }
                      >
                        {radioItem.label}
                      </span>
                    </li>
                  );
                })}
              </ul>
            </li>
          );
        } else if (item.type === 'checkbox') {
          const isChecked = checkboxStates.get(item.id) ?? false;
          const isFocused = state.focusedItemPath[state.focusedItemPath.length - 1] === item.id;

          elements.push(
            <li key={item.id} role="none">
              <span
                ref={(el) => {
                  if (el) {
                    menuItemRefs.current.set(item.id, el);
                  } else {
                    menuItemRefs.current.delete(item.id);
                  }
                }}
                role="menuitemcheckbox"
                aria-checked={isChecked}
                aria-disabled={item.disabled || undefined}
                tabIndex={isFocused ? 0 : -1}
                className="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
                onClick={() => activateMenuItem(item)}
                onKeyDown={(e) => handleMenuKeyDown(e, item, menuItems, isSubmenu)}
              >
                {item.label}
              </span>
            </li>
          );
        } else if (item.type === 'submenu') {
          const isExpanded = state.openSubmenuPath.includes(item.id);
          const isFocused = state.focusedItemPath[state.focusedItemPath.length - 1] === item.id;
          const submenuId = `${instanceId}-submenu-${item.id}`;

          elements.push(
            <li key={item.id} role="none">
              <span
                id={`${instanceId}-menuitem-${item.id}`}
                ref={(el) => {
                  if (el) {
                    menuItemRefs.current.set(item.id, el);
                  } else {
                    menuItemRefs.current.delete(item.id);
                  }
                }}
                role="menuitem"
                aria-haspopup="menu"
                aria-expanded={isExpanded}
                aria-disabled={item.disabled || undefined}
                tabIndex={isFocused ? 0 : -1}
                className="apg-menubar-menuitem apg-menubar-submenu-trigger"
                onClick={() => activateMenuItem(item)}
                onKeyDown={(e) => handleMenuKeyDown(e, item, menuItems, isSubmenu)}
              >
                {item.label}
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  width="12"
                  height="12"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                  style={{ marginLeft: 'auto', position: 'relative', top: '1px' }}
                >
                  <path d="m9 18 6-6-6-6" />
                </svg>
              </span>
              <ul
                id={submenuId}
                role="menu"
                aria-labelledby={`${instanceId}-menuitem-${item.id}`}
                className="apg-menubar-submenu"
                aria-hidden={!isExpanded}
              >
                {isExpanded && renderMenuItems(item.items, item.id, true, depth + 1)}
              </ul>
            </li>
          );
        } else {
          // Regular menuitem
          const isFocused = state.focusedItemPath[state.focusedItemPath.length - 1] === item.id;

          elements.push(
            <li key={item.id} role="none">
              <span
                ref={(el) => {
                  if (el) {
                    menuItemRefs.current.set(item.id, el);
                  } else {
                    menuItemRefs.current.delete(item.id);
                  }
                }}
                role="menuitem"
                aria-disabled={item.disabled || undefined}
                tabIndex={isFocused ? 0 : -1}
                className="apg-menubar-menuitem"
                onClick={() => activateMenuItem(item)}
                onKeyDown={(e) => handleMenuKeyDown(e, item, menuItems, isSubmenu)}
              >
                {item.label}
              </span>
            </li>
          );
        }
      });

      return elements;
    },
    [
      instanceId,
      state.openSubmenuPath,
      state.focusedItemPath,
      checkboxStates,
      radioStates,
      activateMenuItem,
      handleMenuKeyDown,
    ]
  );

  return (
    <ul
      ref={containerRef}
      role="menubar"
      className={`apg-menubar ${className}`.trim()}
      {...restProps}
    >
      {items.map((menubarItem, index) => {
        const isExpanded = state.openMenubarIndex === index;
        const menuId = `${instanceId}-menu-${menubarItem.id}`;
        const menubarItemId = `${instanceId}-menubar-${menubarItem.id}`;

        return (
          <li key={menubarItem.id} role="none">
            <span
              id={menubarItemId}
              ref={(el) => {
                if (el) {
                  menubarItemRefs.current.set(index, el);
                } else {
                  menubarItemRefs.current.delete(index);
                }
              }}
              role="menuitem"
              aria-haspopup="menu"
              aria-expanded={isExpanded}
              tabIndex={index === menubarFocusIndex ? 0 : -1}
              className="apg-menubar-trigger"
              onClick={() => handleMenubarClick(index)}
              onKeyDown={(e) => handleMenubarKeyDown(e, index)}
              onMouseEnter={() => handleMenubarHover(index)}
            >
              {menubarItem.label}
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="12"
                height="12"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
                style={{ position: 'relative', top: '1px', opacity: 0.7 }}
              >
                <path d="m6 9 6 6 6-6" />
              </svg>
            </span>
            <ul
              id={menuId}
              role="menu"
              aria-labelledby={menubarItemId}
              className="apg-menubar-menu"
              aria-hidden={!isExpanded}
            >
              {isExpanded && renderMenuItems(menubarItem.items, menubarItem.id, false)}
            </ul>
          </li>
        );
      })}
    </ul>
  );
}

export default Menubar;

使い方

使用例
import { Menubar, type MenubarItem } from './Menubar';
import '@patterns/menubar/menubar.css';

const menuItems: MenubarItem[] = [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      { type: 'item', id: 'open', label: 'Open' },
      { type: 'separator', id: 'sep1' },
      { type: 'item', id: 'save', label: 'Save' },
    ],
  },
  {
    id: 'edit',
    label: 'Edit',
    items: [
      { type: 'item', id: 'cut', label: 'Cut' },
      { type: 'item', id: 'copy', label: 'Copy' },
      { type: 'item', id: 'paste', label: 'Paste' },
    ],
  },
];

<Menubar
  items={menuItems}
  aria-label="Application"
  onItemSelect={(id) => console.log('Selected:', id)}
/>

API

Menubar Props

Prop デフォルト 説明
items MenubarItem[] 必須 トップレベルのメニュー項目の配列
aria-label string - アクセシブルな名前(aria-labelledbyがない場合は必須)
aria-labelledby string - ラベリング要素のID
onItemSelect (id: string) => void - 項目がアクティブ化されたときのコールバック
className string '' 追加のCSSクラス

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。Menubarコンポーネントは2層テスト戦略を使用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングと操作を検証します。これらのテストは分離された環境での正しいコンポーネント動作を確認します。

  • HTML構造と要素階層
  • 初期属性値(role、aria-haspopup、aria-expanded)
  • クリックイベント処理
  • CSSクラスの適用

E2Eテスト(Playwright)

4つのフレームワーク全体で実際のブラウザ環境でのコンポーネント動作を検証します。これらのテストは完全なブラウザコンテキストを必要とする操作をカバーします。

  • キーボードナビゲーション(矢印キー、Enter、Space、Escape、Tab)
  • サブメニューの開閉
  • メニューバーの水平ナビゲーション
  • チェックボックスとラジオアイテムの切り替え
  • ホバーによるメニュー切り替え
  • 先行入力検索
  • フォーカス管理とローヴィングタブインデックス
  • クロスフレームワークの一貫性

テストカテゴリ

高優先度: APG ARIA属性

テスト 説明
role="menubar" コンテナがmenubarロールを持つ
role="menu" ドロップダウンがmenuロールを持つ
role="menuitem" アイテムがmenuitemロールを持つ
role="menuitemcheckbox" チェックボックスアイテムが正しいロールを持つ
role="menuitemradio" ラジオアイテムが正しいロールを持つ
role="separator" 区切り線がseparatorロールを持つ
role="group" ラジオグループがaria-labelを持つgroupロールを持つ
role="none" すべてのli要素がrole="none"を持つ
aria-haspopup サブメニューを持つアイテムがaria-haspopup="menu"を持つ
aria-expanded サブメニューを持つアイテムが開閉状態を反映する
aria-labelledby サブメニューが親のmenuitemを参照する
aria-checked チェックボックス/ラジオアイテムが正しいチェック状態を持つ
aria-hidden メニューが閉じているときaria-hidden="true"、開いているとき"false"

高優先度: APGキーボード操作 - メニューバー

テスト 説明
ArrowRight 次のメニューバーアイテムにフォーカスを移動(ラップ)
ArrowLeft 前のメニューバーアイテムにフォーカスを移動(ラップ)
ArrowDown サブメニューを開き、最初のアイテムにフォーカス
ArrowUp サブメニューを開き、最後のアイテムにフォーカス
Enter/Space サブメニューを開く
Home 最初のメニューバーアイテムにフォーカスを移動
End 最後のメニューバーアイテムにフォーカスを移動
Tab すべてのメニューを閉じ、フォーカスを外に移動

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

テスト 説明
ArrowDown 次のアイテムにフォーカスを移動(ラップ)
ArrowUp 前のアイテムにフォーカスを移動(ラップ)
ArrowRight サブメニューがあれば開く、または次のメニューバーメニューに移動
ArrowLeft サブメニューを閉じる、または前のメニューバーメニューに移動
Enter/Space アイテムを実行してメニューを閉じる
Escape メニューを閉じ、フォーカスを親に戻す
Home/End 最初/最後のアイテムにフォーカスを移動

高優先度: チェックボックスとラジオアイテム

テスト 説明
チェックボックス切り替え Space/Enterでチェックボックスを切り替え
チェックボックスはメニューを開いたまま 切り替えでメニューが閉じない
aria-checked更新 切り替えでaria-checkedが更新される
ラジオ選択 Space/Enterでラジオを選択
ラジオはメニューを開いたまま 選択でメニューが閉じない
排他的選択 グループ内で1つのラジオのみがチェック可能

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

テスト 説明
tabIndex=0 最初のメニューバーアイテムがtabIndex=0を持つ
tabIndex=-1 他のアイテムがtabIndex=-1を持つ
区切り線 区切り線はフォーカス不可
無効なアイテム 無効なアイテムはフォーカス可能だが実行不可

高優先度: 先行入力検索

テスト 説明
文字マッチ 入力された文字で始まるアイテムにフォーカス
ラップアラウンド 検索が末尾から先頭にラップ
区切り線スキップ 検索時に区切り線をスキップ
無効スキップ 検索時に無効なアイテムをスキップ
バッファリセット 500ms後にバッファがリセット

高優先度: ポインタ操作

テスト 説明
クリックで開く メニューバーアイテムをクリックでメニューを開く
クリックで切り替え 再度クリックでメニューを閉じる
ホバー切り替え メニューが開いているとき他のアイテムにホバーでメニュー切り替え
アイテムクリック menuitemをクリックで実行してメニューを閉じる
外側クリック 外側をクリックでメニューを閉じる

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

テスト 説明
axe 閉じた状態 メニューバーが閉じているとき違反なし
axe メニュー開いた状態 メニューが開いているとき違反なし
axe サブメニュー開いた状態 サブメニューが開いているとき違反なし

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

リソース