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

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

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

Event Behavior
Focused menu item tabIndex="0"
Other menu items tabIndex="-1"
Arrow key navigation Wraps from last to first and vice versa
Disabled items Skipped during navigation
Menu closes Focus returns to button

References

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) => {
          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

PropTypeDefaultDescription
itemsMenuItem[]requiredArray of menu items
labelstringrequiredButton label text
defaultOpenbooleanfalseWhether menu is initially open
classstring''Additional CSS class for the container

Custom Events

EventDetailDescription
itemselect{ itemId: string }Dispatched 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

TestDescription
Button clickOpens menu on button click
ToggleClicking button again closes menu
Item clickClicking menu item activates and closes menu
Disabled item clickClicking disabled item does nothing
Click outsideClicking outside menu closes it

High Priority: APG Keyboard Interaction (Button)

TestDescription
EnterOpens menu, focuses first enabled item
SpaceOpens menu, focuses first enabled item
ArrowDownOpens menu, focuses first enabled item
ArrowUpOpens menu, focuses last enabled item

High Priority: APG Keyboard Interaction (Menu)

TestDescription
ArrowDownMoves focus to next enabled item (wraps)
ArrowUpMoves focus to previous enabled item (wraps)
HomeMoves focus to first enabled item
EndMoves focus to last enabled item
EscapeCloses menu, returns focus to button
TabCloses menu, moves focus out
Enter/SpaceActivates item and closes menu
Disabled skipSkips disabled items during navigation

High Priority: Type-Ahead Search

TestDescription
Single characterFocuses first item starting with typed character
Multiple charactersTyped within 500ms form prefix search string
Wrap aroundSearch wraps from end to beginning
Buffer resetBuffer resets after 500ms of inactivity

High Priority: APG ARIA Attributes

TestDescription
aria-haspopupButton has aria-haspopup="menu"
aria-expandedButton reflects open state (true/false)
aria-controlsButton references menu ID
role="menu"Menu container has menu role
role="menuitem"Each item has menuitem role
aria-labelledbyMenu references button for accessible name
aria-disabledDisabled items have aria-disabled="true"

High Priority: Focus Management (Roving Tabindex)

TestDescription
tabIndex=0Focused item has tabIndex=0
tabIndex=-1Non-focused items have tabIndex=-1
Initial focusFirst enabled item receives focus when menu opens
Focus returnFocus returns to button when menu closes

Medium Priority: Accessibility

TestDescription
axe violationsNo 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;
};

const getAllDisabledMenuButton = (page: import('@playwright/test').Page) => {
  return page.getByRole('button', { name: 'Tools' });
};

const getEmptyMenuButton = (page: import('@playwright/test').Page) => {
  return page.getByRole('button', { name: 'Bookmarks' });
};

// Wait for hydration to complete: Astro removes the `ssr` attribute from
// each <astro-island> once its component has hydrated.
const waitForHydration = async (page: import('@playwright/test').Page) => {
  await expect(page.locator('astro-island[ssr]')).toHaveCount(0);
};

// ============================================
// 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();
      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 is named by its own button when several menu buttons are on the page', async ({
        page,
      }) => {
        await page.getByRole('button', { name: /file/i }).click();

        await expect(page.getByRole('menu', { name: 'File' })).toBeVisible();
      });

      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();
        await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Menus without enabled items
    // ------------------------------------------
    test.describe('Menus without enabled items', () => {
      test('Enter opens a menu whose items are all disabled and keeps focus on the button', async ({
        page,
      }) => {
        const button = getAllDisabledMenuButton(page);
        await button.focus();
        await button.press('Enter');

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

      test('items of a menu whose items are all disabled keep tabindex="-1" when opened', async ({
        page,
      }) => {
        const button = getAllDisabledMenuButton(page);
        await button.click();

        await expect(page.getByRole('menuitem', { name: 'Undo' })).toHaveAttribute(
          'tabindex',
          '-1'
        );
        await expect(page.getByRole('menuitem', { name: 'Redo' })).toHaveAttribute(
          'tabindex',
          '-1'
        );
      });

      test('Enter opens an empty menu and keeps focus on the button', async ({ page }) => {
        const button = getEmptyMenuButton(page);
        await button.focus();
        await button.press('Enter');

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

    // ------------------------------------------
    // 🔴 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 on a disabled item keeps the menu open', async ({ page }) => {
        const fileButton = page.getByRole('button', { name: /file/i });
        await fileButton.click();
        await getMenu(page).waitFor({ state: 'visible' });

        await page.getByRole('menuitem', { name: 'Export' }).click({ force: true });

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

      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);
      });

      test('repeated same character cycles through matching items', async ({ page }) => {
        await openMenu(page);
        const cutItem = page.getByRole('menuitem', { name: 'Cut' });
        const copyItem = page.getByRole('menuitem', { name: 'Copy' });
        await expect(cutItem).toBeFocused();

        await cutItem.press('c');
        await expect(copyItem).toBeFocused();

        await copyItem.press('c');
        await expect(cutItem).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🟢 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([]);
      });
    });
  });
}

// ============================================
// Astro-specific: itemselect CustomEvent
// ============================================

test.describe('Menu Button (astro) - itemselect event', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('patterns/menu-button/astro/demo/');
    await getMenuButton(page).waitFor();
    await page.evaluate(() => {
      const selected: string[] = [];
      (window as unknown as { __selectedItems: string[] }).__selectedItems = selected;
      document.addEventListener('itemselect', (e) => {
        selected.push((e as CustomEvent<{ itemId: string }>).detail.itemId);
      });
    });
  });

  test('dispatches itemselect with the item id on item click', async ({ page }) => {
    await openMenu(page);
    await page.getByRole('menuitem', { name: 'Copy' }).click();

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).toEqual(['copy']);
  });

  test('dispatches itemselect with the item id on Enter', async ({ page }) => {
    await openMenu(page);
    await page.getByRole('menuitem', { name: 'Cut' }).press('Enter');

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).toEqual(['cut']);
  });

  test('dispatches itemselect with the item id on Space', async ({ page }) => {
    await openMenu(page);
    await page.getByRole('menuitem', { name: 'Cut' }).press('Space');

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).toEqual(['cut']);
  });

  test('does not dispatch itemselect on disabled item click', async ({ page }) => {
    await page.getByRole('button', { name: /file/i }).click();
    await getMenu(page).waitFor({ state: 'visible' });
    await page.getByRole('menuitem', { name: 'Export' }).click({ force: true });

    const selected = await page.evaluate(
      () => (window as unknown as { __selectedItems: string[] }).__selectedItems
    );
    expect(selected).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();
      await waitForHydration(page);

      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();
      await waitForHydration(page);

      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 waitForHydration(page);

      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();
      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 the Testing Strategy guide for details.

MenuButton.test.astro.ts
/**
 * MenuButton Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of MenuButton.astro.
 * Web Component behavior (opening/closing, keyboard navigation, roving
 * tabindex updates, type-ahead, click outside and the itemselect event)
 * is covered by E2E tests in e2e/menu-button.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import MenuButton from './MenuButton.astro';

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

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

  async function renderMenuButton(props: Record<string, unknown>): Promise<Document> {
    const html = await container.renderToString(MenuButton, { props });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-menu-button custom element wrapper', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('apg-menu-button')).not.toBeNull();
    });

    it('renders the trigger as a button with type="button" and the label as its text', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const button = doc.querySelector('button');
      expect(button?.getAttribute('type')).toBe('button');
      expect(button?.textContent?.trim()).toBe('Actions');
    });

    it('renders a list with role="menu"', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('ul[role="menu"]')).not.toBeNull();
    });

    it('renders each item as role="menuitem" with its label', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
          { id: 'paste', label: 'Paste' },
        ],
        label: 'Actions',
      });
      const labels = Array.from(doc.querySelectorAll('[role="menuitem"]')).map((item) =>
        item.textContent?.trim()
      );
      expect(labels).toEqual(['Cut', 'Copy', 'Paste']);
    });

    it('renders an empty menu when items is empty', async () => {
      const doc = await renderMenuButton({ items: [], label: 'Actions' });
      expect(doc.querySelector('[role="menu"]')).not.toBeNull();
      expect(doc.querySelectorAll('[role="menuitem"]')).toHaveLength(0);
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has aria-haspopup="menu" on the button', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('button')?.getAttribute('aria-haspopup')).toBe('menu');
    });

    it('has aria-expanded="false" on the button by default', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('button')?.getAttribute('aria-expanded')).toBe('false');
    });

    it('has aria-expanded="true" on the button when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        defaultOpen: true,
      });
      expect(doc.querySelector('button')?.getAttribute('aria-expanded')).toBe('true');
    });

    it('has aria-controls on the button referencing the menu id', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const menuId = doc.querySelector('button')?.getAttribute('aria-controls') ?? '';
      expect(doc.getElementById(menuId)?.getAttribute('role')).toBe('menu');
    });

    it('has aria-labelledby on the menu referencing the button', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const buttonId = doc.querySelector('[role="menu"]')?.getAttribute('aria-labelledby') ?? '';
      expect(doc.getElementById(buttonId)?.textContent?.trim()).toBe('Actions');
    });

    it('has aria-disabled="true" only on disabled items', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy', disabled: true },
          { id: 'paste', label: 'Paste' },
        ],
        label: 'Actions',
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].hasAttribute('aria-disabled')).toBe(false);
      expect(items[1].getAttribute('aria-disabled')).toBe('true');
      expect(items[2].hasAttribute('aria-disabled')).toBe(false);
    });
  });

  // 🔴 High Priority: Menu Visibility
  describe('Menu Visibility', () => {
    it('renders the menu hidden and inert by default', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      const menu = doc.querySelector('[role="menu"]');
      expect(menu?.hasAttribute('hidden')).toBe(true);
      expect(menu?.hasAttribute('inert')).toBe(true);
    });

    it('renders the menu visible and not inert when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        defaultOpen: true,
      });
      const menu = doc.querySelector('[role="menu"]');
      expect(menu?.hasAttribute('hidden')).toBe(false);
      expect(menu?.hasAttribute('inert')).toBe(false);
    });
  });

  // 🔴 High Priority: Focus Management (initial roving tabindex)
  describe('Focus Management', () => {
    it('has tabindex="-1" on all items when the menu is closed', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('-1');
      expect(items[1].getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="0" only on the first item when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
        defaultOpen: true,
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('0');
      expect(items[1].getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="0" on the first enabled item when the first item is disabled and defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut', disabled: true },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
        defaultOpen: true,
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('-1');
      expect(items[1].getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" on all items when all items are disabled and defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut', disabled: true },
          { id: 'copy', label: 'Copy', disabled: true },
        ],
        label: 'Actions',
        defaultOpen: true,
      });
      const items = doc.querySelectorAll('[role="menuitem"]');
      expect(items[0].getAttribute('tabindex')).toBe('-1');
      expect(items[1].getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟡 Medium Priority: Web Component Hydration Data
  describe('Web Component Data Attributes', () => {
    it('sets data-default-open="true" when defaultOpen is true', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        defaultOpen: true,
      });
      expect(doc.querySelector('apg-menu-button')?.getAttribute('data-default-open')).toBe('true');
    });

    it('does not set data-default-open when defaultOpen is false', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
      });
      expect(doc.querySelector('apg-menu-button')?.hasAttribute('data-default-open')).toBe(false);
    });

    it('sets data-item-id on each item', async () => {
      const doc = await renderMenuButton({
        items: [
          { id: 'cut', label: 'Cut' },
          { id: 'copy', label: 'Copy' },
        ],
        label: 'Actions',
      });
      const ids = Array.from(doc.querySelectorAll('[role="menuitem"]')).map((item) =>
        item.getAttribute('data-item-id')
      );
      expect(ids).toEqual(['cut', 'copy']);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('applies a custom class to the container', async () => {
      const doc = await renderMenuButton({
        items: [{ id: 'cut', label: 'Cut' }],
        label: 'Actions',
        class: 'custom-class',
      });
      expect(doc.querySelector('.apg-menu-button')?.classList.contains('custom-class')).toBe(true);
    });

    it('generates unique ids for each instance', async () => {
      const first = await renderMenuButton({ items: [{ id: 'cut', label: 'Cut' }], label: 'A' });
      const second = await renderMenuButton({ items: [{ id: 'cut', label: 'Cut' }], label: 'B' });
      expect(first.querySelector('[role="menu"]')?.id).not.toBe(
        second.querySelector('[role="menu"]')?.id
      );
    });
  });
});

Resources