APG Patterns
日本語
日本語

Menubar

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

Demo

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.astro
---
/**
 * APG Menubar Pattern - Astro Implementation
 *
 * A horizontal bar of menu triggers that open dropdown menus.
 * Uses Web Components for enhanced control and proper focus management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
 */

// MenuItem interface with discriminated union pattern
// type can be: 'item' | 'separator' | 'checkbox' | 'radio' | 'radiogroup' | 'submenu'
export interface MenuItem {
  type: string;
  id: string;
  label?: string;
  disabled?: boolean;
  checked?: boolean;
  name?: string; // for radiogroup
  items?: MenuItem[]; // for submenu and radiogroup
}

// Type guard helper for action items in submenus
type MenuItemAction = MenuItem & { type: 'item' | 'separator' };

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

export interface Props {
  items: MenubarItem[];
  class?: string;
  'aria-label'?: string;
  'aria-labelledby'?: string;
}

const {
  items = [],
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
} = Astro.props;

// Generate unique ID for this instance
const instanceId = `menubar-${Math.random().toString(36).slice(2, 11)}`;
---

<apg-menubar>
  <nav class={`apg-menubar ${className}`.trim()}>
    <ul
      role="menubar"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      class="apg-menubar-list"
      data-menubar
    >
      {
        items.map((menubarItem, index) => {
          const triggerId = `${instanceId}-${menubarItem.id}-trigger`;
          const menuId = `${instanceId}-${menubarItem.id}-menu`;
          const isFirstItem = index === 0;

          return (
            <li role="none">
              <span
                id={triggerId}
                role="menuitem"
                tabindex={isFirstItem ? 0 : -1}
                aria-haspopup="menu"
                aria-expanded="false"
                class="apg-menubar-trigger"
                data-menubar-trigger
                data-menubar-index={index}
              >
                {menubarItem.label}
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  width="12"
                  height="12"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="2"
                  stroke-linecap="round"
                  stroke-linejoin="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={triggerId}
                class="apg-menubar-menu"
                aria-hidden="true"
                data-menubar-menu
                data-menubar-index={index}
              >
                {menubarItem.items.map((item) => {
                  if (item.type === 'separator') {
                    return (
                      <li role="none">
                        <hr role="separator" class="apg-menubar-separator" />
                      </li>
                    );
                  }

                  if (item.type === 'radiogroup') {
                    return (
                      <li role="none">
                        <ul role="group" aria-label={item.label} class="apg-menubar-group">
                          {item.items?.map((radioItem) => (
                            <li role="none">
                              <span
                                role="menuitemradio"
                                tabindex="-1"
                                aria-checked={radioItem.checked ? 'true' : 'false'}
                                aria-disabled={radioItem.disabled || undefined}
                                class="apg-menubar-menuitem apg-menubar-menuitemradio"
                                data-item-id={radioItem.id}
                                data-radio-group={item.name}
                              >
                                {radioItem.label}
                              </span>
                            </li>
                          ))}
                        </ul>
                      </li>
                    );
                  }

                  if (item.type === 'checkbox') {
                    return (
                      <li role="none">
                        <span
                          role="menuitemcheckbox"
                          tabindex="-1"
                          aria-checked={item.checked ? 'true' : 'false'}
                          aria-disabled={item.disabled || undefined}
                          class="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
                          data-item-id={item.id}
                        >
                          {item.label}
                        </span>
                      </li>
                    );
                  }

                  if (item.type === 'submenu') {
                    const submenuTriggerId = `${instanceId}-${item.id}-submenu-trigger`;
                    const submenuId = `${instanceId}-${item.id}-submenu`;

                    return (
                      <li role="none" class="apg-menubar-submenu-container">
                        <span
                          id={submenuTriggerId}
                          role="menuitem"
                          tabindex="-1"
                          aria-haspopup="menu"
                          aria-expanded="false"
                          aria-disabled={item.disabled || undefined}
                          class="apg-menubar-menuitem apg-menubar-submenu-trigger"
                          data-item-id={item.id}
                          data-submenu-trigger
                        >
                          {item.label}
                          <svg
                            xmlns="http://www.w3.org/2000/svg"
                            width="12"
                            height="12"
                            viewBox="0 0 24 24"
                            fill="none"
                            stroke="currentColor"
                            stroke-width="2"
                            stroke-linecap="round"
                            stroke-linejoin="round"
                            aria-hidden="true"
                            style="margin-left: auto; position: relative; top: 1px"
                          >
                            <path d="m9 18 6-6-6-6" />
                          </svg>
                        </span>
                        <ul
                          id={submenuId}
                          role="menu"
                          aria-labelledby={submenuTriggerId}
                          class="apg-menubar-submenu"
                          aria-hidden="true"
                          data-submenu
                        >
                          {(item.items ?? [])
                            .filter(
                              (subItem): subItem is MenuItemAction =>
                                subItem.type === 'item' || subItem.type === 'separator'
                            )
                            .map((subItem) => {
                              if (subItem.type === 'separator') {
                                return (
                                  <li role="none">
                                    <hr role="separator" class="apg-menubar-separator" />
                                  </li>
                                );
                              }
                              return (
                                <li role="none">
                                  <span
                                    role="menuitem"
                                    tabindex="-1"
                                    aria-disabled={subItem.disabled || undefined}
                                    class="apg-menubar-menuitem"
                                    data-item-id={subItem.id}
                                  >
                                    {subItem.label}
                                  </span>
                                </li>
                              );
                            })}
                        </ul>
                      </li>
                    );
                  }

                  // Default: action item
                  return (
                    <li role="none">
                      <span
                        role="menuitem"
                        tabindex="-1"
                        aria-disabled={item.disabled || undefined}
                        class="apg-menubar-menuitem"
                        data-item-id={item.id}
                      >
                        {item.label}
                      </span>
                    </li>
                  );
                })}
              </ul>
            </li>
          );
        })
      }
    </ul>
  </nav>
</apg-menubar>

<script>
  class ApgMenubar extends HTMLElement {
    private menubar: HTMLUListElement | null = null;
    private menubarItems: HTMLElement[] = [];
    private rafId: number | null = null;

    // State
    private openMenuIndex = -1;
    private focusedMenubarIndex = 0;
    private typeAheadBuffer = '';
    private typeAheadTimeoutId: number | null = null;
    private readonly typeAheadTimeout = 500;

    // Checkbox/Radio states (stored separately since we're modifying aria-checked)
    private checkboxStates: Map<string, boolean> = new Map();
    private radioStates: Map<string, string> = new Map(); // group name -> checked id

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.menubar = this.querySelector('[data-menubar]');

      if (!this.menubar) {
        console.warn('apg-menubar: menubar element not found');
        return;
      }

      this.menubarItems = Array.from(
        this.menubar.querySelectorAll<HTMLElement>('[data-menubar-trigger]')
      );

      // Initialize checkbox/radio states from aria-checked
      this.initializeCheckboxRadioStates();

      // Attach event listeners
      this.menubar.addEventListener('click', this.handleClick);
      this.menubar.addEventListener('keydown', this.handleKeyDown);
      this.menubar.addEventListener('mouseover', this.handleMouseOver);
      document.addEventListener('pointerdown', this.handleClickOutside);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
        this.typeAheadTimeoutId = null;
      }
      document.removeEventListener('pointerdown', this.handleClickOutside);
      this.menubar?.removeEventListener('click', this.handleClick);
      this.menubar?.removeEventListener('keydown', this.handleKeyDown);
      this.menubar?.removeEventListener('mouseover', this.handleMouseOver);
    }

    private initializeCheckboxRadioStates() {
      // Checkboxes
      const checkboxes = this.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]');
      checkboxes.forEach((cb) => {
        const id = cb.dataset.itemId;
        if (id) {
          this.checkboxStates.set(id, cb.getAttribute('aria-checked') === 'true');
        }
      });

      // Radio groups
      const radios = this.querySelectorAll<HTMLElement>('[role="menuitemradio"]');
      radios.forEach((radio) => {
        const id = radio.dataset.itemId;
        const group = radio.dataset.radioGroup;
        if (id && group && radio.getAttribute('aria-checked') === 'true') {
          this.radioStates.set(group, id);
        }
      });
    }

    private getMenuForIndex(index: number): HTMLElement | null {
      return this.querySelector(`[data-menubar-menu][data-menubar-index="${index}"]`);
    }

    private getMenuItems(menu: HTMLElement): HTMLElement[] {
      // Get direct menu items (not submenu items)
      // Note: Disabled items ARE included - they should be focusable but not activatable per APG
      const items: HTMLElement[] = [];
      const allItems = menu.querySelectorAll<HTMLElement>(
        '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
      );
      allItems.forEach((item) => {
        // Exclude items that are inside a nested submenu
        const parentMenu = item.closest('[role="menu"]');
        if (parentMenu === menu) {
          items.push(item);
        }
      });
      return items;
    }

    private openMenu(menubarIndex: number, focusPosition: 'first' | 'last' = 'first') {
      // Close any open menu first
      if (this.openMenuIndex >= 0 && this.openMenuIndex !== menubarIndex) {
        this.closeMenu();
      }

      const trigger = this.menubarItems[menubarIndex];
      const menu = this.getMenuForIndex(menubarIndex);
      if (!trigger || !menu) return;

      this.openMenuIndex = menubarIndex;
      trigger.setAttribute('aria-expanded', 'true');
      menu.setAttribute('aria-hidden', 'false');

      // Focus first/last available item
      const menuItems = this.getMenuItems(menu);
      if (menuItems.length > 0) {
        const targetIndex = focusPosition === 'first' ? 0 : menuItems.length - 1;
        menuItems[targetIndex]?.focus();
      }
    }

    private closeMenu() {
      if (this.openMenuIndex < 0) return;

      const trigger = this.menubarItems[this.openMenuIndex];
      const menu = this.getMenuForIndex(this.openMenuIndex);

      if (trigger) {
        trigger.setAttribute('aria-expanded', 'false');
      }
      if (menu) {
        menu.setAttribute('aria-hidden', 'true');

        // Close any open submenus
        const submenus = menu.querySelectorAll('[data-submenu]');
        submenus.forEach((submenu) => {
          submenu.setAttribute('aria-hidden', 'true');
          const submenuTrigger = submenu
            .closest('.apg-menubar-submenu-container')
            ?.querySelector('[data-submenu-trigger]');
          submenuTrigger?.setAttribute('aria-expanded', 'false');
        });
      }

      // Clear type-ahead
      this.typeAheadBuffer = '';
      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
        this.typeAheadTimeoutId = null;
      }

      this.openMenuIndex = -1;
    }

    private closeAllMenus() {
      this.closeMenu();
    }

    private updateMenubarTabindex(newIndex: number) {
      this.menubarItems.forEach((item, idx) => {
        item.tabIndex = idx === newIndex ? 0 : -1;
      });
      this.focusedMenubarIndex = newIndex;
    }

    private handleClick = (event: MouseEvent) => {
      if (!(event.target instanceof HTMLElement)) {
        return;
      }
      const target = event.target;

      // Handle menubar trigger click
      const menubarTrigger = target.closest('[data-menubar-trigger]');
      if (menubarTrigger instanceof HTMLElement) {
        const index = parseInt(menubarTrigger.dataset.menubarIndex || '0', 10);
        if (this.openMenuIndex === index) {
          this.closeMenu();
        } else {
          this.openMenu(index);
        }
        return;
      }

      // Handle submenu trigger click
      const submenuTrigger = target.closest('[data-submenu-trigger]');
      if (
        submenuTrigger instanceof HTMLElement &&
        submenuTrigger.getAttribute('aria-disabled') !== 'true'
      ) {
        const isExpanded = submenuTrigger.getAttribute('aria-expanded') === 'true';
        if (isExpanded) {
          const container = submenuTrigger.closest('.apg-menubar-submenu-container');
          const submenu = container?.querySelector('[data-submenu]');
          if (submenu instanceof HTMLElement) {
            this.closeSubmenu(submenu);
          }
        } else {
          this.openSubmenu(submenuTrigger);
        }
        return;
      }

      // Handle menu item click
      const menuItem = target.closest(
        '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
      );
      if (menuItem instanceof HTMLElement && menuItem.getAttribute('aria-disabled') !== 'true') {
        this.activateItem(menuItem);
      }
    };

    private handleMouseOver = (event: MouseEvent) => {
      // Only handle hover switching when a menu is already open
      if (this.openMenuIndex < 0) return;

      if (!(event.target instanceof HTMLElement)) {
        return;
      }
      const target = event.target;
      const menubarTrigger = target.closest('[data-menubar-trigger]');
      if (menubarTrigger instanceof HTMLElement) {
        const index = parseInt(menubarTrigger.dataset.menubarIndex || '0', 10);
        if (index !== this.openMenuIndex) {
          this.openMenu(index);
        }
      }
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (!(event.target instanceof HTMLElement)) {
        return;
      }
      const target = event.target;

      // Determine if we're on menubar or inside menu
      const isOnMenubar = target.hasAttribute('data-menubar-trigger');
      const isInMenu = target.closest('[data-menubar-menu]') || target.closest('[data-submenu]');

      if (isOnMenubar) {
        this.handleMenubarKeyDown(event, target);
      } else if (isInMenu) {
        this.handleMenuKeyDown(event, target);
      }
    };

    private handleMenubarKeyDown(event: KeyboardEvent, target: HTMLElement) {
      const index = parseInt(target.dataset.menubarIndex || '0', 10);

      switch (event.key) {
        case 'ArrowRight': {
          event.preventDefault();
          const nextIndex = (index + 1) % this.menubarItems.length;
          this.updateMenubarTabindex(nextIndex);
          this.menubarItems[nextIndex]?.focus();
          if (this.openMenuIndex >= 0) {
            this.openMenu(nextIndex);
          }
          break;
        }
        case 'ArrowLeft': {
          event.preventDefault();
          const prevIndex = index === 0 ? this.menubarItems.length - 1 : index - 1;
          this.updateMenubarTabindex(prevIndex);
          this.menubarItems[prevIndex]?.focus();
          if (this.openMenuIndex >= 0) {
            this.openMenu(prevIndex);
          }
          break;
        }
        case 'ArrowDown':
        case 'Enter':
        case ' ': {
          event.preventDefault();
          this.openMenu(index, 'first');
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          this.openMenu(index, 'last');
          break;
        }
        case 'Home': {
          event.preventDefault();
          this.updateMenubarTabindex(0);
          this.menubarItems[0]?.focus();
          break;
        }
        case 'End': {
          event.preventDefault();
          const lastIndex = this.menubarItems.length - 1;
          this.updateMenubarTabindex(lastIndex);
          this.menubarItems[lastIndex]?.focus();
          break;
        }
        case 'Escape': {
          event.preventDefault();
          this.closeMenu();
          break;
        }
        case 'Tab': {
          // Close menus when Tab is pressed from menubar
          this.closeAllMenus();
          break;
        }
      }
    }

    private handleMenuKeyDown(event: KeyboardEvent, target: HTMLElement) {
      const isSubmenuTrigger = target.hasAttribute('data-submenu-trigger');

      // Get the current menu context
      const currentMenu = target.closest('[role="menu"]');
      if (!(currentMenu instanceof HTMLElement)) {
        return;
      }

      const menuItems = this.getMenuItems(currentMenu);
      const currentIndex = menuItems.indexOf(target);

      switch (event.key) {
        case 'ArrowDown': {
          event.preventDefault();
          if (currentIndex >= 0) {
            const nextIndex = (currentIndex + 1) % menuItems.length;
            menuItems[nextIndex]?.focus();
          }
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          if (currentIndex >= 0) {
            const prevIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
            menuItems[prevIndex]?.focus();
          }
          break;
        }
        case 'ArrowRight': {
          event.preventDefault();
          if (isSubmenuTrigger) {
            // Open submenu
            this.openSubmenu(target);
          } else {
            // Move to next menubar item
            const nextIndex = (this.openMenuIndex + 1) % this.menubarItems.length;
            this.updateMenubarTabindex(nextIndex);
            this.openMenu(nextIndex);
          }
          break;
        }
        case 'ArrowLeft': {
          event.preventDefault();
          const parentSubmenu = target.closest('[data-submenu]');
          if (parentSubmenu instanceof HTMLElement) {
            // Close submenu and return to parent
            this.closeSubmenu(parentSubmenu);
          } else {
            // Move to previous menubar item
            const prevIndex =
              this.openMenuIndex === 0 ? this.menubarItems.length - 1 : this.openMenuIndex - 1;
            this.updateMenubarTabindex(prevIndex);
            this.openMenu(prevIndex);
          }
          break;
        }
        case 'Home': {
          event.preventDefault();
          menuItems[0]?.focus();
          break;
        }
        case 'End': {
          event.preventDefault();
          menuItems[menuItems.length - 1]?.focus();
          break;
        }
        case 'Enter':
        case ' ': {
          event.preventDefault();
          if (isSubmenuTrigger) {
            this.openSubmenu(target);
          } else {
            this.activateItem(target);
          }
          break;
        }
        case 'Escape': {
          event.preventDefault();
          const parentSubmenu = target.closest('[data-submenu]');
          if (parentSubmenu instanceof HTMLElement) {
            this.closeSubmenu(parentSubmenu);
          } else {
            this.closeMenu();
            this.menubarItems[this.focusedMenubarIndex]?.focus();
          }
          break;
        }
        case 'Tab': {
          this.closeAllMenus();
          break;
        }
        default: {
          // Type-ahead
          if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
            event.preventDefault();
            this.handleTypeAhead(event.key, menuItems, currentIndex);
          }
        }
      }
    }

    private openSubmenu(trigger: HTMLElement) {
      const container = trigger.closest('.apg-menubar-submenu-container');
      const submenu = container?.querySelector('[data-submenu]');
      if (!(submenu instanceof HTMLElement)) {
        return;
      }

      trigger.setAttribute('aria-expanded', 'true');
      submenu.setAttribute('aria-hidden', 'false');

      // Focus first item in submenu
      const items = this.getMenuItems(submenu);
      items[0]?.focus();
    }

    private closeSubmenu(submenu: HTMLElement) {
      const container = submenu.closest('.apg-menubar-submenu-container');
      const trigger = container?.querySelector('[data-submenu-trigger]');
      if (trigger instanceof HTMLElement) {
        trigger.setAttribute('aria-expanded', 'false');
        trigger.focus();
      }
      submenu.setAttribute('aria-hidden', 'true');
    }

    private activateItem(item: HTMLElement) {
      // Disabled items should not be activatable
      if (item.getAttribute('aria-disabled') === 'true') {
        return;
      }

      const role = item.getAttribute('role');
      const itemId = item.dataset.itemId;

      if (role === 'menuitemcheckbox') {
        // Toggle checkbox
        const currentChecked = item.getAttribute('aria-checked') === 'true';
        const newChecked = !currentChecked;
        item.setAttribute('aria-checked', String(newChecked));
        if (itemId) {
          this.checkboxStates.set(itemId, newChecked);
        }
        this.dispatchEvent(
          new CustomEvent('checkboxchange', {
            detail: { itemId, checked: newChecked },
            bubbles: true,
          })
        );
        // Don't close menu for checkbox
        return;
      }

      if (role === 'menuitemradio') {
        // Update radio group
        const group = item.dataset.radioGroup;
        if (group) {
          // Uncheck all radios in the group
          const groupRadios = this.querySelectorAll<HTMLElement>(
            `[role="menuitemradio"][data-radio-group="${group}"]`
          );
          groupRadios.forEach((radio) => {
            radio.setAttribute('aria-checked', 'false');
          });
          // Check the selected one
          item.setAttribute('aria-checked', 'true');
          if (itemId) {
            this.radioStates.set(group, itemId);
          }
          this.dispatchEvent(
            new CustomEvent('radiochange', {
              detail: { group, itemId },
              bubbles: true,
            })
          );
        }
        // Don't close menu for radio
        return;
      }

      // Regular menu item - dispatch event and close
      if (itemId) {
        this.dispatchEvent(
          new CustomEvent('itemselect', {
            detail: { itemId },
            bubbles: true,
          })
        );
      }
      this.closeAllMenus();
      this.menubarItems[this.focusedMenubarIndex]?.focus();
    }

    private handleTypeAhead(char: string, menuItems: HTMLElement[], currentIndex: number) {
      if (menuItems.length === 0) return;

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

      this.typeAheadBuffer += char.toLowerCase();

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

      let startIndex: number;
      let searchStr: string;

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

      for (let i = 0; i < menuItems.length; i++) {
        const index = (startIndex + i) % menuItems.length;
        const item = menuItems[index];
        const label = item.textContent?.trim().toLowerCase() || '';
        if (label.startsWith(searchStr)) {
          item.focus();
          break;
        }
      }

      this.typeAheadTimeoutId = window.setTimeout(() => {
        this.typeAheadBuffer = '';
        this.typeAheadTimeoutId = null;
      }, this.typeAheadTimeout);
    }

    private handleClickOutside = (event: PointerEvent) => {
      const target = event.target as Node;
      if (this.openMenuIndex >= 0 && !this.contains(target)) {
        this.closeAllMenus();
      }
    };
  }

  if (!customElements.get('apg-menubar')) {
    customElements.define('apg-menubar', ApgMenubar);
  }
</script>

Usage

Example
---
import Menubar, { type MenubarItem } from '@patterns/menubar/Menubar.astro';
import '@patterns/menubar/menubar.css';

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

<Menubar items={menuItems} aria-label="Application" />

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
class string '' Additional CSS class
The Astro implementation uses Web Components (Custom Elements) for interactivity. The <apg-menubar> custom element handles all keyboard navigation, menu opening/closing, and state management on the client side.

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.astro.ts
/**
 * Menubar Web Component Tests
 *
 * Note: These are limited unit tests for the Web Component class.
 * Full keyboard navigation and focus management tests require E2E testing
 * with Playwright due to jsdom limitations with focus events.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Menubar (Web Component)', () => {
  let container: HTMLElement;

  // Simplified mock for testing basic structure
  // Full behavior tests are in E2E tests
  class TestApgMenubar extends HTMLElement {
    private menubar: HTMLUListElement | null = null;
    private openMenuIndex: number = -1;

    connectedCallback() {
      requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.menubar = this.querySelector('[role="menubar"]');
      if (!this.menubar) return;

      this.menubar.addEventListener('click', this.handleClick);
      this.menubar.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      this.menubar?.removeEventListener('click', this.handleClick);
      this.menubar?.removeEventListener('keydown', this.handleKeyDown);
    }

    private getMenubarItems(): HTMLElement[] {
      if (!this.menubar) return [];
      return Array.from(
        this.menubar.querySelectorAll<HTMLElement>(':scope > li > [role="menuitem"]')
      );
    }

    private handleClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const menuitem = target.closest('[role="menuitem"]');
      if (!menuitem) return;

      const menubarItems = this.getMenubarItems();
      const index = menubarItems.indexOf(menuitem as HTMLElement);

      if (index >= 0) {
        this.toggleMenu(index);
      }
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      const target = event.target as HTMLElement;
      const menuitem = target.closest('[role="menuitem"]');
      if (!menuitem) return;

      const menubarItems = this.getMenubarItems();
      const index = menubarItems.indexOf(menuitem as HTMLElement);

      if (index < 0) return;

      switch (event.key) {
        case 'ArrowRight': {
          event.preventDefault();
          const nextIndex = (index + 1) % menubarItems.length;
          menubarItems[nextIndex]?.focus();
          break;
        }
        case 'ArrowLeft': {
          event.preventDefault();
          const prevIndex = index === 0 ? menubarItems.length - 1 : index - 1;
          menubarItems[prevIndex]?.focus();
          break;
        }
        case 'ArrowDown':
        case 'Enter':
        case ' ': {
          event.preventDefault();
          this.openMenu(index);
          break;
        }
      }
    };

    private toggleMenu(index: number) {
      if (this.openMenuIndex === index) {
        this.closeMenu();
      } else {
        this.openMenu(index);
      }
    }

    private openMenu(index: number) {
      const menubarItems = this.getMenubarItems();
      const item = menubarItems[index];
      if (!item) return;

      // Close previous menu
      if (this.openMenuIndex >= 0) {
        const prevItem = menubarItems[this.openMenuIndex];
        prevItem?.setAttribute('aria-expanded', 'false');
        const prevMenu = prevItem?.parentElement?.querySelector('[role="menu"]');
        prevMenu?.setAttribute('hidden', '');
      }

      // Open new menu
      item.setAttribute('aria-expanded', 'true');
      const menu = item.parentElement?.querySelector('[role="menu"]');
      menu?.removeAttribute('hidden');
      this.openMenuIndex = index;

      // Focus first menu item
      const firstItem = menu?.querySelector('[role="menuitem"]') as HTMLElement;
      firstItem?.focus();
    }

    private closeMenu() {
      if (this.openMenuIndex >= 0) {
        const menubarItems = this.getMenubarItems();
        const item = menubarItems[this.openMenuIndex];
        item?.setAttribute('aria-expanded', 'false');
        const menu = item?.parentElement?.querySelector('[role="menu"]');
        menu?.setAttribute('hidden', '');
        this.openMenuIndex = -1;
      }
    }
  }

  function createMenubarHTML() {
    return `
      <apg-menubar>
        <ul role="menubar" aria-label="Application">
          <li role="none">
            <span
              id="file-menu-trigger"
              role="menuitem"
              tabindex="0"
              aria-haspopup="menu"
              aria-expanded="false"
            >
              File
            </span>
            <ul role="menu" aria-labelledby="file-menu-trigger" hidden>
              <li role="none">
                <span role="menuitem" tabindex="-1" data-item-id="new">New</span>
              </li>
              <li role="none">
                <span role="menuitem" tabindex="-1" data-item-id="open">Open</span>
              </li>
              <li role="none">
                <span role="menuitem" tabindex="-1" data-item-id="save">Save</span>
              </li>
            </ul>
          </li>
          <li role="none">
            <span
              id="edit-menu-trigger"
              role="menuitem"
              tabindex="-1"
              aria-haspopup="menu"
              aria-expanded="false"
            >
              Edit
            </span>
            <ul role="menu" aria-labelledby="edit-menu-trigger" hidden>
              <li role="none">
                <span role="menuitem" tabindex="-1" data-item-id="cut">Cut</span>
              </li>
              <li role="none">
                <span role="menuitem" tabindex="-1" data-item-id="copy">Copy</span>
              </li>
            </ul>
          </li>
        </ul>
      </apg-menubar>
    `;
  }

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);

    if (!customElements.get('apg-menubar')) {
      customElements.define('apg-menubar', TestApgMenubar);
    }
  });

  afterEach(() => {
    container.remove();
    vi.restoreAllMocks();
  });

  describe('Initial Rendering', () => {
    it('renders with role="menubar" on container', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menubar = container.querySelector('[role="menubar"]');
      expect(menubar).toBeTruthy();
    });

    it('renders with aria-label on menubar', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menubar = container.querySelector('[role="menubar"]');
      expect(menubar?.getAttribute('aria-label')).toBe('Application');
    });

    it('has role="none" on all li elements', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const listItems = container.querySelectorAll('li');
      listItems.forEach((li) => {
        expect(li.getAttribute('role')).toBe('none');
      });
    });

    it('has aria-haspopup="menu" on menubar items (not "true")', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menubar = container.querySelector('[role="menubar"]');
      const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');

      items?.forEach((item) => {
        expect(item.getAttribute('aria-haspopup')).toBe('menu');
      });
    });

    it('has aria-expanded="false" on menubar items', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menubar = container.querySelector('[role="menubar"]');
      const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');

      items?.forEach((item) => {
        expect(item.getAttribute('aria-expanded')).toBe('false');
      });
    });

    it('has hidden dropdown menus', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menus = container.querySelectorAll('[role="menu"]');
      menus.forEach((menu) => {
        expect(menu.hasAttribute('hidden')).toBe(true);
      });
    });

    it('first menubar item has tabindex="0"', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menubar = container.querySelector('[role="menubar"]');
      const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');

      expect(items?.[0].getAttribute('tabindex')).toBe('0');
    });

    it('other menubar items have tabindex="-1"', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menubar = container.querySelector('[role="menubar"]');
      const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');

      expect(items?.[1].getAttribute('tabindex')).toBe('-1');
    });

    it('dropdown menu has aria-labelledby referencing parent menuitem', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileMenu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
      expect(fileMenu).toBeTruthy();
      expect(fileMenu?.getAttribute('role')).toBe('menu');
    });
  });

  describe('Menu Open/Close', () => {
    it('opens menu on menubar item click', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
      fileItem.click();

      expect(fileItem.getAttribute('aria-expanded')).toBe('true');
      const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
      expect(menu?.hasAttribute('hidden')).toBe(false);
    });

    it('closes menu on second click (toggle)', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
      fileItem.click(); // Open
      fileItem.click(); // Close

      expect(fileItem.getAttribute('aria-expanded')).toBe('false');
      const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
      expect(menu?.hasAttribute('hidden')).toBe(true);
    });

    it('closes previous menu when opening another', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
      const editItem = container.querySelector('#edit-menu-trigger') as HTMLElement;

      fileItem.click();
      expect(fileItem.getAttribute('aria-expanded')).toBe('true');

      editItem.click();
      expect(fileItem.getAttribute('aria-expanded')).toBe('false');
      expect(editItem.getAttribute('aria-expanded')).toBe('true');
    });
  });

  describe('Keyboard Navigation', () => {
    it('ArrowRight moves to next menubar item', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
      const editItem = container.querySelector('#edit-menu-trigger') as HTMLElement;

      fileItem.focus();
      fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));

      // Check that edit item would receive focus (jsdom limitation)
      // In real browser, editItem.focus() is called
      expect(editItem).toBeTruthy();
    });

    it('ArrowDown opens submenu', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;

      fileItem.focus();
      fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));

      expect(fileItem.getAttribute('aria-expanded')).toBe('true');
    });

    it('Enter opens submenu', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;

      fileItem.focus();
      fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));

      expect(fileItem.getAttribute('aria-expanded')).toBe('true');
    });

    it('Space opens submenu', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;

      fileItem.focus();
      fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));

      expect(fileItem.getAttribute('aria-expanded')).toBe('true');
    });
  });

  describe('Menu Items', () => {
    it('dropdown menu contains menuitem elements', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
      fileItem.click();

      const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
      const items = menu?.querySelectorAll('[role="menuitem"]');

      expect(items?.length).toBe(3);
      expect(items?.[0].textContent?.trim()).toBe('New');
      expect(items?.[1].textContent?.trim()).toBe('Open');
      expect(items?.[2].textContent?.trim()).toBe('Save');
    });

    it('menu items have tabindex="-1" when menu opens', async () => {
      container.innerHTML = createMenubarHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
      const items = menu?.querySelectorAll('[role="menuitem"]');

      items?.forEach((item) => {
        expect(item.getAttribute('tabindex')).toBe('-1');
      });
    });
  });
});

Resources