APG Patterns
日本語 GitHub
日本語 GitHub

Menubar

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

🤖 AI Implementation Guide

Demo

Full Featured Menubar

Includes submenus, checkboxes, radio groups, separators, and disabled items.

Last action: None

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
menubar Horizontal container (<ul>) Top-level menu bar, always visible
menu Vertical container (<ul>) Dropdown menu or submenu
menuitem Item (<span>) Standard action item
menuitemcheckbox Checkbox item Toggleable option
menuitemradio Radio item Exclusive option in a group
separator Divider (<hr>) Visual separator (not focusable)
group Group container Groups radio items with a label
none <li> elements Hides list semantics from screen readers

WAI-ARIA menubar role (opens in new tab)

WAI-ARIA Properties (Menubar Items)

Attribute Values Required Description
aria-haspopup "menu" Yes* Indicates the item opens a menu (use "menu", not "true")
aria-expanded true | false Yes* Indicates whether the menu is open

* Only for items with a submenu

WAI-ARIA Properties (Menu/Submenu)

Attribute Target Values Required Description
aria-labelledby menu ID reference Yes** References the parent menuitem
aria-label menubar/menu String Yes** Provides an accessible name
aria-checked checkbox/radio true | false Yes Indicates checked state
aria-disabled menuitem true No Indicates the item is disabled
aria-hidden menu/submenu true | false Yes Hides menu from screen readers when closed

** Either aria-labelledby or aria-label is required for an accessible name

Keyboard Support

Menubar Navigation

Key Action
Right Arrow Move focus to next menubar item (wraps to first)
Left Arrow Move focus to previous menubar item (wraps to last)
Down Arrow Open submenu and focus first item
Up Arrow Open submenu and focus last item
Enter / Space Open submenu and focus first item
Home Move focus to first menubar item
End Move focus to last menubar item
Tab Close all menus and move focus out

Menu/Submenu Navigation

Key Action
Down Arrow Move focus to next item (wraps to first)
Up Arrow Move focus to previous item (wraps to last)
Right Arrow Open submenu if present, or move to next menubar item's menu (in top-level menu)
Left Arrow Close submenu and return to parent, or move to previous menubar item's menu (in top-level menu)
Enter / Space Activate item and close menu; for checkbox/radio, toggle state and keep menu open
Escape Close menu and return focus to parent (menubar item or parent menuitem)
Home Move focus to first item
End Move focus to last item
Type character Type-ahead: focus item starting with typed character(s)

Focus Management

This component uses the Roving Tabindex pattern for focus management:

  • Only one menubar item has tabindex="0" at a time
  • Other items have tabindex="-1"
  • Arrow keys move focus between items with wrapping
  • Disabled items are focusable but not activatable (per APG recommendation)
  • Separators are not focusable
  • Focus returns to invoker when menu closes

Differences from Menu-Button

Feature Menu-Button Menubar
Top-level structure <button> trigger <ul role="menubar"> (always visible)
Horizontal navigation None / between menubar items
Hover behavior None Auto-switch when menu is open
<li> role Not always specified role="none" required for all

Hidden State

When closed, the menu uses aria-hidden="true" with CSS to:

  • Hide the menu from screen readers (aria-hidden)
  • Hide the menu visually (visibility: hidden)
  • Prevent pointer interaction (pointer-events: none)
  • Enable smooth CSS animations on open/close

When open, the menu has aria-hidden="false" with visibility: visible.

Source Code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  const isMenuOpen = state.openMenubarIndex >= 0;

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

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

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

      const focusableItems = getAllFocusableItems(menubarItem.items);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      let searchStr: string;
      let startIndex: number;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

export default Menubar;

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

Menubar Props

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

MenuItem Types

Types
// Top-level menubar item
interface MenubarItem {
  id: string;
  label: string;
  items: MenuItem[];
}

// Menu items (used in dropdowns and submenus)
type MenuItem =
  | { type: 'item'; id: string; label: string; disabled?: boolean }
  | { type: 'separator'; id: string }
  | { type: 'checkbox'; id: string; label: string; checked: boolean; disabled?: boolean }
  | { type: 'radio'; id: string; label: string; checked: boolean; disabled?: boolean }
  | { type: 'radiogroup'; id: string; label: string; items: MenuItemRadio[] }
  | { type: 'submenu'; id: string; label: string; items: MenuItem[]; disabled?: boolean };

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.

Resources