APG Patterns
日本語
日本語

Menubar

A horizontal menu bar that provides application-style navigation with dropdown menus, submenus, checkbox items, and radio groups.

Demo

Last action: None

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
menubarHorizontal container (<ul>)Top-level menu bar, always visible
menuVertical container (<ul>)Dropdown menu or submenu
menuitemItem (<span>)Standard action item
menuitemcheckboxCheckbox itemToggleable option
menuitemradioRadio itemExclusive option in a group
separatorDivider (<hr>)Visual separator (not focusable)
groupGroup containerGroups radio items with a label
none<li> elementsHides list semantics from screen readers

WAI-ARIA Properties

aria-haspopup

Indicates the item opens a menu (use “menu”, not “true”)

Values
menu
Required
Yes*

aria-expanded

Indicates whether the menu is open

Values
true | false
Required
Yes*

aria-labelledby

References the parent menuitem

Values
ID reference
Required
Yes**

aria-label

Provides an accessible name

Values
String
Required
Yes**

aria-checked

Indicates checked state

Values
true | false
Required
Yes

aria-disabled

Indicates the item is disabled

Values
true
Required
No

aria-hidden

Hides menu from screen readers when closed

Values
true | false
Required
Yes

Keyboard Support

KeyAction
Right ArrowMove focus to next menubar item (wraps to first)
Left ArrowMove focus to previous menubar item (wraps to last)
Down ArrowOpen submenu and focus first item
Up ArrowOpen submenu and focus last item
Enter / SpaceOpen submenu and focus first item
HomeMove focus to first menubar item
EndMove focus to last menubar item
TabClose all menus and move focus out
KeyAction
Down ArrowMove focus to next item (wraps to first)
Up ArrowMove focus to previous item (wraps to last)
Right ArrowOpen submenu if present, or move to next menubar item’s menu (in top-level menu)
Left ArrowClose submenu and return to parent, or move to previous menubar item’s menu (in top-level menu)
Enter / SpaceActivate item and close menu; for checkbox/radio, toggle state and keep menu open
EscapeClose menu and return focus to parent (menubar item or parent menuitem)
HomeMove focus to first item
EndMove focus to last item
CharacterType-ahead: focus item starting with typed character(s)
  • When closed, the menu uses aria-hidden=“true” with CSS to hide from screen readers (aria-hidden), hide visually (visibility: hidden), prevent pointer interaction (pointer-events: none), and enable smooth CSS animations on open/close.
  • When open, the menu has aria-hidden=“false” with visibility: visible.

Focus Management

EventBehavior
Initial focusOnly one menubar item has tabindex="0" at a time
Other itemsOther items have tabindex="-1"
Arrow key navigationArrow keys move focus between items with wrapping
Disabled itemsDisabled items are focusable but not activatable (per APG recommendation)
SeparatorSeparators are not focusable
Menu closeFocus returns to invoker when menu closes

References

Source Code

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;

Usage

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

Prop Type Default Description
items MenubarItem[] required Array of top-level menu items
aria-label string - Accessible name (required if no aria-labelledby)
aria-labelledby string - ID of labelling element (required if no aria-label)
onItemSelect (id: string) => void - Callback when an item is activated
className string '' Additional CSS class for the container

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Menubar component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.

  • HTML structure and element hierarchy
  • Initial attribute values (role, aria-haspopup, aria-expanded)
  • Click event handling
  • CSS class application

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.

  • Keyboard navigation (Arrow keys, Enter, Space, Escape, Tab)
  • Submenu opening and closing
  • Menubar horizontal navigation
  • Checkbox and radio item toggling
  • Hover-based menu switching
  • Type-ahead search
  • Focus management and roving tabindex
  • Cross-framework consistency

Test Categories

High Priority: APG ARIA Attributes

Test Description
role="menubar" Container has menubar role
role="menu" Dropdown has menu role
role="menuitem" Items have menuitem role
role="menuitemcheckbox" Checkbox items have correct role
role="menuitemradio" Radio items have correct role
role="separator" Dividers have separator role
role="group" Radio groups have group role with aria-label
role="none" All li elements have role=none
aria-haspopup Items with submenu have aria-haspopup=menu
aria-expanded Items with submenu reflect open state
aria-labelledby Submenu references parent menuitem
aria-checked Checkbox/radio items have correct checked state
aria-hidden Menu has aria-hidden=true when closed, false when open

High Priority: APG Keyboard Interaction - Menubar

Test Description
ArrowRight Moves focus to next menubar item (wraps)
ArrowLeft Moves focus to previous menubar item (wraps)
ArrowDown Opens submenu and focuses first item
ArrowUp Opens submenu and focuses last item
Enter/Space Opens submenu
Home Moves focus to first menubar item
End Moves focus to last menubar item
Tab Closes all menus, moves focus out

High Priority: APG Keyboard Interaction - Menu

Test Description
ArrowDown Moves focus to next item (wraps)
ArrowUp Moves focus to previous item (wraps)
ArrowRight Opens submenu if present, or moves to next menubar menu
ArrowLeft Closes submenu, or moves to previous menubar menu
Enter/Space Activates item and closes menu
Escape Closes menu, returns focus to parent
Home/End Moves focus to first/last item

High Priority: Checkbox and Radio Items

Test Description
Checkbox toggle Space/Enter toggles checkbox
Checkbox keeps open Toggle does not close menu
aria-checked update aria-checked updates on toggle
Radio select Space/Enter selects radio
Radio keeps open Selection does not close menu
Exclusive selection Only one radio in group can be checked

High Priority: Focus Management (Roving Tabindex)

Test Description
tabIndex=0 First menubar item has tabIndex=0
tabIndex=-1 Other items have tabIndex=-1
Separator Separator is not focusable
Disabled items Disabled items are focusable but not activatable

High Priority: Type-Ahead Search

Test Description
Character match Focuses item starting with typed character
Wrap around Search wraps from end to beginning
Skip separator Skips separator during search
Skip disabled Skips disabled items during search
Buffer reset Buffer resets after 500ms

High Priority: Pointer Interaction

Test Description
Click open Click menubar item opens menu
Click toggle Click menubar item again closes menu
Hover switch Hover on another menubar item switches menu (when open)
Item click Click menuitem activates and closes menu
Click outside Click outside closes menu

Medium Priority: Accessibility

Test Description
axe closed No violations when menubar is closed
axe menu open No violations with menu open
axe submenu open No violations with submenu open

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

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

Resources