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.vue
<template>
  <div ref="containerRef" :class="`apg-menu-button ${className}`.trim()">
    <button
      ref="buttonRef"
      :id="buttonId"
      type="button"
      class="apg-menu-button-trigger"
      aria-haspopup="menu"
      :aria-expanded="isOpen"
      :aria-controls="menuId"
      v-bind="$attrs"
      @click="toggleMenu"
      @keydown="handleButtonKeyDown"
    >
      {{ label }}
    </button>
    <ul
      :id="menuId"
      role="menu"
      :aria-labelledby="buttonId"
      class="apg-menu-button-menu"
      :hidden="!isOpen || undefined"
      :inert="!isOpen || undefined"
    >
      <li
        v-for="item in items"
        :key="item.id"
        :ref="(el) => setItemRef(item.id, el)"
        role="menuitem"
        :tabindex="getTabIndex(item)"
        :aria-disabled="item.disabled || undefined"
        class="apg-menu-button-item"
        @click="handleItemClick(item)"
        @keydown="(e) => handleMenuKeyDown(e, item)"
        @focus="handleItemFocus(item)"
      >
        {{ item.label }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onUnmounted, nextTick, watch, useId } from 'vue';

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

export interface MenuButtonProps {
  items: MenuItem[];
  label: string;
  defaultOpen?: boolean;
  className?: string;
}

const props = withDefaults(defineProps<MenuButtonProps>(), {
  defaultOpen: false,
  className: '',
});

const emit = defineEmits<{
  itemSelect: [itemId: string];
}>();

defineOptions({
  inheritAttrs: false,
});

// Refs
const containerRef = ref<HTMLDivElement>();
const buttonRef = ref<HTMLButtonElement>();
const menuItemRefs = ref<Record<string, HTMLLIElement>>({});
// Use Vue 3.5+ useId for SSR-safe unique IDs
const instanceId = useId();
const isOpen = ref(props.defaultOpen);
const focusedIndex = ref(-1);
const typeAheadBuffer = ref('');
const typeAheadTimeoutId = ref<number | null>(null);
const typeAheadTimeout = 500;

// Computed
const buttonId = computed(() => `${instanceId}-button`);
const menuId = computed(() => `${instanceId}-menu`);
const availableItems = computed(() => props.items.filter((item) => !item.disabled));

// Map of item id to index in availableItems for O(1) lookup
const availableIndexMap = computed(() => {
  const map = new Map<string, number>();
  availableItems.value.forEach(({ id }, index) => map.set(id, index));
  return map;
});

onUnmounted(() => {
  if (typeAheadTimeoutId.value !== null) {
    clearTimeout(typeAheadTimeoutId.value);
  }
});

// Watch focusedIndex to focus the correct item (also react to availableItems changes)
watch([() => isOpen.value, () => focusedIndex.value, availableItems], async () => {
  if (!isOpen.value || focusedIndex.value < 0) return;

  const targetItem = availableItems.value[focusedIndex.value];
  if (targetItem) {
    await nextTick();
    menuItemRefs.value[targetItem.id]?.focus();
  }
});

// Helper functions
const setItemRef = (id: string, el: unknown) => {
  if (el instanceof HTMLLIElement) {
    menuItemRefs.value[id] = el;
  } else if (el === null) {
    delete menuItemRefs.value[id];
  }
};

const getTabIndex = (item: MenuItem): number => {
  if (item.disabled) return -1;
  const availableIndex = availableIndexMap.value.get(item.id) ?? -1;
  return availableIndex === focusedIndex.value ? 0 : -1;
};

// Menu control
const closeMenu = () => {
  isOpen.value = false;
  focusedIndex.value = -1;
  // Clear type-ahead state
  typeAheadBuffer.value = '';
  if (typeAheadTimeoutId.value !== null) {
    clearTimeout(typeAheadTimeoutId.value);
    typeAheadTimeoutId.value = null;
  }
};

const openMenu = (focusPosition: 'first' | 'last') => {
  if (availableItems.value.length === 0) {
    isOpen.value = true;
    return;
  }

  isOpen.value = true;
  const targetIndex = focusPosition === 'first' ? 0 : availableItems.value.length - 1;
  focusedIndex.value = targetIndex;
};

const toggleMenu = () => {
  if (isOpen.value) {
    closeMenu();
  } else {
    openMenu('first');
  }
};

// Event handlers
const handleItemClick = async (item: MenuItem) => {
  if (item.disabled) return;
  emit('itemSelect', item.id);
  closeMenu();
  await nextTick();
  buttonRef.value?.focus();
};

const handleItemFocus = (item: MenuItem) => {
  if (item.disabled) return;
  const availableIndex = availableIndexMap.value.get(item.id) ?? -1;
  if (availableIndex >= 0) {
    focusedIndex.value = availableIndex;
  }
};

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

const handleTypeAhead = (char: string) => {
  const { value: items } = availableItems;
  if (items.length === 0) return;

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

  typeAheadBuffer.value += char.toLowerCase();

  const buffer = typeAheadBuffer.value;
  const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
  const currentFocusedIndex = focusedIndex.value;
  const itemsLength = items.length;

  let startIndex: number;
  let searchStr: string;

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

  for (let i = 0; i < itemsLength; i++) {
    const index = (startIndex + i) % itemsLength;
    const option = items[index];
    if (option.label.toLowerCase().startsWith(searchStr)) {
      focusedIndex.value = index;
      break;
    }
  }

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

const handleMenuKeyDown = async (event: KeyboardEvent, item: MenuItem) => {
  const { value: items } = availableItems;
  const itemsLength = items.length;

  // Guard: no available items
  if (itemsLength === 0) {
    if (event.key === 'Escape') {
      event.preventDefault();
      closeMenu();
      await nextTick();
      buttonRef.value?.focus();
    }
    return;
  }

  const currentIndex = availableIndexMap.value.get(item.id) ?? -1;

  // Guard: disabled item received focus
  if (currentIndex < 0) {
    if (event.key === 'Escape') {
      event.preventDefault();
      closeMenu();
      await nextTick();
      buttonRef.value?.focus();
    }
    return;
  }

  switch (event.key) {
    case 'ArrowDown': {
      event.preventDefault();
      const nextIndex = (currentIndex + 1) % itemsLength;
      focusedIndex.value = nextIndex;
      break;
    }
    case 'ArrowUp': {
      event.preventDefault();
      const prevIndex = currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
      focusedIndex.value = prevIndex;
      break;
    }
    case 'Home': {
      event.preventDefault();
      focusedIndex.value = 0;
      break;
    }
    case 'End': {
      event.preventDefault();
      focusedIndex.value = itemsLength - 1;
      break;
    }
    case 'Escape': {
      event.preventDefault();
      closeMenu();
      await nextTick();
      buttonRef.value?.focus();
      break;
    }
    case 'Tab': {
      closeMenu();
      break;
    }
    case 'Enter':
    case ' ': {
      event.preventDefault();
      if (!item.disabled) {
        emit('itemSelect', item.id);
        closeMenu();
        await nextTick();
        buttonRef.value?.focus();
      }
      break;
    }
    default: {
      // Type-ahead: single printable character
      const { key, ctrlKey, metaKey, altKey } = event;
      if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
        event.preventDefault();
        handleTypeAhead(key);
      }
    }
  }
};

// Click outside handler
const handleClickOutside = (event: MouseEvent) => {
  const { value: container } = containerRef;
  if (container && !container.contains(event.target as Node)) {
    closeMenu();
  }
};

watch(
  () => isOpen.value,
  (newIsOpen) => {
    if (newIsOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    } else {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  }
);

onUnmounted(() => {
  document.removeEventListener('mousedown', handleClickOutside);
});
</script>

Usage

Example
<script setup lang="ts">
import MenuButton from './MenuButton.vue';

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

const handleItemSelect = (itemId: string) => {
  console.log('Selected:', itemId);
};
</script>

<template>
  <!-- Basic usage -->
  <MenuButton
    :items="items"
    label="Actions"
    @item-select="handleItemSelect"
  />

  <!-- With default open state -->
  <MenuButton
    :items="items"
    label="Actions"
    default-open
    @item-select="handleItemSelect"
  />
</template>

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
className string '' Additional CSS class for the container

Events

Event Payload Description
item-select string Emitted 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