APG Patterns
日本語 GitHub
日本語 GitHub

Menu Button

A button that opens a menu of actions or options.

🤖 AI Implementation Guide

Demo

Basic Menu Button

Click the button or use keyboard to open the menu.

Last action: None

With Disabled Items

Disabled items are skipped during keyboard navigation.

Last action: None

Note: "Export" is disabled and will be skipped during keyboard navigation

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
button Trigger (<button>) The trigger that opens the menu (implicit via <button> element)
menu Container (<ul>) A widget offering a list of choices to the user
menuitem Each item (<li>) An option in a menu

WAI-ARIA menu role (opens in new tab)

WAI-ARIA Properties (Button)

Attribute Values Required Description
aria-haspopup "menu" Yes Indicates the button opens a menu
aria-expanded true | false Yes Indicates whether the menu is open
aria-controls ID reference No References the menu element

WAI-ARIA Properties (Menu)

Attribute Target Values Required Description
aria-labelledby menu ID reference Yes* References the button that opens the menu
aria-label menu String Yes* Provides an accessible name for the menu
aria-disabled menuitem true No Indicates the menu item is disabled

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

Keyboard Support

Button (Closed Menu)

Key Action
Enter / Space Open menu and focus first item
Down Arrow Open menu and focus first item
Up Arrow Open menu and focus last item

Menu (Open)

Key Action
Down Arrow Move focus to next item (wraps to first)
Up Arrow Move focus to previous item (wraps to last)
Home Move focus to first item
End Move focus to last item
Escape Close menu and return focus to button
Tab Close menu and move focus to next focusable element
Enter / Space Activate focused item and close menu
Type character Type-ahead: focus item starting with typed character(s)

Focus Management

This component uses the Roving Tabindex pattern for focus management:

  • Only one menu item has tabindex="0" at a time
  • Other menu items have tabindex="-1"
  • Arrow keys move focus between items with wrapping
  • Disabled items are skipped during navigation
  • Focus returns to button when menu closes

Hidden State

When closed, the menu uses both hidden and inert attributes to:

  • Hide the menu from visual display
  • Remove the menu from the accessibility tree
  • Prevent keyboard and mouse interaction with hidden items

Source Code

MenuButton.astro
---
/**
 * APG Menu Button Pattern - Astro Implementation
 *
 * A button that opens a menu of actions or functions.
 * Uses Web Components for enhanced control and proper focus management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
 */

export interface MenuItem {
  id: string;
  label: string;
  disabled?: boolean;
}

export interface Props {
  /** Array of menu items */
  items: MenuItem[];
  /** Button label */
  label: string;
  /** Whether menu is initially open */
  defaultOpen?: boolean;
  /** Additional CSS class */
  class?: string;
}

const { items = [], label, defaultOpen = false, class: className = '' } = Astro.props;

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

// Calculate available items and initial focus
const availableItems = items.filter((item) => !item.disabled);
const initialFocusIndex = defaultOpen && availableItems.length > 0 ? 0 : -1;
---

<apg-menu-button
  data-default-open={defaultOpen ? 'true' : undefined}
  data-initial-focus-index={initialFocusIndex}
>
  <div class={`apg-menu-button ${className}`.trim()}>
    <button
      id={buttonId}
      type="button"
      class="apg-menu-button-trigger"
      aria-haspopup="menu"
      aria-expanded={defaultOpen}
      aria-controls={menuId}
      data-menu-trigger
    >
      {label}
    </button>
    <ul
      id={menuId}
      role="menu"
      aria-labelledby={buttonId}
      class="apg-menu-button-menu"
      hidden={!defaultOpen || undefined}
      inert={!defaultOpen || undefined}
      data-menu-list
    >
      {
        items.map((item, index) => {
          const availableIndex = availableItems.findIndex((i) => i.id === item.id);
          const isFocusTarget = availableIndex === initialFocusIndex;
          const tabIndex = item.disabled ? -1 : isFocusTarget ? 0 : -1;

          return (
            <li
              role="menuitem"
              data-item-id={item.id}
              tabindex={tabIndex}
              aria-disabled={item.disabled || undefined}
              class="apg-menu-button-item"
            >
              {item.label}
            </li>
          );
        })
      }
    </ul>
  </div>
</apg-menu-button>

<script>
  class ApgMenuButton extends HTMLElement {
    private container: HTMLDivElement | null = null;
    private button: HTMLButtonElement | null = null;
    private menu: HTMLUListElement | null = null;
    private rafId: number | null = null;
    private isOpen = false;
    private focusedIndex = -1;
    private typeAheadBuffer = '';
    private typeAheadTimeoutId: number | null = null;
    private typeAheadTimeout = 500;

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

    private initialize() {
      this.rafId = null;
      this.container = this.querySelector('.apg-menu-button');
      this.button = this.querySelector('[data-menu-trigger]');
      this.menu = this.querySelector('[data-menu-list]');

      if (!this.button || !this.menu) {
        console.warn('apg-menu-button: required elements not found');
        return;
      }

      // Initialize state from data attributes
      this.isOpen = this.dataset.defaultOpen === 'true';
      this.focusedIndex = parseInt(this.dataset.initialFocusIndex || '-1', 10);

      // Attach event listeners
      this.button.addEventListener('click', this.handleButtonClick);
      this.button.addEventListener('keydown', this.handleButtonKeyDown);
      this.menu.addEventListener('keydown', this.handleMenuKeyDown);
      this.menu.addEventListener('click', this.handleMenuClick);
      this.menu.addEventListener('focusin', this.handleMenuFocusIn);

      // Click outside listener (only when open)
      if (this.isOpen) {
        document.addEventListener('pointerdown', this.handleClickOutside);
        // Initialize roving tabindex and focus first item for APG compliance
        this.updateTabIndices();
        const availableItems = this.getAvailableItems();
        if (this.focusedIndex >= 0 && availableItems[this.focusedIndex]) {
          availableItems[this.focusedIndex].focus();
        }
      }
    }

    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.button?.removeEventListener('click', this.handleButtonClick);
      this.button?.removeEventListener('keydown', this.handleButtonKeyDown);
      this.menu?.removeEventListener('keydown', this.handleMenuKeyDown);
      this.menu?.removeEventListener('click', this.handleMenuClick);
      this.menu?.removeEventListener('focusin', this.handleMenuFocusIn);
    }

    private getItems(): HTMLLIElement[] {
      if (!this.menu) return [];
      return Array.from(this.menu.querySelectorAll<HTMLLIElement>('[role="menuitem"]'));
    }

    private getAvailableItems(): HTMLLIElement[] {
      return this.getItems().filter((item) => item.getAttribute('aria-disabled') !== 'true');
    }

    private openMenu(focusPosition: 'first' | 'last') {
      if (!this.button || !this.menu) return;

      const availableItems = this.getAvailableItems();

      this.isOpen = true;
      this.button.setAttribute('aria-expanded', 'true');
      this.menu.removeAttribute('hidden');
      this.menu.removeAttribute('inert');

      document.addEventListener('pointerdown', this.handleClickOutside);

      if (availableItems.length === 0) {
        this.focusedIndex = -1;
        return;
      }

      const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
      this.focusedIndex = targetIndex;
      this.updateTabIndices();
      availableItems[targetIndex]?.focus();
    }

    private closeMenu() {
      if (!this.button || !this.menu) return;

      this.isOpen = false;
      this.focusedIndex = -1;
      this.button.setAttribute('aria-expanded', 'false');
      this.menu.setAttribute('hidden', '');
      this.menu.setAttribute('inert', '');

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

      document.removeEventListener('pointerdown', this.handleClickOutside);

      // Reset tabindex
      this.updateTabIndices();
    }

    private toggleMenu() {
      if (this.isOpen) {
        this.closeMenu();
      } else {
        this.openMenu('first');
      }
    }

    private updateTabIndices() {
      const items = this.getItems();
      const availableItems = this.getAvailableItems();

      items.forEach((item) => {
        if (item.getAttribute('aria-disabled') === 'true') {
          item.tabIndex = -1;
          return;
        }
        const availableIndex = availableItems.indexOf(item);
        item.tabIndex = availableIndex === this.focusedIndex ? 0 : -1;
      });
    }

    private focusItem(index: number) {
      const availableItems = this.getAvailableItems();
      if (index >= 0 && index < availableItems.length) {
        this.focusedIndex = index;
        this.updateTabIndices();
        availableItems[index]?.focus();
      }
    }

    private handleTypeAhead(char: string) {
      const availableItems = this.getAvailableItems();
      if (availableItems.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 = this.focusedIndex >= 0 ? (this.focusedIndex + 1) % availableItems.length : 0;
      } else if (buffer.length === 1) {
        searchStr = buffer;
        startIndex = this.focusedIndex >= 0 ? (this.focusedIndex + 1) % availableItems.length : 0;
      } else {
        searchStr = buffer;
        startIndex = this.focusedIndex >= 0 ? this.focusedIndex : 0;
      }

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

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

    private handleButtonClick = () => {
      this.toggleMenu();
    };

    private handleButtonKeyDown = (event: KeyboardEvent) => {
      switch (event.key) {
        case 'Enter':
        case ' ':
          event.preventDefault();
          this.openMenu('first');
          break;
        case 'ArrowDown':
          event.preventDefault();
          this.openMenu('first');
          break;
        case 'ArrowUp':
          event.preventDefault();
          this.openMenu('last');
          break;
      }
    };

    private handleMenuClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
      if (!item || item.getAttribute('aria-disabled') === 'true') return;

      const itemId = item.dataset.itemId;
      if (itemId) {
        this.dispatchEvent(
          new CustomEvent('itemselect', {
            detail: { itemId },
            bubbles: true,
          })
        );
      }
      this.closeMenu();
      this.button?.focus();
    };

    private handleMenuFocusIn = (event: FocusEvent) => {
      const target = event.target as HTMLElement;
      const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
      if (!item || item.getAttribute('aria-disabled') === 'true') return;

      const availableItems = this.getAvailableItems();
      const index = availableItems.indexOf(item);
      if (index >= 0 && index !== this.focusedIndex) {
        this.focusedIndex = index;
        this.updateTabIndices();
      }
    };

    private handleMenuKeyDown = (event: KeyboardEvent) => {
      const availableItems = this.getAvailableItems();

      // Handle Escape even with no available items
      if (event.key === 'Escape') {
        event.preventDefault();
        this.closeMenu();
        this.button?.focus();
        return;
      }

      if (availableItems.length === 0) return;

      const target = event.target as HTMLElement;
      const currentItem = target.closest('[role="menuitem"]') as HTMLLIElement | null;
      const currentIndex = currentItem ? availableItems.indexOf(currentItem) : -1;

      // If focus is on disabled item, only handle Escape (already handled above)
      if (currentIndex < 0) return;

      switch (event.key) {
        case 'ArrowDown': {
          event.preventDefault();
          const nextIndex = (currentIndex + 1) % availableItems.length;
          this.focusItem(nextIndex);
          break;
        }
        case 'ArrowUp': {
          event.preventDefault();
          const prevIndex = currentIndex === 0 ? availableItems.length - 1 : currentIndex - 1;
          this.focusItem(prevIndex);
          break;
        }
        case 'Home': {
          event.preventDefault();
          this.focusItem(0);
          break;
        }
        case 'End': {
          event.preventDefault();
          this.focusItem(availableItems.length - 1);
          break;
        }
        case 'Tab': {
          this.closeMenu();
          break;
        }
        case 'Enter':
        case ' ': {
          event.preventDefault();
          const item = availableItems[currentIndex];
          const itemId = item?.dataset.itemId;
          if (itemId) {
            this.dispatchEvent(
              new CustomEvent('itemselect', {
                detail: { itemId },
                bubbles: true,
              })
            );
          }
          this.closeMenu();
          this.button?.focus();
          break;
        }
        default: {
          // Type-ahead: single printable character
          if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
            event.preventDefault();
            this.handleTypeAhead(event.key);
          }
        }
      }
    };

    private handleClickOutside = (event: PointerEvent) => {
      if (this.container && !this.container.contains(event.target as Node)) {
        this.closeMenu();
      }
    };
  }

  if (!customElements.get('apg-menu-button')) {
    customElements.define('apg-menu-button', ApgMenuButton);
  }
</script>

Usage

Example
---
import MenuButton from './MenuButton.astro';

const items = [
  { id: 'cut', label: 'Cut' },
  { id: 'copy', label: 'Copy' },
  { id: 'paste', label: 'Paste' },
  { id: 'delete', label: 'Delete', disabled: true },
];
---

<!-- Basic usage -->
<MenuButton items={items} label="Actions" />

<script>
  // Handle selection via custom event
  document.querySelectorAll('apg-menu-button').forEach((menuButton) => {
    menuButton.addEventListener('itemselect', (e) => {
      const event = e as CustomEvent<{ itemId: string }>;
      console.log('Selected:', event.detail.itemId);
    });
  });
</script>

API

MenuButton Props

Prop Type Default Description
items MenuItem[] required Array of menu items
label string required Button label text
defaultOpen boolean false Whether menu is initially open
class string '' Additional CSS class for the container

Custom Events

Event Detail Description
itemselect { itemId: string } Dispatched when a menu item is selected

MenuItem Interface

Types
interface MenuItem {
  id: string;
  label: string;
  disabled?: boolean;
}

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Mouse Interaction

Test Description
Button click Opens menu on button click
Toggle Clicking button again closes menu
Item click Clicking menu item activates and closes menu
Disabled item click Clicking disabled item does nothing
Click outside Clicking outside menu closes it

High Priority: APG Keyboard Interaction (Button)

Test Description
Enter Opens menu, focuses first enabled item
Space Opens menu, focuses first enabled item
ArrowDown Opens menu, focuses first enabled item
ArrowUp Opens menu, focuses last enabled item

High Priority: APG Keyboard Interaction (Menu)

Test Description
ArrowDown Moves focus to next enabled item (wraps)
ArrowUp Moves focus to previous enabled item (wraps)
Home Moves focus to first enabled item
End Moves focus to last enabled item
Escape Closes menu, returns focus to button
Tab Closes menu, moves focus out
Enter/Space Activates item and closes menu
Disabled skip Skips disabled items during navigation

High Priority: Type-Ahead Search

Test Description
Single character Focuses first item starting with typed character
Multiple characters Typed within 500ms form prefix search string
Wrap around Search wraps from end to beginning
Buffer reset Buffer resets after 500ms of inactivity

High Priority: APG ARIA Attributes

Test Description
aria-haspopup Button has aria-haspopup="menu"
aria-expanded Button reflects open state (true/false)
aria-controls Button references menu ID
role="menu" Menu container has menu role
role="menuitem" Each item has menuitem role
aria-labelledby Menu references button for accessible name
aria-disabled Disabled items have aria-disabled="true"

High Priority: Focus Management (Roving Tabindex)

Test Description
tabIndex=0 Focused item has tabIndex=0
tabIndex=-1 Non-focused items have tabIndex=-1
Initial focus First enabled item receives focus when menu opens
Focus return Focus returns to button when menu closes

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)

Testing Tools

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

Resources