Menu Button
アクションやオプションのメニューを開くボタン。
デモ
基本的なメニューボタン
ボタンをクリックするか、キーボードでメニューを開きます。
Last action: None
無効な項目を含む場合
無効な項目はキーボード操作時にスキップされます。
Last action: None
Note: "Export" is disabled and will be skipped during keyboard navigation
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
button | トリガー(<button>) | メニューを開くトリガー(<button>要素による暗黙的なロール) |
menu | コンテナ(<ul>) | ユーザーに選択肢のリストを提供するウィジェット |
menuitem | 各アイテム(<li>) | メニュー内のオプション |
WAI-ARIA プロパティ
aria-haspopup
ボタンがメニューを開くことを示す
- 値
menu- 必須
- はい
aria-controls
メニュー要素を参照する
- 値
- ID参照
- 必須
- いいえ
aria-labelledby
メニューを開くボタンを参照する
- 値
- ID参照
- 必須
- はい(またはaria-label)
aria-label
メニューのアクセシブルな名前を提供する
- 値
- 文字列
- 必須
- はい(またはaria-labelledby)
aria-disabled
メニューアイテムが無効であることを示す
- 値
- true
- 必須
- いいえ
WAI-ARIA ステート
aria-expanded
- 対象要素
- button
- 値
- true | false
- 必須
- はい
- 変更トリガー
- メニューを開く/閉じる
キーボードサポート
ボタン(メニューが閉じている状態)
| キー | アクション |
|---|---|
| Enter / Space | メニューを開き、最初のアイテムにフォーカスを移動する |
| Down Arrow | メニューを開き、最初のアイテムにフォーカスを移動する |
| Up Arrow | メニューを開き、最後のアイテムにフォーカスを移動する |
メニュー(開いている状態)
| キー | アクション |
|---|---|
| Down Arrow | 次のアイテムにフォーカスを移動する(最後の項目から最初にラップする) |
| Up Arrow | 前のアイテムにフォーカスを移動する(最初の項目から最後にラップする) |
| Home | 最初のアイテムにフォーカスを移動する |
| End | 最後のアイテムにフォーカスを移動する |
| Escape | メニューを閉じ、フォーカスをボタンに戻す |
| Tab | メニューを閉じ、次のフォーカス可能な要素にフォーカスを移動する |
| Enter / Space | フォーカスされたアイテムを実行し、メニューを閉じる |
| Type character | 先行入力: 入力された文字で始まるアイテムにフォーカスを移動する |
- 閉じているとき、メニューはhiddenとinert属性の両方を使用して、視覚的な表示からメニューを隠し、アクセシビリティツリーから削除し、非表示のアイテムに対するキーボードとマウスの操作を防ぎます。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| フォーカスされたメニューアイテム | tabIndex="0" |
| その他のメニューアイテム | tabIndex="-1" |
| 矢印キーナビゲーション | 最後から最初へ、またはその逆にラップする |
| 無効なアイテム | ナビゲーション中にスキップされる |
| メニューが閉じる | フォーカスがボタンに戻る |
参考資料
ソースコード
MenuButton.tsx
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; 使い方
Example
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
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | MenuItem[] | required | メニュー項目の配列 |
label | string | required | ボタンのラベルテキスト |
defaultOpen | boolean | false | 初期状態でメニューを開くかどうか |
onItemSelect | (id: string) => void | - | 項目選択時のコールバック |
className | string | '' | コンテナの追加 CSS クラス |
テスト
テストでは、キーボード操作、ARIA属性、アクセシビリティ要件全般にわたってAPG準拠を検証します。
テストカテゴリ
高優先度 : APG マウス操作
| テスト | 説明 |
|---|---|
Button click | ボタンをクリックするとメニューが開く |
Toggle | ボタンを再度クリックするとメニューが閉じる |
Item click | メニューアイテムをクリックすると実行され、メニューが閉じる |
Disabled item click | 無効なアイテムをクリックしても何も起こらない |
Click outside | メニューの外側をクリックするとメニューが閉じる |
高優先度 : APG キーボード操作(ボタン)
| テスト | 説明 |
|---|---|
Enter | メニューを開き、最初の有効なアイテムにフォーカスを移動する |
Space | メニューを開き、最初の有効なアイテムにフォーカスを移動する |
ArrowDown | メニューを開き、最初の有効なアイテムにフォーカスを移動する |
ArrowUp | メニューを開き、最後の有効なアイテムにフォーカスを移動する |
高優先度 : APG キーボード操作(メニュー)
| テスト | 説明 |
|---|---|
ArrowDown | 次の有効なアイテムにフォーカスを移動する(ラップする) |
ArrowUp | 前の有効なアイテムにフォーカスを移動する(ラップする) |
Home | 最初の有効なアイテムにフォーカスを移動する |
End | 最後の有効なアイテムにフォーカスを移動する |
Escape | メニューを閉じ、フォーカスをボタンに戻す |
Tab | メニューを閉じ、フォーカスを外に移動する |
Enter/Space | アイテムを実行し、メニューを閉じる |
Disabled skip | ナビゲーション中に無効なアイテムをスキップする |
高優先度 : 先行入力検索
| テスト | 説明 |
|---|---|
Single character | 入力された文字で始まる最初のアイテムにフォーカスを移動する |
Multiple characters | 500ms以内に入力された文字がプレフィックス検索文字列を形成する |
Wrap around | 検索が終わりから始まりにラップする |
Buffer reset | 500msの非アクティブ後にバッファがリセットされる |
高優先度 : APG ARIA 属性
| テスト | 説明 |
|---|---|
aria-haspopup | ボタンがaria-haspopup="menu"を持つ |
aria-expanded | ボタンが開いている状態を反映する(true/false) |
aria-controls | ボタンがメニューのIDを参照する |
role="menu" | メニューコンテナがmenuロールを持つ |
role="menuitem" | 各アイテムがmenuitemロールを持つ |
aria-labelledby | メニューがアクセシブルな名前のためにボタンを参照する |
aria-disabled | 無効なアイテムがaria-disabled="true"を持つ |
高優先度 : フォーカス管理(Roving Tabindex)
| テスト | 説明 |
|---|---|
tabIndex=0 | フォーカスされたアイテムがtabIndex=0を持つ |
tabIndex=-1 | フォーカスされていないアイテムがtabIndex=-1を持つ |
Initial focus | メニューが開くと最初の有効なアイテムがフォーカスを受け取る |
Focus return | メニューが閉じるとフォーカスがボタンに戻る |
中優先度 : アクセシビリティ
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反がないこと(jest-axe経由) |
テストコード例
以下は実際のE2Eテストファイルです (e2e/menu-button.spec.ts).
e2e/menu-button.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Menu Button Pattern
*
* A button that opens a menu containing menu items. The button has
* aria-haspopup="menu" and controls a dropdown menu.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getMenuButton = (page: import('@playwright/test').Page) => {
return page.getByRole('button', { name: /actions|file/i }).first();
};
const getMenu = (page: import('@playwright/test').Page) => {
return page.getByRole('menu');
};
const getMenuItems = (page: import('@playwright/test').Page) => {
return page.getByRole('menuitem');
};
const openMenu = async (page: import('@playwright/test').Page) => {
const button = getMenuButton(page);
await button.click();
await getMenu(page).waitFor({ state: 'visible' });
return button;
};
// Wait for hydration to complete
// This is necessary for frameworks like Svelte where event handlers are attached after hydration
const waitForHydration = async (page: import('@playwright/test').Page) => {
const button = getMenuButton(page);
// Wait for aria-controls to be set (basic check)
await expect(button).toHaveAttribute('aria-controls', /.+/);
// Poll until a click actually opens the menu (ensures handlers are attached)
await expect
.poll(async () => {
await button.click();
const isOpen = await getMenu(page).isVisible();
if (isOpen) {
await page.keyboard.press('Escape');
}
return isOpen;
})
.toBe(true);
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Menu Button (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
// Wait for hydration in frameworks that need it (Svelte)
// This ensures event handlers are attached before tests run
if (framework === 'svelte') {
await waitForHydration(page);
}
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('button has aria-haspopup="menu"', async ({ page }) => {
const button = getMenuButton(page);
await expect(button).toHaveAttribute('aria-haspopup', 'menu');
});
test('button has aria-expanded (false when closed)', async ({ page }) => {
const button = getMenuButton(page);
await expect(button).toHaveAttribute('aria-expanded', 'false');
});
test('button has aria-expanded (true when open)', async ({ page }) => {
const button = await openMenu(page);
await expect(button).toHaveAttribute('aria-expanded', 'true');
});
test('button has aria-controls referencing menu id', async ({ page }) => {
const button = getMenuButton(page);
// Wait for hydration - aria-controls may not be set immediately in Svelte
await expect
.poll(async () => {
const id = await button.getAttribute('aria-controls');
return id && id.length > 1 && !id.startsWith('-');
})
.toBe(true);
const menuId = await button.getAttribute('aria-controls');
expect(menuId).toBeTruthy();
await openMenu(page);
const menu = getMenu(page);
await expect(menu).toHaveAttribute('id', menuId!);
});
test('menu has role="menu"', async ({ page }) => {
await openMenu(page);
const menu = getMenu(page);
await expect(menu).toBeVisible();
await expect(menu).toHaveRole('menu');
});
test('menu has accessible name via aria-labelledby', async ({ page }) => {
await openMenu(page);
const menu = getMenu(page);
const labelledby = await menu.getAttribute('aria-labelledby');
expect(labelledby).toBeTruthy();
// Verify it references the button
const button = getMenuButton(page);
const buttonId = await button.getAttribute('id');
expect(labelledby).toBe(buttonId);
});
test('menu items have role="menuitem"', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const count = await items.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(items.nth(i)).toHaveRole('menuitem');
}
});
test('disabled items have aria-disabled="true"', async ({ page }) => {
// Use the File menu demo which must have disabled item (Export)
const fileButton = page.getByRole('button', { name: /file/i });
await expect(fileButton).toBeVisible();
await fileButton.click();
await getMenu(page).waitFor({ state: 'visible' });
const disabledItem = page.getByRole('menuitem', { name: /export/i });
await expect(disabledItem).toBeVisible();
await expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction (Button)
// ------------------------------------------
test.describe('APG: Keyboard Interaction (Button)', () => {
test('Enter opens menu and focuses first item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('Enter');
await expect(getMenu(page)).toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'true');
// First item should be focused
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
});
test('Space opens menu and focuses first item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('Space');
await expect(getMenu(page)).toBeVisible();
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
});
test('ArrowDown opens menu and focuses first item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('ArrowDown');
await expect(getMenu(page)).toBeVisible();
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
});
test('ArrowUp opens menu and focuses last enabled item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('ArrowUp');
await expect(getMenu(page)).toBeVisible();
// Find the last enabled item by checking focus
const focusedItem = page.locator(':focus');
await expect(focusedItem).toHaveRole('menuitem');
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction (Menu)
// ------------------------------------------
test.describe('APG: Keyboard Interaction (Menu)', () => {
test('ArrowDown moves to next item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
});
test('ArrowDown wraps from last to first', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
// Focus the first item, then use End to go to last
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('End');
// Get the last item and verify it's focused
const lastItem = items.last();
await expect(lastItem).toBeFocused();
const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
await lastItem.press('ArrowDown');
const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);
// Should have wrapped to a different item (first)
expect(focusedAfter).not.toBe(focusedBefore);
});
test('ArrowUp moves to previous item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
const secondItem = items.nth(1);
// Navigate to second item using keyboard
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
await expect(secondItem).toBeFocused();
await secondItem.press('ArrowUp');
await expect(firstItem).toBeFocused();
});
test('ArrowUp wraps from first to last', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
await firstItem.press('ArrowUp');
const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);
// Should have wrapped to last item
expect(focusedAfter).not.toBe(focusedBefore);
});
test('Home moves to first enabled item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
const secondItem = items.nth(1);
// Navigate to second item using keyboard
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
await expect(secondItem).toBeFocused();
await secondItem.press('Home');
await expect(firstItem).toBeFocused();
});
test('End moves to last enabled item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('End');
// Focus should be on last item (or last enabled item)
const focusedItem = page.locator(':focus');
await expect(focusedItem).toHaveRole('menuitem');
// Should not be the first item anymore
const focusedText = await focusedItem.textContent();
const firstText = await firstItem.textContent();
expect(focusedText).not.toBe(firstText);
});
test('Escape closes menu and returns focus to button', async ({ page }) => {
const button = await openMenu(page);
await page.keyboard.press('Escape');
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'false');
await expect(button).toBeFocused();
});
test('Enter activates item and closes menu', async ({ page }) => {
const button = await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('Enter');
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toBeFocused();
});
test('Space activates item and closes menu', async ({ page }) => {
const button = await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('Space');
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toBeFocused();
});
test('Tab closes menu', async ({ page }) => {
await openMenu(page);
await page.keyboard.press('Tab');
await expect(getMenu(page)).not.toBeVisible();
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management (Roving Tabindex)
// ------------------------------------------
test.describe('APG: Focus Management', () => {
test('focused item has tabindex="0"', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toHaveAttribute('tabindex', '0');
});
test('non-focused items have tabindex="-1"', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const count = await items.count();
if (count > 1) {
const firstItem = items.first();
await firstItem.focus();
// Check second item has tabindex="-1"
const secondItem = items.nth(1);
await expect(secondItem).toHaveAttribute('tabindex', '-1');
}
});
test('disabled items are skipped during navigation', async ({ page }) => {
// Use the File menu demo which must have disabled items
const fileButton = page.getByRole('button', { name: /file/i });
await expect(fileButton).toBeVisible();
await fileButton.click();
await getMenu(page).waitFor({ state: 'visible' });
// Navigate through all items
const focusedTexts: string[] = [];
// Get first focused item
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
for (let i = 0; i < 10; i++) {
const focusedElement = page.locator(':focus');
const text = await focusedElement.textContent();
if (text && !focusedTexts.includes(text)) {
focusedTexts.push(text);
}
await focusedElement.press('ArrowDown');
}
// "Export" (disabled) should not be in the focused list
expect(focusedTexts).not.toContain('Export');
});
});
// ------------------------------------------
// 🔴 High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('click button opens menu', async ({ page }) => {
const button = getMenuButton(page);
await button.click();
await expect(getMenu(page)).toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'true');
});
test('click button again closes menu (toggle)', async ({ page }) => {
const button = getMenuButton(page);
await button.click();
await expect(getMenu(page)).toBeVisible();
await button.click();
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'false');
});
test('click menu item activates and closes menu', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.click();
await expect(getMenu(page)).not.toBeVisible();
});
test('click outside menu closes it', async ({ page }) => {
await openMenu(page);
const menu = getMenu(page);
const menuBox = await menu.boundingBox();
expect(menuBox).not.toBeNull();
const viewportSize = page.viewportSize();
expect(viewportSize).not.toBeNull();
// Find a safe position outside menu, handling edge cases
const candidates = [
// Above menu (if there's space)
{ x: menuBox!.x + menuBox!.width / 2, y: Math.max(1, menuBox!.y - 20) },
// Left of menu (if there's space)
{ x: Math.max(1, menuBox!.x - 20), y: menuBox!.y + menuBox!.height / 2 },
// Right of menu (if there's space)
{
x: Math.min(viewportSize!.width - 1, menuBox!.x + menuBox!.width + 20),
y: menuBox!.y + menuBox!.height / 2,
},
// Below menu (if there's space)
{
x: menuBox!.x + menuBox!.width / 2,
y: Math.min(viewportSize!.height - 1, menuBox!.y + menuBox!.height + 20),
},
];
// Find first candidate that's outside menu bounds
const isOutsideMenu = (x: number, y: number) =>
x < menuBox!.x ||
x > menuBox!.x + menuBox!.width ||
y < menuBox!.y ||
y > menuBox!.y + menuBox!.height;
const safePosition = candidates.find((pos) => isOutsideMenu(pos.x, pos.y));
if (safePosition) {
await page.mouse.click(safePosition.x, safePosition.y);
} else {
// Fallback: click at viewport corner (1,1)
await page.mouse.click(1, 1);
}
await expect(getMenu(page)).not.toBeVisible();
});
});
// ------------------------------------------
// 🟡 Medium Priority: Type-Ahead
// ------------------------------------------
test.describe('Type-Ahead', () => {
test('single character focuses matching item', async ({ page }) => {
await openMenu(page);
// Wait for first item to be focused (menu opens with focus on first item)
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
// Type 'p' to find "Paste" - use element.press() for single key
await firstItem.press('p');
// Wait for focus to move to item starting with 'p'
// Use trim() because some frameworks may include whitespace in textContent
await expect
.poll(async () => {
const text = await page.evaluate(
() => document.activeElement?.textContent?.trim().toLowerCase() || ''
);
return text.startsWith('p');
})
.toBe(true);
});
test('type-ahead wraps around', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
// Navigate to last item using keyboard
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('End');
const lastItem = items.last();
await expect(lastItem).toBeFocused();
// Type character that matches earlier item - use element.press() for single key
await lastItem.press('c');
// Wait for focus to wrap and find item starting with 'c'
// Use trim() because some frameworks may include whitespace in textContent
await expect
.poll(async () => {
const text = await page.evaluate(
() => document.activeElement?.textContent?.trim().toLowerCase() || ''
);
return text.startsWith('c');
})
.toBe(true);
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations (closed)', async ({ page }) => {
const results = await new AxeBuilder({ page })
.include('.apg-menu-button')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('has no axe-core violations (open)', async ({ page }) => {
await openMenu(page);
const results = await new AxeBuilder({ page })
.include('.apg-menu-button')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Menu Button - Cross-framework Consistency', () => {
test('all frameworks have menu button with aria-haspopup="menu"', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
const button = getMenuButton(page);
await expect(button).toHaveAttribute('aria-haspopup', 'menu');
}
});
test('all frameworks open menu on click', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
const button = getMenuButton(page);
await button.click();
const menu = getMenu(page);
await expect(menu).toBeVisible();
// Close for next iteration
await page.keyboard.press('Escape');
}
});
test('all frameworks close menu on Escape', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
await openMenu(page);
await expect(getMenu(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(getMenu(page)).not.toBeVisible();
}
});
test('all frameworks have consistent keyboard navigation', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
// Wait for hydration (especially needed for Svelte)
if (framework === 'svelte') {
await waitForHydration(page);
}
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('Enter');
const menu = getMenu(page);
await expect(menu).toBeVisible();
// First item should be focused
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
// Arrow navigation
await firstItem.press('ArrowDown');
const secondItem = getMenuItems(page).nth(1);
await expect(secondItem).toBeFocused();
await page.keyboard.press('Escape');
}
});
}); テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト(136のクロスフレームワークテスト)
- @axe-core/playwright (opens in new tab) - 自動アクセシビリティテスト
E2Eテスト: e2e/menu-button.spec.ts (opens in new tab)
詳細なドキュメントについては、
testing-strategy.md
(opens in new tab) を参照してください。
MenuButton.test.tsx
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);
});
});
}); リソース
- WAI-ARIA APG: Menu Button パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist