APG Patterns
English
English

メニューバー

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

デモ

Last action: None

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA プロパティ

aria-haspopup

アイテムがメニューを開くことを示す(“true”ではなく”menu”を使用)

menu
必須
はい*

aria-expanded

メニューが開いているかどうかを示す

true | false
必須
はい*

aria-labelledby

親のmenuitemを参照する

ID参照
必須
はい**

aria-label

アクセシブルな名前を提供する

文字列
必須
はい**

aria-checked

チェック状態を示す

true | false
必須
はい

aria-disabled

アイテムが無効であることを示す

true
必須
いいえ

aria-hidden

閉じているときメニューをスクリーンリーダーから隠す

true | false
必須
はい

キーボードサポート

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

キーアクション
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最後のアイテムにフォーカスを移動
Character先行入力: 入力された文字で始まるアイテムにフォーカスを移動
  • 閉じているとき、メニューはaria-hidden=“true”とCSSを使用して、スクリーンリーダーから隠す(aria-hidden)、視覚的に隠す(visibility: hidden)、ポインター操作を防ぐ(pointer-events: none)、開閉時のスムーズなCSSアニメーションを可能にします。
  • 開いているとき、メニューはaria-hidden=“false”とvisibility: visibleになります。

フォーカス管理

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

参考資料

ソースコード

Menubar.tsx
import type { HTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, 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 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.openSubmenuPath,
      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 className="apg-menubar-separator" />
            </li>
          );
        } else if (item.type === 'radiogroup') {
          const { name, label, id } = item;
          elements.push(
            <li key={id} role="none">
              <ul role="group" aria-label={label} className="apg-menubar-group">
                {item.items.map((radioItem) => {
                  const { id: radioItemId, label: radioItemLabel, disabled } = radioItem;
                  const isChecked = radioStates.get(name) === radioItemId;
                  const isFocused =
                    state.focusedItemPath[state.focusedItemPath.length - 1] === radioItemId;

                  return (
                    <li key={radioItemId} role="none">
                      <span
                        ref={(el) => {
                          if (el) {
                            menuItemRefs.current.set(radioItemId, el);
                          } else {
                            menuItemRefs.current.delete(radioItemId);
                          }
                        }}
                        role="menuitemradio"
                        aria-checked={isChecked}
                        aria-disabled={disabled || undefined}
                        tabIndex={isFocused ? 0 : -1}
                        className="apg-menubar-menuitem apg-menubar-menuitemradio"
                        onClick={() => activateMenuItem(radioItem, name)}
                        onKeyDown={(e) =>
                          handleMenuKeyDown(e, radioItem, menuItems, isSubmenu, name)
                        }
                      >
                        {radioItemLabel}
                      </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;

使い方

Example
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' },
      { type: 'item', id: 'export', label: 'Export', disabled: true },
    ],
  },
  {
    id: 'edit',
    label: 'Edit',
    items: [
      { type: 'item', id: 'cut', label: 'Cut' },
      { type: 'item', id: 'copy', label: 'Copy' },
      { type: 'item', id: 'paste', label: 'Paste' },
    ],
  },
  {
    id: 'view',
    label: 'View',
    items: [
      { type: 'checkbox', id: 'toolbar', label: 'Show Toolbar', checked: true },
      { type: 'separator', id: 'sep2' },
      {
        type: 'radiogroup',
        id: 'theme',
        label: 'Theme',
        items: [
          { type: 'radio', id: 'light', label: 'Light', checked: true },
          { type: 'radio', id: 'dark', label: 'Dark', checked: false },
        ],
      },
    ],
  },
];

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

API

プロパティ デフォルト 説明
items MenubarItem[] required トップレベルのメニュー項目の配列
aria-label string - アクセシブルな名前(aria-labelledbyがない場合は必須)
aria-labelledby string - ラベリング要素のID(aria-labelがない場合は必須)
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 最初/最後のアイテムにフォーカスを移動

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

テスト 説明
Checkbox toggle Space/Enterでチェックボックスを切り替え
Checkbox keeps open 切り替えでメニューが閉じない
aria-checked update 切り替えでaria-checkedが更新される
Radio select Space/Enterでラジオを選択
Radio keeps open 選択でメニューが閉じない
Exclusive selection グループ内で1つのラジオのみがチェック可能

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

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

高優先度: 先行入力検索

テスト 説明
Character match 入力された文字で始まるアイテムにフォーカス
Wrap around 検索が末尾から先頭にラップ
Skip separator 検索時に区切り線をスキップ
Skip disabled 検索時に無効なアイテムをスキップ
Buffer reset 500ms後にバッファがリセット

高優先度: ポインタ操作

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

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

テスト 説明
axe closed メニューバーが閉じているとき違反なし
axe menu open メニューが開いているとき違反なし
axe submenu open サブメニューが開いているとき違反なし

テストツール

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

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

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

// Helper function to create basic menubar items
const createBasicItems = (): MenubarItem[] => [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      { type: 'item', id: 'open', label: 'Open' },
      { 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' },
    ],
  },
  {
    id: 'view',
    label: 'View',
    items: [
      { type: 'item', id: 'zoom-in', label: 'Zoom In' },
      { type: 'item', id: 'zoom-out', label: 'Zoom Out' },
    ],
  },
];

// Items with submenu
const createItemsWithSubmenu = (): MenubarItem[] => [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      {
        type: 'submenu',
        id: 'open-recent',
        label: 'Open Recent',
        items: [
          { type: 'item', id: 'doc1', label: 'Document 1' },
          { type: 'item', id: 'doc2', label: 'Document 2' },
        ],
      },
      { type: 'item', id: 'save', label: 'Save' },
    ],
  },
  {
    id: 'edit',
    label: 'Edit',
    items: [{ type: 'item', id: 'cut', label: 'Cut' }],
  },
];

// Items with checkbox and radio
const createItemsWithCheckboxRadio = (): MenubarItem[] => [
  {
    id: 'view',
    label: 'View',
    items: [
      {
        type: 'checkbox',
        id: 'auto-save',
        label: 'Auto Save',
        checked: false,
      },
      {
        type: 'checkbox',
        id: 'word-wrap',
        label: 'Word Wrap',
        checked: true,
      },
      { type: 'separator', id: 'sep1' },
      {
        type: 'radiogroup',
        id: 'theme-group',
        name: 'theme',
        label: 'Theme',
        items: [
          { type: 'radio', id: 'light', label: 'Light', checked: true },
          { type: 'radio', id: 'dark', label: 'Dark', checked: false },
          { type: 'radio', id: 'system', label: 'System', checked: false },
        ],
      },
    ],
  },
];

// Items with disabled items
const createItemsWithDisabled = (): MenubarItem[] => [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      { type: 'item', id: 'open', label: 'Open', disabled: true },
      { type: 'item', id: 'save', label: 'Save' },
    ],
  },
];

// Items with separator
const createItemsWithSeparator = (): MenubarItem[] => [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      { type: 'separator', id: 'sep1' },
      { type: 'item', id: 'save', label: 'Save' },
    ],
  },
];

describe('Menubar', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG ARIA Attributes', () => {
    it('has role="menubar" on container', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);
      expect(screen.getByRole('menubar')).toBeInTheDocument();
    });

    it('has role="menu" on dropdown', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      expect(screen.getByRole('menu')).toBeInTheDocument();
    });

    it('has role="menuitem" on items', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);
      expect(screen.getAllByRole('menuitem')).toHaveLength(3);
    });

    it('has role="menuitemcheckbox" on checkbox items', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
    });

    it('has role="menuitemradio" on radio items', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      expect(screen.getAllByRole('menuitemradio')).toHaveLength(3);
    });

    it('has role="separator" on dividers', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithSeparator()} aria-label="Application" />);

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

      expect(screen.getByRole('separator')).toBeInTheDocument();
    });

    it('has role="group" on radio groups with aria-label', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const group = screen.getByRole('group', { name: 'Theme' });
      expect(group).toBeInTheDocument();
    });

    it('has role="none" on li elements', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const listItems = document.querySelectorAll('li');
      listItems.forEach((li) => {
        expect(li).toHaveAttribute('role', 'none');
      });
    });

    it('has aria-haspopup="menu" on items with submenu (not "true")', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      expect(fileItem).toHaveAttribute('aria-haspopup', 'menu');
    });

    it('has aria-expanded on items with submenu', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });

    it('updates aria-expanded when submenu opens', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('has aria-checked on checkbox items', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      expect(autoSave).toHaveAttribute('aria-checked', 'false');

      const wordWrap = screen.getByRole('menuitemcheckbox', { name: 'Word Wrap' });
      expect(wordWrap).toHaveAttribute('aria-checked', 'true');
    });

    it('has aria-checked on radio items', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const lightRadio = screen.getByRole('menuitemradio', { name: 'Light' });
      expect(lightRadio).toHaveAttribute('aria-checked', 'true');

      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
      expect(darkRadio).toHaveAttribute('aria-checked', 'false');
    });

    it('has accessible name on menubar', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);
      expect(screen.getByRole('menubar', { name: 'Application' })).toBeInTheDocument();
    });

    it('submenu has aria-labelledby referencing parent menuitem', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithSubmenu()} aria-label="Application" />);

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

      const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
      openRecent.focus();
      await user.keyboard('{ArrowRight}');

      const submenu = screen.getAllByRole('menu')[1];
      const labelledBy = submenu.getAttribute('aria-labelledby');
      expect(labelledBy).toBe(openRecent.id);
    });

    it('closed menu has hidden or aria-hidden', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const menus = document.querySelectorAll('[role="menu"]');
      menus.forEach((menu) => {
        const hasHidden =
          menu.hasAttribute('hidden') || menu.getAttribute('aria-hidden') === 'true';
        expect(hasHidden).toBe(true);
      });
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Menubar
  describe('APG Keyboard Interaction - Menubar', () => {
    it('ArrowRight moves to next menubar item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{ArrowRight}');

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

    it('ArrowLeft moves to previous menubar item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const editItem = screen.getByRole('menuitem', { name: 'Edit' });
      editItem.focus();
      await user.keyboard('{ArrowLeft}');

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

    it('ArrowRight wraps from last to first', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const viewItem = screen.getByRole('menuitem', { name: 'View' });
      viewItem.focus();
      await user.keyboard('{ArrowRight}');

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

    it('ArrowLeft wraps from first to last', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{ArrowLeft}');

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

    it('ArrowDown opens submenu and focuses first item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('ArrowUp opens submenu and focuses last item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('Enter opens submenu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('Space opens submenu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('Home moves to first menubar item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('End moves to last menubar item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('Tab moves focus out and closes all menus', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <Menubar items={createBasicItems()} aria-label="Application" />
          <button>Outside</button>
        </div>
      );

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

      await user.keyboard('{Tab}');

      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });

    it('Shift+Tab moves focus out and closes all menus', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <button>Before</button>
          <Menubar items={createBasicItems()} aria-label="Application" />
        </div>
      );

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

      // Use fireEvent instead of user.keyboard for Shift+Tab due to jsdom limitations
      // with user-event's tab destination calculation
      fireEvent.keyDown(fileItem, { key: 'Tab', shiftKey: true });

      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Menu
  describe('APG Keyboard Interaction - Menu', () => {
    it('ArrowDown moves to next item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

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

    it('ArrowUp moves to previous item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

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

    it('ArrowDown wraps from last to first', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

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

    it('ArrowUp wraps from first to last', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

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

    it('ArrowRight opens submenu when item has one', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithSubmenu()} aria-label="Application" />);

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

      const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
      openRecent.focus();
      await user.keyboard('{ArrowRight}');

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

    it('ArrowRight moves to next menubar item when in top-level menu (item has no submenu)', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      const newItem = screen.getByRole('menuitem', { name: 'New' });
      newItem.focus();
      await user.keyboard('{ArrowRight}');

      // File menu should close, Edit menu should open
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
      const editItem = screen.getByRole('menuitem', { name: 'Edit' });
      expect(editItem).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });

    it('ArrowLeft closes submenu and returns to parent', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithSubmenu()} aria-label="Application" />);

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

      const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
      openRecent.focus();
      await user.keyboard('{ArrowRight}');

      expect(openRecent).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{ArrowLeft}');

      expect(openRecent).toHaveAttribute('aria-expanded', 'false');
      expect(openRecent).toHaveFocus();
    });

    it('ArrowLeft moves to previous menubar item when in top-level menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

      // Edit menu should close, File menu should open
      expect(editItem).toHaveAttribute('aria-expanded', 'false');
      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      expect(fileItem).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
    });

    it('Enter activates menuitem and closes menu', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
      );

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

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

      expect(onItemSelect).toHaveBeenCalledWith('new');
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });

    it('Space activates menuitem and closes menu', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
      );

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

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

      expect(onItemSelect).toHaveBeenCalledWith('new');
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });

    it('Escape closes menu and returns focus to menubar', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      await user.keyboard('{Escape}');

      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
      expect(fileItem).toHaveFocus();
    });

    it('Home moves to first item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

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

    it('End moves to last item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

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

  // 🔴 High Priority: Checkbox and Radio Items
  describe('Checkbox and Radio Items', () => {
    it('Space toggles menuitemcheckbox', async () => {
      const user = userEvent.setup();
      const onCheckedChange = vi.fn();
      const items = createItemsWithCheckboxRadio();
      (items[0].items[0] as any).onCheckedChange = onCheckedChange;
      render(<Menubar items={items} aria-label="Application" />);

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

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      autoSave.focus();
      await user.keyboard(' ');

      expect(onCheckedChange).toHaveBeenCalledWith(true);
    });

    it('Enter toggles menuitemcheckbox', async () => {
      const user = userEvent.setup();
      const onCheckedChange = vi.fn();
      const items = createItemsWithCheckboxRadio();
      (items[0].items[0] as any).onCheckedChange = onCheckedChange;
      render(<Menubar items={items} aria-label="Application" />);

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

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      autoSave.focus();
      await user.keyboard('{Enter}');

      expect(onCheckedChange).toHaveBeenCalledWith(true);
    });

    it('Space on checkbox does not close menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      autoSave.focus();
      await user.keyboard(' ');

      // Menu should still be open
      expect(viewItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('Enter on checkbox does not close menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      autoSave.focus();
      await user.keyboard('{Enter}');

      // Menu should still be open
      expect(viewItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('updates aria-checked on toggle', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      expect(autoSave).toHaveAttribute('aria-checked', 'false');

      autoSave.focus();
      await user.keyboard(' ');

      expect(autoSave).toHaveAttribute('aria-checked', 'true');
    });

    it('Space selects menuitemradio', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
      darkRadio.focus();
      await user.keyboard(' ');

      expect(darkRadio).toHaveAttribute('aria-checked', 'true');
    });

    it('Space on radio does not close menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
      darkRadio.focus();
      await user.keyboard(' ');

      // Menu should still be open
      expect(viewItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('Enter on radio does not close menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
      darkRadio.focus();
      await user.keyboard('{Enter}');

      // Menu should still be open
      expect(viewItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('only one radio in group can be checked', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const lightRadio = screen.getByRole('menuitemradio', { name: 'Light' });
      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });

      expect(lightRadio).toHaveAttribute('aria-checked', 'true');
      expect(darkRadio).toHaveAttribute('aria-checked', 'false');

      darkRadio.focus();
      await user.keyboard(' ');

      expect(lightRadio).toHaveAttribute('aria-checked', 'false');
      expect(darkRadio).toHaveAttribute('aria-checked', 'true');
    });

    it('unchecks other radios in group when one is selected', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);

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

      const systemRadio = screen.getByRole('menuitemradio', { name: 'System' });
      systemRadio.focus();
      await user.keyboard(' ');

      const lightRadio = screen.getByRole('menuitemradio', { name: 'Light' });
      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });

      expect(lightRadio).toHaveAttribute('aria-checked', 'false');
      expect(darkRadio).toHaveAttribute('aria-checked', 'false');
      expect(systemRadio).toHaveAttribute('aria-checked', 'true');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('first menubar item has tabIndex="0"', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

    it('other menubar items have tabIndex="-1"', () => {
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const editItem = screen.getByRole('menuitem', { name: 'Edit' });
      const viewItem = screen.getByRole('menuitem', { name: 'View' });

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

    it('separator is not focusable', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithSeparator()} aria-label="Application" />);

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

      const separator = screen.getByRole('separator');
      expect(separator).not.toHaveAttribute('tabindex');

      // Navigate through - should skip separator
      const newItem = screen.getByRole('menuitem', { name: 'New' });
      newItem.focus();
      await user.keyboard('{ArrowDown}');

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

    it('disabled items are focusable', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithDisabled()} aria-label="Application" />);

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

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

      const openItem = screen.getByRole('menuitem', { name: 'Open' });
      expect(openItem).toHaveFocus();
      expect(openItem).toHaveAttribute('aria-disabled', 'true');
    });

    it('disabled items cannot be activated', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <Menubar
          items={createItemsWithDisabled()}
          aria-label="Application"
          onItemSelect={onItemSelect}
        />
      );

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

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

      expect(onItemSelect).not.toHaveBeenCalled();
      // Menu should still be open
      expect(fileItem).toHaveAttribute('aria-expanded', 'true');
    });
  });

  // 🔴 High Priority: Type-Ahead
  describe('Type-Ahead', () => {
    it('character focuses matching item', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      const newItem = screen.getByRole('menuitem', { name: 'New' });
      newItem.focus();
      await user.keyboard('s');

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

    it('search wraps around', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      const saveItem = screen.getByRole('menuitem', { name: 'Save' });
      saveItem.focus();
      await user.keyboard('n');

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

    it('skips separator', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createItemsWithSeparator()} aria-label="Application" />);

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

      const newItem = screen.getByRole('menuitem', { name: 'New' });
      newItem.focus();
      await user.keyboard('s');

      // Should find Save, not get stuck on separator
      expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
    });

    it('skips disabled items', async () => {
      const user = userEvent.setup();
      const items: MenubarItem[] = [
        {
          id: 'file',
          label: 'File',
          items: [
            { type: 'item', id: 'open', label: 'Open', disabled: true },
            { type: 'item', id: 'options', label: 'Options' },
          ],
        },
      ];
      render(<Menubar items={items} aria-label="Application" />);

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

      const optionsItem = screen.getByRole('menuitem', { name: 'Options' });
      optionsItem.focus();
      await user.keyboard('o');

      // Should wrap to Options (skip disabled Open)
      expect(screen.getByRole('menuitem', { name: 'Options' })).toHaveFocus();
    });

    it('resets after 500ms', async () => {
      vi.useFakeTimers();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      act(() => {
        fireEvent.click(fileItem);
      });

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

      // Type 'o' -> should focus 'Open'
      act(() => {
        fireEvent.keyDown(newItem, { key: 'o' });
      });
      expect(screen.getByRole('menuitem', { name: 'Open' })).toHaveFocus();

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

      // Type 's' -> should focus 'Save' (not 'os')
      const openItem = screen.getByRole('menuitem', { name: 'Open' });
      act(() => {
        fireEvent.keyDown(openItem, { key: 's' });
      });
      expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: Pointer Interaction
  describe('Pointer Interaction', () => {
    it('click on menubar item opens menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      expect(fileItem).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menu')).toBeInTheDocument();
    });

    it('click on menubar item again closes menu', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

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

    it('hover on another menubar item switches menu when open', async () => {
      const user = userEvent.setup();
      render(<Menubar items={createBasicItems()} aria-label="Application" />);

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      const editItem = screen.getByRole('menuitem', { name: 'Edit' });

      await user.click(fileItem);
      expect(fileItem).toHaveAttribute('aria-expanded', 'true');

      await user.hover(editItem);

      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
      expect(editItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('click on menuitem activates and closes menu', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
      );

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

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

      expect(onItemSelect).toHaveBeenCalledWith('new');
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });

    it('click outside closes menu', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <Menubar items={createBasicItems()} aria-label="Application" />
          <button>Outside</button>
        </div>
      );

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

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

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations when closed', async () => {
      const { container } = render(<Menubar items={createBasicItems()} aria-label="Application" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with menu open', async () => {
      const user = userEvent.setup();
      const { container } = render(<Menubar items={createBasicItems()} aria-label="Application" />);

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

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with submenu open', async () => {
      const user = userEvent.setup();
      const { container } = render(
        <Menubar items={createItemsWithSubmenu()} aria-label="Application" />
      );

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

      const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
      openRecent.focus();
      await user.keyboard('{ArrowRight}');

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with checkbox and radio', async () => {
      const user = userEvent.setup();
      const { container } = render(
        <Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />
      );

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

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props & Behavior
  describe('Props & Behavior', () => {
    it('calls onItemSelect with correct id', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(
        <Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
      );

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

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

      expect(onItemSelect).toHaveBeenCalledWith('save');
    });

    it('applies className to container', () => {
      const { container } = render(
        <Menubar items={createBasicItems()} aria-label="Application" className="custom-class" />
      );

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

    it('supports aria-labelledby instead of aria-label', () => {
      render(
        <div>
          <h2 id="menu-heading">Application Menu</h2>
          <Menubar items={createBasicItems()} aria-labelledby="menu-heading" />
        </div>
      );

      const menubar = screen.getByRole('menubar');
      expect(menubar).toHaveAttribute('aria-labelledby', 'menu-heading');
    });
  });
});

リソース