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
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 |
Menu (Open)
| Key | Action |
|---|---|
| Down Arrow | Move focus to next item (wraps to first) |
| Up Arrow | Move focus to previous item (wraps to last) |
| Home | Move focus to first item |
| End | Move focus to last item |
| Escape | Close menu and return focus to button |
| Tab | Close menu and move focus to next focusable element |
| Enter / Space | Activate focused item and close menu |
| Type character | Type-ahead: focus item starting with typed character(s) |
- 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
import type { ButtonHTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
}
export interface MenuButtonProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-haspopup' | 'aria-expanded' | 'aria-controls' | 'type'
> {
items: MenuItem[];
label: string;
onItemSelect?: (itemId: string) => void;
defaultOpen?: boolean;
}
export function MenuButton({
items,
label,
onItemSelect,
defaultOpen = false,
className = '',
...restProps
}: MenuButtonProps): ReactElement {
const instanceId = useId();
const buttonId = `${instanceId}-button`;
const menuId = `${instanceId}-menu`;
const [isOpen, setIsOpen] = useState(defaultOpen);
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuItemRefs = useRef<Map<string, HTMLLIElement>>(new Map());
const typeAheadBuffer = useRef<string>('');
const typeAheadTimeoutId = useRef<number | null>(null);
const typeAheadTimeout = 500;
// Get available (non-disabled) items
const availableItems = useMemo(() => items.filter((item) => !item.disabled), [items]);
// Map of item id to index in availableItems for O(1) lookup
const availableIndexMap = useMemo(() => {
const map = new Map<string, number>();
availableItems.forEach(({ id }, index) => map.set(id, index));
return map;
}, [availableItems]);
const closeMenu = useCallback(() => {
setIsOpen(false);
setFocusedIndex(-1);
// Clear type-ahead state to prevent stale buffer on reopen
typeAheadBuffer.current = '';
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
typeAheadTimeoutId.current = null;
}
}, []);
const openMenu = useCallback(
(focusPosition: 'first' | 'last') => {
if (availableItems.length === 0) {
// All items disabled, open menu but keep focus on button
setIsOpen(true);
return;
}
setIsOpen(true);
const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
setFocusedIndex(targetIndex);
},
[availableItems]
);
// Focus menu item when focusedIndex changes and menu is open
useEffect(() => {
if (!isOpen || focusedIndex < 0) return;
const targetItem = availableItems[focusedIndex];
if (targetItem) {
menuItemRefs.current.get(targetItem.id)?.focus();
}
}, [isOpen, focusedIndex, availableItems]);
const toggleMenu = useCallback(() => {
if (isOpen) {
closeMenu();
} else {
openMenu('first');
}
}, [isOpen, closeMenu, openMenu]);
const handleItemClick = useCallback(
(item: MenuItem) => {
if (item.disabled) return;
onItemSelect?.(item.id);
closeMenu();
buttonRef.current?.focus();
},
[onItemSelect, closeMenu]
);
const handleButtonKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
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;
}
},
[openMenu]
);
const handleTypeAhead = useCallback(
(char: string) => {
if (availableItems.length === 0) return;
// Clear existing timeout
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
}
// Add character to buffer
typeAheadBuffer.current += char.toLowerCase();
const buffer = typeAheadBuffer.current;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
// For same char repeated or single char, start from next item to cycle through matches
// For multi-char string, start from current to allow refining the search
let startIndex: number;
let searchStr: string;
if (isSameChar) {
// Same character repeated: cycle through matches
typeAheadBuffer.current = buffer[0];
searchStr = buffer[0];
startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
} else if (buffer.length === 1) {
// Single character: start from next item to find next match
searchStr = buffer;
startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
} else {
// Multi-character: refine search from current position
searchStr = buffer;
startIndex = focusedIndex >= 0 ? focusedIndex : 0;
}
// Search from start index, wrapping around
for (let i = 0; i < availableItems.length; i++) {
const index = (startIndex + i) % availableItems.length;
const option = availableItems[index];
if (option.label.toLowerCase().startsWith(searchStr)) {
setFocusedIndex(index);
break;
}
}
// Set timeout to clear buffer
typeAheadTimeoutId.current = window.setTimeout(() => {
typeAheadBuffer.current = '';
typeAheadTimeoutId.current = null;
}, typeAheadTimeout);
},
[availableItems, focusedIndex]
);
const handleMenuKeyDown = useCallback(
(event: KeyboardEvent<HTMLLIElement>, item: MenuItem) => {
// Guard: no available items to navigate
if (availableItems.length === 0) {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
buttonRef.current?.focus();
}
return;
}
const currentIndex = availableIndexMap.get(item.id) ?? -1;
// Guard: disabled item received focus (e.g., programmatic focus)
if (currentIndex < 0) {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
buttonRef.current?.focus();
}
return;
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = (currentIndex + 1) % availableItems.length;
setFocusedIndex(nextIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = currentIndex === 0 ? availableItems.length - 1 : currentIndex - 1;
setFocusedIndex(prevIndex);
break;
}
case 'Home': {
event.preventDefault();
setFocusedIndex(0);
break;
}
case 'End': {
event.preventDefault();
setFocusedIndex(availableItems.length - 1);
break;
}
case 'Escape': {
event.preventDefault();
closeMenu();
buttonRef.current?.focus();
break;
}
case 'Tab': {
// Let the browser handle Tab, but close the menu
closeMenu();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
if (!item.disabled) {
onItemSelect?.(item.id);
closeMenu();
buttonRef.current?.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);
}
}
}
},
[availableIndexMap, availableItems, closeMenu, onItemSelect, handleTypeAhead]
);
// Cleanup type-ahead timeout on unmount
useEffect(() => {
return () => {
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
}
};
}, []);
// Click outside to close
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
event.target instanceof Node &&
!containerRef.current.contains(event.target)
) {
closeMenu();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, closeMenu]);
return (
<div ref={containerRef} className={`apg-menu-button ${className}`.trim()}>
<button
ref={buttonRef}
id={buttonId}
type="button"
className="apg-menu-button-trigger"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={menuId}
onClick={toggleMenu}
onKeyDown={handleButtonKeyDown}
{...restProps}
>
{label}
</button>
<ul
id={menuId}
role="menu"
aria-labelledby={buttonId}
className="apg-menu-button-menu"
hidden={!isOpen || undefined}
inert={!isOpen || undefined}
>
{items.map((item) => {
const availableIndex = availableIndexMap.get(item.id) ?? -1;
const isFocused = availableIndex === focusedIndex;
const tabIndex = item.disabled ? -1 : isFocused ? 0 : -1;
return (
<li
key={item.id}
ref={(el) => {
if (el) {
menuItemRefs.current.set(item.id, el);
} else {
menuItemRefs.current.delete(item.id);
}
}}
role="menuitem"
tabIndex={tabIndex}
aria-disabled={item.disabled || undefined}
className="apg-menu-button-item"
onClick={() => handleItemClick(item)}
onKeyDown={(e) => handleMenuKeyDown(e, item)}
onFocus={() => {
if (!item.disabled && availableIndex >= 0) {
setFocusedIndex(availableIndex);
}
}}
>
{item.label}
</li>
);
})}
</ul>
</div>
);
}
export default MenuButton; Usage
import { MenuButton } from './MenuButton';
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"
onItemSelect={(id) => console.log('Selected:', id)}
/>
// With default open state
<MenuButton
items={items}
label="Actions"
defaultOpen
onItemSelect={(id) => console.log('Selected:', id)}
/> 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 |
onItemSelect | (id: string) => void | - | Callback when an item is selected |
className | string | '' | Additional CSS class for the container |
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).
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
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - E2E testing (136 cross-framework tests)
- @axe-core/playwright (opens in new tab) - Automated accessibility testing
E2E tests: e2e/menu-button.spec.ts (opens in new tab)
See
testing-strategy.md
(opens in new tab) for full documentation.
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MenuButton, type MenuItem } from './MenuButton';
afterEach(() => {
vi.useRealTimers();
});
// Default test items
const defaultItems: MenuItem[] = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
];
// Test items with disabled item
const itemsWithDisabled: MenuItem[] = [
{ id: 'cut', label: 'Cut', disabled: true },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
];
// Test items with all disabled
const allDisabledItems: MenuItem[] = [
{ id: 'cut', label: 'Cut', disabled: true },
{ id: 'copy', label: 'Copy', disabled: true },
{ id: 'paste', label: 'Paste', disabled: true },
];
// Test items for type-ahead
const typeAheadItems: MenuItem[] = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'clear', label: 'Clear' },
{ id: 'edit', label: 'Edit' },
];
describe('MenuButton', () => {
// 🔴 High Priority: APG Mouse Operations
describe('APG: Mouse Operations', () => {
it('opens menu on button click', async () => {
const user = userEvent.setup();
render(<MenuButton 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('closes menu on button click when open (toggle)', async () => {
const user = userEvent.setup();
render(<MenuButton 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('executes and closes menu on menu item click', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(<MenuButton items={defaultItems} label="Actions" onItemSelect={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('does nothing on disabled item click', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(<MenuButton items={itemsWithDisabled} label="Actions" onItemSelect={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('closes menu on click outside', async () => {
const user = userEvent.setup();
render(
<div>
<MenuButton items={defaultItems} label="Actions" />
<button>Outside</button>
</div>
);
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 Keyboard Interaction (Button)
describe('APG: Keyboard Interaction (Button)', () => {
it('opens menu and focuses first enabled item with Enter', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" />);
const button = screen.getByRole('button', { name: 'Actions' });
button.focus();
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('opens menu and focuses first enabled item with Space', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" />);
const button = screen.getByRole('button', { name: 'Actions' });
button.focus();
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('opens menu and focuses first enabled item with ArrowDown', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" />);
const button = screen.getByRole('button', { name: 'Actions' });
button.focus();
await user.keyboard('{ArrowDown}');
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('opens menu and focuses last enabled item with ArrowUp', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" />);
const button = screen.getByRole('button', { name: 'Actions' });
button.focus();
await user.keyboard('{ArrowUp}');
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
});
});
// 🔴 High Priority: APG Keyboard Interaction (Menu)
describe('APG: Keyboard Interaction (Menu)', () => {
it('moves to next enabled item with ArrowDown', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
});
it('loops from last to first with ArrowDown', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const lastItem = screen.getByRole('menuitem', { name: 'Paste' });
lastItem.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('moves to previous enabled item with ArrowUp', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const secondItem = screen.getByRole('menuitem', { name: 'Copy' });
secondItem.focus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('loops from first to last with ArrowUp', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
});
it('moves to first enabled item with Home (skips disabled)', async () => {
const user = userEvent.setup();
render(<MenuButton items={itemsWithDisabled} label="Actions" defaultOpen />);
const lastItem = screen.getByRole('menuitem', { name: 'Paste' });
lastItem.focus();
await user.keyboard('{Home}');
// Cut is disabled, so focus should go to Copy
expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
});
it('moves to last enabled item with End (skips disabled)', async () => {
const user = userEvent.setup();
const itemsWithLastDisabled: MenuItem[] = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste', disabled: true },
];
render(<MenuButton items={itemsWithLastDisabled} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('{End}');
// Paste is disabled, so focus should go to Copy
expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
});
it('skips disabled items with ArrowDown/Up', async () => {
const user = userEvent.setup();
const itemsWithMiddleDisabled: MenuItem[] = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy', disabled: true },
{ id: 'paste', label: 'Paste' },
];
render(<MenuButton items={itemsWithMiddleDisabled} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('{ArrowDown}');
// Copy is disabled, so focus should skip to Paste
expect(screen.getByRole('menuitem', { name: 'Paste' })).toHaveFocus();
});
it('closes menu and focuses button with Escape', async () => {
const user = userEvent.setup();
render(<MenuButton 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('closes menu and moves focus with Tab', async () => {
const user = userEvent.setup();
render(
<div>
<MenuButton items={defaultItems} label="Actions" />
<button>Next</button>
</div>
);
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('executes item and closes menu with Enter', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<MenuButton items={defaultItems} label="Actions" onItemSelect={onItemSelect} defaultOpen />
);
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('executes item and closes menu with Space (prevents scroll)', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<MenuButton items={defaultItems} label="Actions" onItemSelect={onItemSelect} defaultOpen />
);
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: Type-ahead
describe('APG: Type-ahead', () => {
it('focuses matching item with character key', async () => {
const user = userEvent.setup();
render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('e');
expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
});
it('matches with multiple characters (e.g., "cl" → "Clear")', async () => {
const user = userEvent.setup();
render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('cl');
expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
});
it('cycles through matches with repeated same character', async () => {
const user = userEvent.setup();
render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
// First 'c' -> Cut (already focused, or next match)
await user.keyboard('c');
// Items starting with 'c': Cut, Copy, Clear
// After first 'c' from Cut, should go to Copy
expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
await user.keyboard('c');
expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
await user.keyboard('c');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('skips disabled items in type-ahead', async () => {
const user = userEvent.setup();
const itemsWithDisabledMatch: MenuItem[] = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy', disabled: true },
{ id: 'clear', label: 'Clear' },
];
render(<MenuButton items={itemsWithDisabledMatch} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('c');
// Copy is disabled, so should skip to Clear
expect(screen.getByRole('menuitem', { name: 'Clear' })).toHaveFocus();
});
it('does not change focus when no match', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
firstItem.focus();
await user.keyboard('z');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('resets buffer after 500ms', () => {
vi.useFakeTimers();
render(<MenuButton items={typeAheadItems} label="Actions" defaultOpen />);
const firstItem = screen.getByRole('menuitem', { name: 'Cut' });
act(() => {
firstItem.focus();
});
// Type 'c' -> moves to Copy
act(() => {
fireEvent.keyDown(firstItem, { key: 'c' });
});
expect(screen.getByRole('menuitem', { name: 'Copy' })).toHaveFocus();
// Wait for buffer reset (500ms)
act(() => {
vi.advanceTimersByTime(500);
});
// After reset, 'e' should match 'Edit' (not 'ce')
const copyItem = screen.getByRole('menuitem', { name: 'Copy' });
act(() => {
fireEvent.keyDown(copyItem, { key: 'e' });
});
expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
});
});
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('button has aria-haspopup="menu"', () => {
render(<MenuButton items={defaultItems} label="Actions" />);
expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
'aria-haspopup',
'menu'
);
});
it('has aria-expanded="false" when closed', () => {
render(<MenuButton items={defaultItems} label="Actions" />);
expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
'aria-expanded',
'false'
);
});
it('has aria-expanded="true" when open', () => {
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
expect(screen.getByRole('button', { name: 'Actions' })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('button always references menu with aria-controls', () => {
render(<MenuButton 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('menu has role="menu"', () => {
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
expect(screen.getByRole('menu')).toBeInTheDocument();
});
it('menu references button with aria-labelledby', () => {
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const menu = screen.getByRole('menu');
const labelledbyId = menu.getAttribute('aria-labelledby');
expect(labelledbyId).toBeTruthy();
expect(document.getElementById(labelledbyId!)).toHaveAttribute('aria-haspopup', 'menu');
});
it('items have role="menuitem"', () => {
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const menuItems = screen.getAllByRole('menuitem');
expect(menuItems).toHaveLength(3);
});
it('disabled item has aria-disabled="true"', () => {
render(<MenuButton items={itemsWithDisabled} label="Actions" defaultOpen />);
const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });
expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('focused item has tabindex="0"', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" />);
const button = screen.getByRole('button', { name: 'Actions' });
await user.click(button);
const focusedItem = screen.getByRole('menuitem', { name: 'Cut' });
expect(focusedItem).toHaveAttribute('tabindex', '0');
});
it('other items have tabindex="-1"', async () => {
const user = userEvent.setup();
render(<MenuButton items={defaultItems} label="Actions" />);
const button = screen.getByRole('button', { name: 'Actions' });
await user.click(button);
const otherItems = screen
.getAllByRole('menuitem')
.filter((item) => item.textContent !== 'Cut');
otherItems.forEach((item) => {
expect(item).toHaveAttribute('tabindex', '-1');
});
});
it('disabled item has tabindex="-1"', () => {
render(<MenuButton items={itemsWithDisabled} label="Actions" defaultOpen />);
const disabledItem = screen.getByRole('menuitem', { name: 'Cut' });
expect(disabledItem).toHaveAttribute('tabindex', '-1');
});
it('returns focus to button when menu closes', async () => {
const user = userEvent.setup();
render(<MenuButton 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('menu has inert and hidden when closed', () => {
render(<MenuButton items={defaultItems} label="Actions" />);
const menu = screen.getByRole('menu', { hidden: true });
expect(menu).toHaveAttribute('hidden');
expect(menu).toHaveAttribute('inert');
});
});
// 🔴 High Priority: Edge Cases
describe('Edge Cases', () => {
it('when all items are disabled, menu opens but focus stays on button', async () => {
const user = userEvent.setup();
render(<MenuButton 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('does not crash with empty items array', () => {
expect(() => {
render(<MenuButton items={[]} label="Actions" />);
}).not.toThrow();
expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
});
it('IDs do not conflict with multiple instances', () => {
render(
<>
<MenuButton items={defaultItems} label="Actions 1" />
<MenuButton items={defaultItems} label="Actions 2" />
</>
);
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: Accessibility Validation
describe('Accessibility', () => {
it('no axe violations when closed', async () => {
const { container } = render(<MenuButton items={defaultItems} label="Actions" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('no axe violations when open', async () => {
const { container } = render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props
describe('Props', () => {
it('initially displayed when defaultOpen=true', () => {
render(<MenuButton items={defaultItems} label="Actions" defaultOpen />);
const button = screen.getByRole('button', { name: 'Actions' });
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menu')).not.toHaveAttribute('hidden');
});
it('applies className to container', () => {
const { container } = render(
<MenuButton items={defaultItems} label="Actions" className="custom-class" />
);
expect(container.querySelector('.apg-menu-button')).toHaveClass('custom-class');
});
it('calls onItemSelect with correct id', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(<MenuButton items={defaultItems} label="Actions" onItemSelect={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
- WAI-ARIA APG: Menu Button Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist