APG Patterns
ๆ—ฅๆœฌ่ชž
ๆ—ฅๆœฌ่ชž

Menu Button

A button that opens a menu of actions or options.

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

Open demo only โ†’

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
buttonTrigger (<button>)The trigger that opens the menu (implicit via <button> element)
menuContainer (<ul>)A widget offering a list of choices to the user
menuitemEach item (<li>)An option in a menu

WAI-ARIA Properties

aria-haspopup

Indicates the button opens a menu

Values
menu
Required
Yes

aria-controls

References the menu element

Values
ID reference
Required
No

aria-labelledby

References the button that opens the menu

Values
ID reference
Required
Yes (or aria-label)

aria-label

Provides an accessible name for the menu

Values
String
Required
Yes (or aria-labelledby)

aria-disabled

Indicates the menu item is disabled

Values
true
Required
No

WAI-ARIA States

aria-expanded

Target Element
button
Values
true | false
Required
Yes
Change Trigger
Open/close menu

Keyboard Support

Button (Closed Menu)

KeyAction
Enter / SpaceOpen menu and focus first item
Down ArrowOpen menu and focus first item
Up ArrowOpen menu and focus last item
KeyAction
Down ArrowMove focus to next item (wraps to first)
Up ArrowMove focus to previous item (wraps to last)
HomeMove focus to first item
EndMove focus to last item
EscapeClose menu and return focus to button
TabClose menu and move focus to next focusable element
Enter / SpaceActivate focused item and close menu
Type characterType-ahead: focus item starting with typed character(s)
  • When closed, the menu uses both hidden and inert attributes to hide the menu from visual display, remove it from the accessibility tree, and prevent keyboard and mouse interaction with hidden items.

Focus Management

EventBehavior
Focused menu itemtabIndex="0"
Other menu itemstabIndex="-1"
Arrow key navigationWraps from last to first and vice versa
Disabled itemsSkipped during navigation
Menu closesFocus returns to button

References

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

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

Custom Events

Event Detail Description
item-select string Emitted when a menu item is selected

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)

Example Test Code

The following is the actual E2E test file (e2e/menu-button.spec.ts).

e2e/menu-button.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Menu Button Pattern
 *
 * A button that opens a menu containing menu items. The button has
 * aria-haspopup="menu" and controls a dropdown menu.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getMenuButton = (page: import('@playwright/test').Page) => {
  return page.getByRole('button', { name: /actions|file/i }).first();
};

const getMenu = (page: import('@playwright/test').Page) => {
  return page.getByRole('menu');
};

const getMenuItems = (page: import('@playwright/test').Page) => {
  return page.getByRole('menuitem');
};

const openMenu = async (page: import('@playwright/test').Page) => {
  const button = getMenuButton(page);
  await button.click();
  await getMenu(page).waitFor({ state: 'visible' });
  return button;
};

// Wait for hydration to complete
// This is necessary for frameworks like Svelte where event handlers are attached after hydration
const waitForHydration = async (page: import('@playwright/test').Page) => {
  const button = getMenuButton(page);
  // Wait for aria-controls to be set (basic check)
  await expect(button).toHaveAttribute('aria-controls', /.+/);
  // Poll until a click actually opens the menu (ensures handlers are attached)
  await expect
    .poll(async () => {
      await button.click();
      const isOpen = await getMenu(page).isVisible();
      if (isOpen) {
        await page.keyboard.press('Escape');
      }
      return isOpen;
    })
    .toBe(true);
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Menu Button (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      // Wait for hydration in frameworks that need it (Svelte)
      // This ensures event handlers are attached before tests run
      if (framework === 'svelte') {
        await waitForHydration(page);
      }
    });

    // ------------------------------------------
    // ๐Ÿ”ด High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('button has aria-haspopup="menu"', async ({ page }) => {
        const button = getMenuButton(page);
        await expect(button).toHaveAttribute('aria-haspopup', 'menu');
      });

      test('button has aria-expanded (false when closed)', async ({ page }) => {
        const button = getMenuButton(page);
        await expect(button).toHaveAttribute('aria-expanded', 'false');
      });

      test('button has aria-expanded (true when open)', async ({ page }) => {
        const button = await openMenu(page);
        await expect(button).toHaveAttribute('aria-expanded', 'true');
      });

      test('button has aria-controls referencing menu id', async ({ page }) => {
        const button = getMenuButton(page);

        // Wait for hydration - aria-controls may not be set immediately in Svelte
        await expect
          .poll(async () => {
            const id = await button.getAttribute('aria-controls');
            return id && id.length > 1 && !id.startsWith('-');
          })
          .toBe(true);

        const menuId = await button.getAttribute('aria-controls');
        expect(menuId).toBeTruthy();

        await openMenu(page);
        const menu = getMenu(page);
        await expect(menu).toHaveAttribute('id', menuId!);
      });

      test('menu has role="menu"', async ({ page }) => {
        await openMenu(page);
        const menu = getMenu(page);
        await expect(menu).toBeVisible();
        await expect(menu).toHaveRole('menu');
      });

      test('menu has accessible name via aria-labelledby', async ({ page }) => {
        await openMenu(page);
        const menu = getMenu(page);
        const labelledby = await menu.getAttribute('aria-labelledby');
        expect(labelledby).toBeTruthy();

        // Verify it references the button
        const button = getMenuButton(page);
        const buttonId = await button.getAttribute('id');
        expect(labelledby).toBe(buttonId);
      });

      test('menu items have role="menuitem"', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const count = await items.count();
        expect(count).toBeGreaterThan(0);

        for (let i = 0; i < count; i++) {
          await expect(items.nth(i)).toHaveRole('menuitem');
        }
      });

      test('disabled items have aria-disabled="true"', async ({ page }) => {
        // Use the File menu demo which must have disabled item (Export)
        const fileButton = page.getByRole('button', { name: /file/i });
        await expect(fileButton).toBeVisible();
        await fileButton.click();
        await getMenu(page).waitFor({ state: 'visible' });

        const disabledItem = page.getByRole('menuitem', { name: /export/i });
        await expect(disabledItem).toBeVisible();
        await expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
      });
    });

    // ------------------------------------------
    // ๐Ÿ”ด High Priority: Keyboard Interaction (Button)
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction (Button)', () => {
      test('Enter opens menu and focuses first item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('Enter');

        await expect(getMenu(page)).toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'true');

        // First item should be focused
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();
      });

      test('Space opens menu and focuses first item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('Space');

        await expect(getMenu(page)).toBeVisible();
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();
      });

      test('ArrowDown opens menu and focuses first item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('ArrowDown');

        await expect(getMenu(page)).toBeVisible();
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();
      });

      test('ArrowUp opens menu and focuses last enabled item', async ({ page }) => {
        const button = getMenuButton(page);
        await button.focus();
        await expect(button).toBeFocused();
        await button.press('ArrowUp');

        await expect(getMenu(page)).toBeVisible();

        // Find the last enabled item by checking focus
        const focusedItem = page.locator(':focus');
        await expect(focusedItem).toHaveRole('menuitem');
      });
    });

    // ------------------------------------------
    // ๐Ÿ”ด High Priority: Keyboard Interaction (Menu)
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction (Menu)', () => {
      test('ArrowDown moves to next item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('ArrowDown');

        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();
      });

      test('ArrowDown wraps from last to first', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();

        // Focus the first item, then use End to go to last
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('End');

        // Get the last item and verify it's focused
        const lastItem = items.last();
        await expect(lastItem).toBeFocused();

        const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
        await lastItem.press('ArrowDown');
        const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);

        // Should have wrapped to a different item (first)
        expect(focusedAfter).not.toBe(focusedBefore);
      });

      test('ArrowUp moves to previous item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        const secondItem = items.nth(1);

        // Navigate to second item using keyboard
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('ArrowDown');
        await expect(secondItem).toBeFocused();

        await secondItem.press('ArrowUp');

        await expect(firstItem).toBeFocused();
      });

      test('ArrowUp wraps from first to last', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
        await firstItem.press('ArrowUp');
        const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);

        // Should have wrapped to last item
        expect(focusedAfter).not.toBe(focusedBefore);
      });

      test('Home moves to first enabled item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        const secondItem = items.nth(1);

        // Navigate to second item using keyboard
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('ArrowDown');
        await expect(secondItem).toBeFocused();

        await secondItem.press('Home');

        await expect(firstItem).toBeFocused();
      });

      test('End moves to last enabled item', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('End');

        // Focus should be on last item (or last enabled item)
        const focusedItem = page.locator(':focus');
        await expect(focusedItem).toHaveRole('menuitem');

        // Should not be the first item anymore
        const focusedText = await focusedItem.textContent();
        const firstText = await firstItem.textContent();
        expect(focusedText).not.toBe(firstText);
      });

      test('Escape closes menu and returns focus to button', async ({ page }) => {
        const button = await openMenu(page);

        await page.keyboard.press('Escape');

        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'false');
        await expect(button).toBeFocused();
      });

      test('Enter activates item and closes menu', async ({ page }) => {
        const button = await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('Enter');

        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toBeFocused();
      });

      test('Space activates item and closes menu', async ({ page }) => {
        const button = await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();
        await expect(firstItem).toBeFocused();

        await firstItem.press('Space');

        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toBeFocused();
      });

      test('Tab closes menu', async ({ page }) => {
        await openMenu(page);

        await page.keyboard.press('Tab');

        await expect(getMenu(page)).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // ๐Ÿ”ด High Priority: Focus Management (Roving Tabindex)
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('focused item has tabindex="0"', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();
        await firstItem.focus();

        await expect(firstItem).toHaveAttribute('tabindex', '0');
      });

      test('non-focused items have tabindex="-1"', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const count = await items.count();

        if (count > 1) {
          const firstItem = items.first();
          await firstItem.focus();

          // Check second item has tabindex="-1"
          const secondItem = items.nth(1);
          await expect(secondItem).toHaveAttribute('tabindex', '-1');
        }
      });

      test('disabled items are skipped during navigation', async ({ page }) => {
        // Use the File menu demo which must have disabled items
        const fileButton = page.getByRole('button', { name: /file/i });
        await expect(fileButton).toBeVisible();
        await fileButton.click();
        await getMenu(page).waitFor({ state: 'visible' });

        // Navigate through all items
        const focusedTexts: string[] = [];
        // Get first focused item
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();

        for (let i = 0; i < 10; i++) {
          const focusedElement = page.locator(':focus');
          const text = await focusedElement.textContent();
          if (text && !focusedTexts.includes(text)) {
            focusedTexts.push(text);
          }
          await focusedElement.press('ArrowDown');
        }

        // "Export" (disabled) should not be in the focused list
        expect(focusedTexts).not.toContain('Export');
      });
    });

    // ------------------------------------------
    // ๐Ÿ”ด High Priority: Click Interaction
    // ------------------------------------------
    test.describe('APG: Click Interaction', () => {
      test('click button opens menu', async ({ page }) => {
        const button = getMenuButton(page);
        await button.click();

        await expect(getMenu(page)).toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'true');
      });

      test('click button again closes menu (toggle)', async ({ page }) => {
        const button = getMenuButton(page);
        await button.click();
        await expect(getMenu(page)).toBeVisible();

        await button.click();
        await expect(getMenu(page)).not.toBeVisible();
        await expect(button).toHaveAttribute('aria-expanded', 'false');
      });

      test('click menu item activates and closes menu', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();

        await firstItem.click();

        await expect(getMenu(page)).not.toBeVisible();
      });

      test('click outside menu closes it', async ({ page }) => {
        await openMenu(page);

        const menu = getMenu(page);
        const menuBox = await menu.boundingBox();
        expect(menuBox).not.toBeNull();

        const viewportSize = page.viewportSize();
        expect(viewportSize).not.toBeNull();

        // Find a safe position outside menu, handling edge cases
        const candidates = [
          // Above menu (if there's space)
          { x: menuBox!.x + menuBox!.width / 2, y: Math.max(1, menuBox!.y - 20) },
          // Left of menu (if there's space)
          { x: Math.max(1, menuBox!.x - 20), y: menuBox!.y + menuBox!.height / 2 },
          // Right of menu (if there's space)
          {
            x: Math.min(viewportSize!.width - 1, menuBox!.x + menuBox!.width + 20),
            y: menuBox!.y + menuBox!.height / 2,
          },
          // Below menu (if there's space)
          {
            x: menuBox!.x + menuBox!.width / 2,
            y: Math.min(viewportSize!.height - 1, menuBox!.y + menuBox!.height + 20),
          },
        ];

        // Find first candidate that's outside menu bounds
        const isOutsideMenu = (x: number, y: number) =>
          x < menuBox!.x ||
          x > menuBox!.x + menuBox!.width ||
          y < menuBox!.y ||
          y > menuBox!.y + menuBox!.height;

        const safePosition = candidates.find((pos) => isOutsideMenu(pos.x, pos.y));

        if (safePosition) {
          await page.mouse.click(safePosition.x, safePosition.y);
        } else {
          // Fallback: click at viewport corner (1,1)
          await page.mouse.click(1, 1);
        }

        await expect(getMenu(page)).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // ๐ŸŸก Medium Priority: Type-Ahead
    // ------------------------------------------
    test.describe('Type-Ahead', () => {
      test('single character focuses matching item', async ({ page }) => {
        await openMenu(page);

        // Wait for first item to be focused (menu opens with focus on first item)
        const firstItem = getMenuItems(page).first();
        await expect(firstItem).toBeFocused();

        // Type 'p' to find "Paste" - use element.press() for single key
        await firstItem.press('p');

        // Wait for focus to move to item starting with 'p'
        // Use trim() because some frameworks may include whitespace in textContent
        await expect
          .poll(async () => {
            const text = await page.evaluate(
              () => document.activeElement?.textContent?.trim().toLowerCase() || ''
            );
            return text.startsWith('p');
          })
          .toBe(true);
      });

      test('type-ahead wraps around', async ({ page }) => {
        await openMenu(page);
        const items = getMenuItems(page);
        const firstItem = items.first();

        // Navigate to last item using keyboard
        await firstItem.focus();
        await expect(firstItem).toBeFocused();
        await firstItem.press('End');
        const lastItem = items.last();
        await expect(lastItem).toBeFocused();

        // Type character that matches earlier item - use element.press() for single key
        await lastItem.press('c');

        // Wait for focus to wrap and find item starting with 'c'
        // Use trim() because some frameworks may include whitespace in textContent
        await expect
          .poll(async () => {
            const text = await page.evaluate(
              () => document.activeElement?.textContent?.trim().toLowerCase() || ''
            );
            return text.startsWith('c');
          })
          .toBe(true);
      });
    });

    // ------------------------------------------
    // ๐ŸŸข Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations (closed)', async ({ page }) => {
        const results = await new AxeBuilder({ page })
          .include('.apg-menu-button')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });

      test('has no axe-core violations (open)', async ({ page }) => {
        await openMenu(page);

        const results = await new AxeBuilder({ page })
          .include('.apg-menu-button')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Menu Button - Cross-framework Consistency', () => {
  test('all frameworks have menu button with aria-haspopup="menu"', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      const button = getMenuButton(page);
      await expect(button).toHaveAttribute('aria-haspopup', 'menu');
    }
  });

  test('all frameworks open menu on click', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      const button = getMenuButton(page);
      await button.click();

      const menu = getMenu(page);
      await expect(menu).toBeVisible();

      // Close for next iteration
      await page.keyboard.press('Escape');
    }
  });

  test('all frameworks close menu on Escape', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      await openMenu(page);
      await expect(getMenu(page)).toBeVisible();

      await page.keyboard.press('Escape');
      await expect(getMenu(page)).not.toBeVisible();
    }
  });

  test('all frameworks have consistent keyboard navigation', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/menu-button/${framework}/demo/`);
      await getMenuButton(page).waitFor();

      // Wait for hydration (especially needed for Svelte)
      if (framework === 'svelte') {
        await waitForHydration(page);
      }

      const button = getMenuButton(page);
      await button.focus();
      await expect(button).toBeFocused();
      await button.press('Enter');

      const menu = getMenu(page);
      await expect(menu).toBeVisible();

      // First item should be focused
      const firstItem = getMenuItems(page).first();
      await expect(firstItem).toBeFocused();

      // Arrow navigation
      await firstItem.press('ArrowDown');
      const secondItem = getMenuItems(page).nth(1);
      await expect(secondItem).toBeFocused();

      await page.keyboard.press('Escape');
    }
  });
});

Testing Tools

E2E tests: e2e/menu-button.spec.ts (opens in new tab)
See testing-strategy.md (opens in new tab) for full documentation.

MenuButton.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi, afterEach } from 'vitest';
import MenuButton from './MenuButton.vue';

afterEach(() => {
  vi.useRealTimers();
});

// ใƒ†ใ‚นใƒˆ็”จใฎใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใ‚ขใ‚คใƒ†ใƒ 
const defaultItems = [
  { id: 'cut', label: 'Cut' },
  { id: 'copy', label: 'Copy' },
  { id: 'paste', label: 'Paste' },
];

// disabled ใ‚ขใ‚คใƒ†ใƒ ใ‚’ๅซใ‚€ใƒ†ใ‚นใƒˆ็”จใ‚ขใ‚คใƒ†ใƒ 
const itemsWithDisabled = [
  { id: 'cut', label: 'Cut', disabled: true },
  { id: 'copy', label: 'Copy' },
  { id: 'paste', label: 'Paste' },
];

// ๅ…จใฆ disabled ใฎใƒ†ใ‚นใƒˆ็”จใ‚ขใ‚คใƒ†ใƒ 
const allDisabledItems = [
  { id: 'cut', label: 'Cut', disabled: true },
  { id: 'copy', label: 'Copy', disabled: true },
  { id: 'paste', label: 'Paste', disabled: true },
];

// ใ‚ฟใ‚คใƒ—ใ‚ขใƒ˜ใƒƒใƒ‰็”จใฎใƒ†ใ‚นใƒˆใ‚ขใ‚คใƒ†ใƒ 
const typeAheadItems = [
  { id: 'cut', label: 'Cut' },
  { id: 'copy', label: 'Copy' },
  { id: 'clear', label: 'Clear' },
  { id: 'edit', label: 'Edit' },
];

describe('MenuButton (Vue)', () => {
  // ๐Ÿ”ด High Priority: APG ใƒžใ‚ฆใ‚นๆ“ไฝœ
  describe('APG: ใƒžใ‚ฆใ‚นๆ“ไฝœ', () => {
    it('ใƒœใ‚ฟใƒณใ‚ฏใƒชใƒƒใ‚ฏใงใƒกใƒ‹ใƒฅใƒผใŒ้–‹ใ', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menu')).not.toHaveAttribute('hidden');
    });

    it('้–‹ใ„ใŸ็Šถๆ…‹ใงใƒœใ‚ฟใƒณใ‚ฏใƒชใƒƒใ‚ฏใงใƒกใƒ‹ใƒฅใƒผใŒ้–‰ใ˜ใ‚‹ (ใƒˆใ‚ฐใƒซ)', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('ใƒกใƒ‹ใƒฅใƒผใ‚ขใ‚คใƒ†ใƒ ใ‚ฏใƒชใƒƒใ‚ฏใงๅฎŸ่กŒใ€ใƒกใƒ‹ใƒฅใƒผใŒ้–‰ใ˜ใ‚‹', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', onItemSelect },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const menuItem = screen.getByRole('menuitem', { name: 'Copy' });
      await user.click(menuItem);

      expect(onItemSelect).toHaveBeenCalledWith('copy');
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('disabled ใ‚ขใ‚คใƒ†ใƒ ใ‚ฏใƒชใƒƒใ‚ฏใงใฏไฝ•ใ‚‚่ตทใ“ใ‚‰ใชใ„', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(MenuButton, {
        props: { items: itemsWithDisabled, label: 'Actions', onItemSelect },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });
      await user.click(disabledItem);

      expect(onItemSelect).not.toHaveBeenCalled();
      expect(button).toHaveAttribute('aria-expanded', 'true');
    });

    it('ใƒกใƒ‹ใƒฅใƒผๅค–ใ‚ฏใƒชใƒƒใ‚ฏใงใƒกใƒ‹ใƒฅใƒผใŒ้–‰ใ˜ใ‚‹', async () => {
      const user = userEvent.setup();
      render({
        components: { MenuButton },
        template: `
          <div>
            <MenuButton :items="items" label="Actions" />
            <button>Outside</button>
          </div>
        `,
        data: () => ({ items: defaultItems }),
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // ๐Ÿ”ด High Priority: APG ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ (ใƒœใ‚ฟใƒณ)
  describe('APG: ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ (ใƒœใ‚ฟใƒณ)', () => {
    it('Enter ใงใƒกใƒ‹ใƒฅใƒผใŒ้–‹ใใ€ๆœ€ๅˆใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซใƒ•ใ‚ฉใƒผใ‚ซใ‚น', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard('{Enter}');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
      });
    });

    it('Space ใงใƒกใƒ‹ใƒฅใƒผใŒ้–‹ใใ€ๆœ€ๅˆใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซใƒ•ใ‚ฉใƒผใ‚ซใ‚น', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard(' ');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
      });
    });

    it('ArrowDown ใงใƒกใƒ‹ใƒฅใƒผใŒ้–‹ใใ€ๆœ€ๅˆใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซใƒ•ใ‚ฉใƒผใ‚ซใ‚น', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard('{ArrowDown}');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
      });
    });

    it('ArrowUp ใงใƒกใƒ‹ใƒฅใƒผใŒ้–‹ใใ€ๆœ€ๅพŒใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซใƒ•ใ‚ฉใƒผใ‚ซใ‚น', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      button.focus();
      await user.keyboard('{ArrowUp}');

      expect(button).toHaveAttribute('aria-expanded', 'true');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
      });
    });
  });

  // ๐Ÿ”ด High Priority: APG ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ (ใƒกใƒ‹ใƒฅใƒผ)
  describe('APG: ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ (ใƒกใƒ‹ใƒฅใƒผ)', () => {
    it('ArrowDown ใงๆฌกใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซ็งปๅ‹•', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{ArrowDown}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
      });
    });

    it('ArrowDown ใงๆœ€ๅพŒใ‹ใ‚‰ๆœ€ๅˆใซใƒซใƒผใƒ—', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });

      const lastItem = screen.getByRole('menuitem', { name: 'Paste' });
      lastItem.focus();
      await user.keyboard('{ArrowDown}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
      });
    });

    it('ArrowUp ใงๅ‰ใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซ็งปๅ‹•', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });

      const secondItem = screen.getByRole('menuitem', { name: 'Copy' });
      secondItem.focus();
      await user.keyboard('{ArrowUp}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
      });
    });

    it('ArrowUp ใงๆœ€ๅˆใ‹ใ‚‰ๆœ€ๅพŒใซใƒซใƒผใƒ—', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{ArrowUp}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
      });
    });

    it('Home ใงๆœ€ๅˆใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซ็งปๅ‹•', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: itemsWithDisabled, label: 'Actions', defaultOpen: true },
      });

      const lastItem = screen.getByRole('menuitem', { name: 'Paste' });
      lastItem.focus();
      await user.keyboard('{Home}');

      // Cut is disabled, so focus should go to Copy (first available)
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
      });
    });

    it('End ใงๆœ€ๅพŒใฎๆœ‰ๅŠนใ‚ขใ‚คใƒ†ใƒ ใซ็งปๅ‹•', async () => {
      const user = userEvent.setup();
      const itemsWithLastDisabled = [
        { id: 'cut', label: 'Cut' },
        { id: 'copy', label: 'Copy' },
        { id: 'paste', label: 'Paste', disabled: true },
      ];
      render(MenuButton, {
        props: { items: itemsWithLastDisabled, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('{End}');

      // Paste is disabled, so focus should go to Copy (last available)
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
      });
    });

    it('Escape ใงใƒกใƒ‹ใƒฅใƒผใ‚’้–‰ใ˜ใ€ใƒœใ‚ฟใƒณใซใƒ•ใ‚ฉใƒผใ‚ซใ‚น', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Escape}');
      expect(button).toHaveAttribute('aria-expanded', 'false');
      expect(button).toHaveFocus();
    });

    it('Tab ใงใƒกใƒ‹ใƒฅใƒผใ‚’้–‰ใ˜ใ‚‹', async () => {
      const user = userEvent.setup();
      render({
        components: { MenuButton },
        template: `
          <div>
            <MenuButton :items="items" label="Actions" />
            <button>Next</button>
          </div>
        `,
        data: () => ({ items: defaultItems }),
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);
      expect(button).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Tab}');
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('Enter ใงใ‚ขใ‚คใƒ†ใƒ ใ‚’ๅฎŸ่กŒใ€ใƒกใƒ‹ใƒฅใƒผใ‚’้–‰ใ˜ใ‚‹', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true, onItemSelect },
      });

      const item = screen.getByRole('menuitem', { name: 'Copy' });
      item.focus();
      await user.keyboard('{Enter}');

      expect(onItemSelect).toHaveBeenCalledWith('copy');
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'false'
      );
    });

    it('Space ใงใ‚ขใ‚คใƒ†ใƒ ใ‚’ๅฎŸ่กŒใ€ใƒกใƒ‹ใƒฅใƒผใ‚’้–‰ใ˜ใ‚‹', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true, onItemSelect },
      });

      const item = screen.getByRole('menuitem', { name: 'Copy' });
      item.focus();
      await user.keyboard(' ');

      expect(onItemSelect).toHaveBeenCalledWith('copy');
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'false'
      );
    });
  });

  // ๐Ÿ”ด High Priority: ใ‚ฟใ‚คใƒ—ใ‚ขใƒ˜ใƒƒใƒ‰
  describe('APG: ใ‚ฟใ‚คใƒ—ใ‚ขใƒ˜ใƒƒใƒ‰', () => {
    it('ๆ–‡ๅญ—ใ‚ญใƒผใงใƒžใƒƒใƒใ™ใ‚‹ใ‚ขใ‚คใƒ†ใƒ ใซใƒ•ใ‚ฉใƒผใ‚ซใ‚น', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: typeAheadItems, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('e');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
      });
    });

    it('่ค‡ๆ•ฐๆ–‡ๅญ—ๅ…ฅๅŠ›ใงใƒžใƒƒใƒ', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: typeAheadItems, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('cl');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
      });
    });

    it('ๅŒใ˜ๆ–‡ๅญ—้€ฃๆ‰“ใงใƒžใƒƒใƒใ‚’ใ‚ตใ‚คใ‚ฏใƒซ', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: typeAheadItems, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();

      await user.keyboard('c');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
      });

      await user.keyboard('c');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
      });

      await user.keyboard('c');
      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
      });
    });

    it('ใƒžใƒƒใƒใชใ—ใฎๅ ดๅˆใƒ•ใ‚ฉใƒผใ‚ซใ‚นๅค‰ๆ›ดใชใ—', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });

      const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
      firstItem.focus();
      await user.keyboard('z');

      expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
    });
  });

  // ๐Ÿ”ด High Priority: APG ARIA ๅฑžๆ€ง
  describe('APG: ARIA ๅฑžๆ€ง', () => {
    it('ใƒœใ‚ฟใƒณใŒ aria-haspopup="menu" ใ‚’ๆŒใค', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-haspopup',
        'menu'
      );
    });

    it('้–‰ใ˜ใŸ็Šถๆ…‹ใง aria-expanded="false"', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'false'
      );
    });

    it('้–‹ใ„ใŸ็Šถๆ…‹ใง aria-expanded="true"', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });
      expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
        'aria-expanded',
        'true'
      );
    });

    it('ใƒœใ‚ฟใƒณใŒ aria-controls ใงใƒกใƒ‹ใƒฅใƒผใ‚’ๅ‚็…ง', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });
      const button = screen.getByRole('button', { name: 'Actions' });
      const menuId = button.getAttribute('aria-controls');

      expect(menuId).toBeTruthy();
      expect(document.getElementById(menuId!)).toHaveAttribute('role', 'menu');
    });

    it('ใƒกใƒ‹ใƒฅใƒผใŒ role="menu" ใ‚’ๆŒใค', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });
      expect(screen.getByRole('menu')).toBeInTheDocument();
    });

    it('ใƒกใƒ‹ใƒฅใƒผใŒ aria-labelledby ใงใƒœใ‚ฟใƒณใ‚’ๅ‚็…ง', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });
      const menu = screen.getByRole('menu');
      const labelledbyId = menu.getAttribute('aria-labelledby');

      expect(labelledbyId).toBeTruthy();
      expect(document.getElementById(labelledbyId!)).toHaveAttribute('aria-haspopup', 'menu');
    });

    it('ใ‚ขใ‚คใƒ†ใƒ ใŒ role="menuitem" ใ‚’ๆŒใค', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });
      const menuItems = screen.getAllByRole('menuitem');
      expect(menuItems).toHaveLength(3);
    });

    it('disabled ใ‚ขใ‚คใƒ†ใƒ ใŒ aria-disabled="true"', () => {
      render(MenuButton, {
        props: { items: itemsWithDisabled, label: 'Actions', defaultOpen: true },
      });
      const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });
      expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // ๐Ÿ”ด High Priority: ใƒ•ใ‚ฉใƒผใ‚ซใ‚น็ฎก็†
  describe('APG: ใƒ•ใ‚ฉใƒผใ‚ซใ‚น็ฎก็†', () => {
    it('ใƒ•ใ‚ฉใƒผใ‚ซใ‚นใ‚ขใ‚คใƒ†ใƒ ใŒ tabindex="0"', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      await vi.waitFor(() => {
        const focusedItem = screen.getByRole('menuitem', { name: 'Cut' });
        expect(focusedItem).toHaveAttribute('tabindex', '0');
      });
    });

    it('ไป–ใ‚ขใ‚คใƒ†ใƒ ใŒ tabindex="-1"', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      await vi.waitFor(() => {
        const otherItems = screen
          .getAllByRole('menuitem')
          .filter((item) => item.textContent !== 'Cut');
        otherItems.forEach((item) => {
          expect(item).toHaveAttribute('tabindex', '-1');
        });
      });
    });

    it('disabled ใ‚ขใ‚คใƒ†ใƒ ใŒ tabindex="-1"', () => {
      render(MenuButton, {
        props: { items: itemsWithDisabled, label: 'Actions', defaultOpen: true },
      });
      const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });
      expect(disabledItem).toHaveAttribute('tabindex', '-1');
    });

    it('ใƒกใƒ‹ใƒฅใƒผ้–‰ใ˜ใงใƒ•ใ‚ฉใƒผใ‚ซใ‚นใŒใƒœใ‚ฟใƒณใซๆˆปใ‚‹', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const item = screen.getByRole('menuitem', { name: 'Copy' });
      await user.click(item);

      expect(button).toHaveFocus();
    });

    it('้–‰ใ˜ใŸ็Šถๆ…‹ใงใƒกใƒ‹ใƒฅใƒผใŒ inert + hidden', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });
      const menu = screen.getByRole('menu', { hidden: true });

      expect(menu).toHaveAttribute('hidden');
      expect(menu).toHaveAttribute('inert');
    });
  });

  // ๐Ÿ”ด High Priority: ใ‚จใƒƒใ‚ธใ‚ฑใƒผใ‚น
  describe('ใ‚จใƒƒใ‚ธใ‚ฑใƒผใ‚น', () => {
    it('ๅ…จใ‚ขใ‚คใƒ†ใƒ  disabled ใฎๅ ดๅˆใ€ใƒกใƒ‹ใƒฅใƒผใฏ้–‹ใใŒใƒ•ใ‚ฉใƒผใ‚ซใ‚นใฏใƒœใ‚ฟใƒณใซ็•™ใพใ‚‹', async () => {
      const user = userEvent.setup();
      render(MenuButton, {
        props: { items: allDisabledItems, label: 'Actions' },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(button).toHaveFocus();
    });

    it('็ฉบใฎ items ้…ๅˆ—ใงใ‚‚ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใ—ใชใ„', () => {
      expect(() => {
        render(MenuButton, {
          props: { items: [], label: 'Actions' },
        });
      }).not.toThrow();

      expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
    });

    it('่ค‡ๆ•ฐใ‚คใƒณใ‚นใ‚ฟใƒณใ‚นใง ID ใŒ่ก็ชใ—ใชใ„', () => {
      render({
        components: { MenuButton },
        template: `
          <div>
            <MenuButton :items="items" label="Actions 1" />
            <MenuButton :items="items" label="Actions 2" />
          </div>
        `,
        data: () => ({ items: defaultItems }),
      });

      const button1 = screen.getByRole('button', { name: 'Actions 1' });
      const button2 = screen.getByRole('button', { name: 'Actions 2' });

      const menuId1 = button1.getAttribute('aria-controls');
      const menuId2 = button2.getAttribute('aria-controls');

      expect(menuId1).not.toBe(menuId2);
    });
  });

  // ๐ŸŸก Medium Priority: ใ‚ขใ‚ฏใ‚ปใ‚ทใƒ“ใƒชใƒ†ใ‚ฃๆคœ่จผ
  describe('ใ‚ขใ‚ฏใ‚ปใ‚ทใƒ“ใƒชใƒ†ใ‚ฃ', () => {
    it('้–‰ใ˜ใŸ็Šถๆ…‹ใง axe ้•ๅใชใ—', async () => {
      const { container } = render(MenuButton, {
        props: { items: defaultItems, label: 'Actions' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('้–‹ใ„ใŸ็Šถๆ…‹ใง axe ้•ๅใชใ—', async () => {
      const { container } = render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // ๐ŸŸข Low Priority: Props / ๅ‹•ไฝœ
  describe('Props', () => {
    it('defaultOpen=true ใงๅˆๆœŸ่กจ็คบ', () => {
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', defaultOpen: true },
      });
      const button = screen.getByRole('button', { name: 'Actions' });

      expect(button).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menu')).not.toHaveAttribute('hidden');
    });

    it('className ใŒใ‚ณใƒณใƒ†ใƒŠใซ้ฉ็”จ', () => {
      const { container } = render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', className: 'custom-class' },
      });

      expect(container.querySelector('.apg-menu-button')).toHaveClass('custom-class');
    });

    it('onItemSelect ใŒๆญฃใ—ใ„ id ใงๅ‘ผใฐใ‚Œใ‚‹', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(MenuButton, {
        props: { items: defaultItems, label: 'Actions', onItemSelect },
      });

      const button = screen.getByRole('button', { name: 'Actions' });
      await user.click(button);

      const item = screen.getByRole('menuitem', { name: 'Paste' });
      await user.click(item);

      expect(onItemSelect).toHaveBeenCalledWith('paste');
      expect(onItemSelect).toHaveBeenCalledTimes(1);
    });
  });
});

Resources