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

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 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

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
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 Activate item and close menu (checkbox/radio: change state, then close menu)
Space Activate item and close menu; for checkbox/radio, change state without closing menu (optional)
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
Character Type-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

Event Behavior
Initial focus Only one menubar item has tabindex="0" at a time
Other items Other items have tabindex="-1"
Arrow key navigation Arrow keys move focus between items with wrapping
Disabled items Disabled items are focusable but not activatable (per APG recommendation)
Separator Separators are not focusable
Menu close Focus 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;
      // Keep roving tabindex / focusedMenubarIndex in sync with the open menu so that
      // closing (Enter, Escape) returns focus to the correct menubar item, even when
      // the menu was opened by click or hover rather than keyboard.
      this.updateMenubarTabindex(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) {
            // Disabled submenu triggers are focusable but must not open (matches click path)
            if (target.getAttribute('aria-disabled') !== 'true') {
              this.openSubmenu(target);
            }
          } else {
            // Enter activates the item and closes the menu (including checkbox/radio).
            // Space toggles checkbox/radio without closing the menu (APG optional behavior).
            this.activateItem(target, event.key === 'Enter');
          }
          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');
    }

    // closeAfterToggle: for checkbox/radio, whether to close the menu after changing state.
    // APG: Enter always closes the menu; Space (optional) keeps it open for checkbox/radio.
    private activateItem(item: HTMLElement, closeAfterToggle = false) {
      // 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,
          })
        );
        if (closeAfterToggle) {
          this.closeAllMenus();
          this.menubarItems[this.focusedMenubarIndex]?.focus();
        }
        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,
            })
          );
        }
        if (closeAfterToggle) {
          this.closeAllMenus();
          this.menubarItems[this.focusedMenubarIndex]?.focus();
        }
        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

PropTypeDefaultDescription
itemsMenubarItem[]requiredArray of top-level menu items
aria-labelstring-Accessible name (required if no aria-labelledby)
aria-labelledbystring-ID of labelling element
classstring''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

TestDescription
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-haspopupItems with submenu have aria-haspopup=menu
aria-expandedItems with submenu reflect open state
aria-labelledbySubmenu references parent menuitem
aria-checkedCheckbox/radio items have correct checked state
aria-hiddenMenu has aria-hidden=true when closed, false when open

High Priority: APG Keyboard Interaction - Menubar

TestDescription
ArrowRightMoves focus to next menubar item (wraps)
ArrowLeftMoves focus to previous menubar item (wraps)
ArrowDownOpens submenu and focuses first item
ArrowUpOpens submenu and focuses last item
Enter/SpaceOpens submenu
HomeMoves focus to first menubar item
EndMoves focus to last menubar item
TabCloses all menus, moves focus out

High Priority: APG Keyboard Interaction - Menu

TestDescription
ArrowDownMoves focus to next item (wraps)
ArrowUpMoves focus to previous item (wraps)
ArrowRightOpens submenu if present, or moves to next menubar menu
ArrowLeftCloses submenu, or moves to previous menubar menu
Enter/SpaceActivates item and closes menu
EscapeCloses menu, returns focus to parent
Home/EndMoves focus to first/last item

High Priority: Checkbox and Radio Items

TestDescription
Checkbox toggleSpace/Enter toggles checkbox
Checkbox keeps openSpace toggle does not close menu; Enter toggles and closes menu
aria-checked updatearia-checked updates on toggle
Radio selectSpace/Enter selects radio
Radio keeps openSpace selection does not close menu; Enter selects and closes menu
Exclusive selectionOnly one radio in group can be checked

High Priority: Focus Management (Roving Tabindex)

TestDescription
tabIndex=0First menubar item has tabIndex=0
tabIndex=-1Other items have tabIndex=-1
SeparatorSeparator is not focusable
Disabled itemsDisabled items are focusable but not activatable

High Priority: Type-Ahead Search

TestDescription
Character matchFocuses item starting with typed character
Wrap aroundSearch wraps from end to beginning
Skip separatorSkips separator during search
Skip disabledSkips disabled items during search
Buffer resetBuffer resets after 500ms

High Priority: Pointer Interaction

TestDescription
Click openClick menubar item opens menu
Click toggleClick menubar item again closes menu
Hover switchHover on another menubar item switches menu (when open)
Item clickClick menuitem activates and closes menu
Click outsideClick outside closes menu

Medium Priority: Accessibility

TestDescription
axe closedNo violations when menubar is closed
axe menu openNo violations with menu open
axe submenu openNo violations with submenu open

Testing Tools

See the Testing Strategy guide for details.

Menubar.test.astro.ts
/**
 * Menubar Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of Menubar.astro.
 * Web Component behavior (opening/closing menus, keyboard navigation,
 * pointer interaction, checkbox/radio state changes and type-ahead) is
 * covered by E2E tests in e2e/menubar.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Menubar from './Menubar.astro';
// tsc cannot read types exported from .astro frontmatter; the React type has the same shape
import type { MenubarItem } from './Menubar';

// Shared menubar definition used as render input (not as an expectation source)
const items: 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', disabled: true },
    ],
  },
  {
    id: 'view',
    label: 'View',
    items: [
      { type: 'checkbox', id: 'toolbar', label: 'Show Toolbar', checked: true },
      { type: 'checkbox', id: 'statusbar', label: 'Show Status Bar', checked: false },
      {
        type: 'radiogroup',
        id: 'theme',
        name: 'theme',
        label: 'Theme',
        items: [
          { type: 'radio', id: 'light', label: 'Light', checked: true },
          { type: 'radio', id: 'dark', label: 'Dark', checked: false },
        ],
      },
    ],
  },
  {
    id: 'edit',
    label: 'Edit',
    items: [
      {
        type: 'submenu',
        id: 'find',
        label: 'Find',
        items: [
          { type: 'item', id: 'find-next', label: 'Find Next' },
          { type: 'item', id: 'replace', label: 'Replace' },
        ],
      },
    ],
  },
];

describe('Menubar (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderMenubar(props: Record<string, unknown> = {}): Promise<Document> {
    const html = await container.renderToString(Menubar, {
      props: { items, 'aria-label': 'Application', ...props },
    });
    return new JSDOM(html).window.document;
  }

  function getMenubarItem(doc: Document, label: string): Element | undefined {
    return Array.from(doc.querySelectorAll('[role="menubar"] > li > [role="menuitem"]')).find(
      (el) => el.textContent?.trim() === label
    );
  }

  function getMenuOf(doc: Document, label: string): Element | null {
    const trigger = getMenubarItem(doc, label);
    return doc.querySelector(`[role="menu"][aria-labelledby="${trigger?.id}"]`);
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-menubar custom element wrapper', async () => {
      const doc = await renderMenubar();
      expect(doc.querySelector('apg-menubar')).not.toBeNull();
    });

    it('renders a ul with role="menubar"', async () => {
      const doc = await renderMenubar();
      expect(doc.querySelector('ul[role="menubar"]')).not.toBeNull();
    });

    it('renders one menubar item per top-level entry', async () => {
      const doc = await renderMenubar();
      const labels = Array.from(
        doc.querySelectorAll('[role="menubar"] > li > [role="menuitem"]')
      ).map((el) => el.textContent?.trim());
      expect(labels).toEqual(['File', 'View', 'Edit']);
    });

    it('has role="none" on all li elements', async () => {
      const doc = await renderMenubar();
      const roles = Array.from(doc.querySelectorAll('li')).map((li) => li.getAttribute('role'));
      expect(roles.length).toBeGreaterThan(0);
      expect(roles.every((role) => role === 'none')).toBe(true);
    });

    it('hides the decorative chevron icons from assistive technology', async () => {
      const doc = await renderMenubar();
      const svgs = Array.from(doc.querySelectorAll('svg'));
      expect(svgs.length).toBeGreaterThan(0);
      expect(svgs.every((svg) => svg.getAttribute('aria-hidden') === 'true')).toBe(true);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has aria-label on the menubar when provided', async () => {
      const doc = await renderMenubar({ 'aria-label': 'Application' });
      expect(doc.querySelector('[role="menubar"]')?.getAttribute('aria-label')).toBe('Application');
    });

    it('has aria-labelledby on the menubar when provided', async () => {
      const doc = await renderMenubar({ 'aria-label': undefined, 'aria-labelledby': 'app-title' });
      expect(doc.querySelector('[role="menubar"]')?.getAttribute('aria-labelledby')).toBe(
        'app-title'
      );
    });
  });

  // 🔴 High Priority: Menubar Items
  describe('Menubar Items', () => {
    it('has aria-haspopup="menu" (not "true") on menubar items', async () => {
      const doc = await renderMenubar();
      expect(getMenubarItem(doc, 'File')?.getAttribute('aria-haspopup')).toBe('menu');
    });

    it('has aria-expanded="false" on menubar items initially', async () => {
      const doc = await renderMenubar();
      expect(getMenubarItem(doc, 'File')?.getAttribute('aria-expanded')).toBe('false');
      expect(getMenubarItem(doc, 'View')?.getAttribute('aria-expanded')).toBe('false');
      expect(getMenubarItem(doc, 'Edit')?.getAttribute('aria-expanded')).toBe('false');
    });

    it('gives each menubar item a unique id per component instance', async () => {
      const first = await renderMenubar();
      const second = await renderMenubar();
      expect(getMenubarItem(first, 'File')?.id).not.toBe(getMenubarItem(second, 'File')?.id);
    });
  });

  // 🔴 High Priority: Focus Management (roving tabindex)
  describe('Focus Management', () => {
    it('has tabindex="0" on the first menubar item', async () => {
      const doc = await renderMenubar();
      expect(getMenubarItem(doc, 'File')?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" on the other menubar items', async () => {
      const doc = await renderMenubar();
      expect(getMenubarItem(doc, 'View')?.getAttribute('tabindex')).toBe('-1');
      expect(getMenubarItem(doc, 'Edit')?.getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="-1" on all items inside menus', async () => {
      const doc = await renderMenubar();
      const tabindexes = Array.from(
        doc.querySelectorAll(
          '[role="menu"] [role="menuitem"], [role="menu"] [role="menuitemcheckbox"], [role="menu"] [role="menuitemradio"]'
        )
      ).map((el) => el.getAttribute('tabindex'));
      expect(tabindexes.length).toBe(10);
      expect(tabindexes.every((tabindex) => tabindex === '-1')).toBe(true);
    });
  });

  // 🔴 High Priority: Menus
  describe('Menus', () => {
    it('renders a role="menu" labelled by its menubar item', async () => {
      const doc = await renderMenubar();
      const menu = getMenuOf(doc, 'File');
      const labelId = menu?.getAttribute('aria-labelledby') ?? '';
      expect(doc.getElementById(labelId)?.textContent?.trim()).toBe('File');
    });

    it('has aria-hidden="true" on menus initially', async () => {
      const doc = await renderMenubar();
      expect(getMenuOf(doc, 'File')?.getAttribute('aria-hidden')).toBe('true');
    });

    it('renders the menu items in order', async () => {
      const doc = await renderMenubar();
      const labels = Array.from(
        getMenuOf(doc, 'File')?.querySelectorAll('[role="menuitem"]') ?? []
      ).map((el) => el.textContent?.trim());
      expect(labels).toEqual(['New', 'Open', 'Save']);
    });

    it('renders a separator as hr with role="separator"', async () => {
      const doc = await renderMenubar();
      const separator = getMenuOf(doc, 'File')?.querySelector('[role="separator"]');
      expect(separator?.tagName).toBe('HR');
    });

    it('has aria-disabled="true" on disabled items', async () => {
      const doc = await renderMenubar();
      const save = Array.from(doc.querySelectorAll('[role="menuitem"]')).find(
        (el) => el.textContent?.trim() === 'Save'
      );
      expect(save?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled on enabled items', async () => {
      const doc = await renderMenubar();
      const newItem = Array.from(doc.querySelectorAll('[role="menuitem"]')).find(
        (el) => el.textContent?.trim() === 'New'
      );
      expect(newItem?.hasAttribute('aria-disabled')).toBe(false);
    });
  });

  // 🟡 Medium Priority: Checkbox and Radio Items
  describe('Checkbox and Radio Items', () => {
    it('renders checkbox items with role="menuitemcheckbox"', async () => {
      const doc = await renderMenubar();
      const labels = Array.from(doc.querySelectorAll('[role="menuitemcheckbox"]')).map((el) =>
        el.textContent?.trim()
      );
      expect(labels).toEqual(['Show Toolbar', 'Show Status Bar']);
    });

    it('has aria-checked="true" on a checked checkbox item', async () => {
      const doc = await renderMenubar();
      const toolbar = doc.querySelector('[role="menuitemcheckbox"][data-item-id="toolbar"]');
      expect(toolbar?.getAttribute('aria-checked')).toBe('true');
    });

    it('has aria-checked="false" on an unchecked checkbox item', async () => {
      const doc = await renderMenubar();
      const statusbar = doc.querySelector('[role="menuitemcheckbox"][data-item-id="statusbar"]');
      expect(statusbar?.getAttribute('aria-checked')).toBe('false');
    });

    it('wraps radio items in role="group" with aria-label', async () => {
      const doc = await renderMenubar();
      const group = doc.querySelector('[role="group"]');
      expect(group?.getAttribute('aria-label')).toBe('Theme');
      expect(group?.querySelectorAll('[role="menuitemradio"]').length).toBe(2);
    });

    it('has aria-checked reflecting the checked radio item', async () => {
      const doc = await renderMenubar();
      const light = doc.querySelector('[role="menuitemradio"][data-item-id="light"]');
      const dark = doc.querySelector('[role="menuitemradio"][data-item-id="dark"]');
      expect(light?.getAttribute('aria-checked')).toBe('true');
      expect(dark?.getAttribute('aria-checked')).toBe('false');
    });
  });

  // 🟡 Medium Priority: Submenus
  describe('Submenus', () => {
    it('has aria-haspopup="menu" and aria-expanded="false" on a submenu trigger', async () => {
      const doc = await renderMenubar();
      const trigger = doc.querySelector('[role="menuitem"][data-item-id="find"]');
      expect(trigger?.getAttribute('aria-haspopup')).toBe('menu');
      expect(trigger?.getAttribute('aria-expanded')).toBe('false');
    });

    it('renders a submenu labelled by its trigger with aria-hidden="true"', async () => {
      const doc = await renderMenubar();
      const trigger = doc.querySelector('[role="menuitem"][data-item-id="find"]');
      const submenu = doc.querySelector(`[role="menu"][aria-labelledby="${trigger?.id}"]`);
      expect(submenu?.getAttribute('aria-hidden')).toBe('true');
    });

    it('renders the submenu items in order', async () => {
      const doc = await renderMenubar();
      const trigger = doc.querySelector('[role="menuitem"][data-item-id="find"]');
      const submenu = doc.querySelector(`[role="menu"][aria-labelledby="${trigger?.id}"]`);
      const labels = Array.from(submenu?.querySelectorAll('[role="menuitem"]') ?? []).map((el) =>
        el.textContent?.trim()
      );
      expect(labels).toEqual(['Find Next', 'Replace']);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('applies a custom class to the nav container', async () => {
      const doc = await renderMenubar({ class: 'custom-class' });
      const nav = doc.querySelector('nav.apg-menubar');
      expect(nav?.classList.contains('custom-class')).toBe(true);
    });
  });
});

Resources