Menu Button
アクションやオプションのメニューを開くボタン。
デモ
基本的なメニューボタン
ボタンをクリックするか、キーボードでメニューを開きます。
Last action: None
無効な項目を含む場合
無効な項目はキーボード操作時にスキップされます。
Last action: None
注: "Export" は無効化されており、キーボード操作時にスキップされます
アクセシビリティ
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.astro
---
/**
* APG Menu Button Pattern - Astro Implementation
*
* A button that opens a menu of actions or functions.
* Uses Web Components for enhanced control and proper focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
*/
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
}
export interface Props {
/** Array of menu items */
items: MenuItem[];
/** Button label */
label: string;
/** Whether menu is initially open */
defaultOpen?: boolean;
/** Additional CSS class */
class?: string;
}
const { items = [], label, defaultOpen = false, class: className = '' } = Astro.props;
// Generate unique ID for this instance
const instanceId = `menu-button-${Math.random().toString(36).slice(2, 11)}`;
const buttonId = `${instanceId}-button`;
const menuId = `${instanceId}-menu`;
// Calculate available items and initial focus
const availableItems = items.filter((item) => !item.disabled);
const initialFocusIndex = defaultOpen && availableItems.length > 0 ? 0 : -1;
---
<apg-menu-button
data-default-open={defaultOpen ? 'true' : undefined}
data-initial-focus-index={initialFocusIndex}
>
<div class={`apg-menu-button ${className}`.trim()}>
<button
id={buttonId}
type="button"
class="apg-menu-button-trigger"
aria-haspopup="menu"
aria-expanded={defaultOpen}
aria-controls={menuId}
data-menu-trigger
>
{label}
</button>
<ul
id={menuId}
role="menu"
aria-labelledby={buttonId}
class="apg-menu-button-menu"
hidden={!defaultOpen || undefined}
inert={!defaultOpen || undefined}
data-menu-list
>
{
items.map((item) => {
const availableIndex = availableItems.findIndex((i) => i.id === item.id);
const isFocusTarget = availableIndex === initialFocusIndex;
const tabIndex = item.disabled ? -1 : isFocusTarget ? 0 : -1;
return (
<li
role="menuitem"
data-item-id={item.id}
tabindex={tabIndex}
aria-disabled={item.disabled || undefined}
class="apg-menu-button-item"
>
{item.label}
</li>
);
})
}
</ul>
</div>
</apg-menu-button>
<script>
class ApgMenuButton extends HTMLElement {
private container: HTMLDivElement | null = null;
private button: HTMLButtonElement | null = null;
private menu: HTMLUListElement | null = null;
private rafId: number | null = null;
private isOpen = false;
private focusedIndex = -1;
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private typeAheadTimeout = 500;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.container = this.querySelector('.apg-menu-button');
this.button = this.querySelector('[data-menu-trigger]');
this.menu = this.querySelector('[data-menu-list]');
if (!this.button || !this.menu) {
console.warn('apg-menu-button: required elements not found');
return;
}
// Initialize state from data attributes
this.isOpen = this.dataset.defaultOpen === 'true';
this.focusedIndex = parseInt(this.dataset.initialFocusIndex || '-1', 10);
// Attach event listeners
this.button.addEventListener('click', this.handleButtonClick);
this.button.addEventListener('keydown', this.handleButtonKeyDown);
this.menu.addEventListener('keydown', this.handleMenuKeyDown);
this.menu.addEventListener('click', this.handleMenuClick);
this.menu.addEventListener('focusin', this.handleMenuFocusIn);
// Click outside listener (only when open)
if (this.isOpen) {
document.addEventListener('pointerdown', this.handleClickOutside);
// Initialize roving tabindex and focus first item for APG compliance
this.updateTabIndices();
const availableItems = this.getAvailableItems();
if (this.focusedIndex >= 0 && availableItems[this.focusedIndex]) {
availableItems[this.focusedIndex].focus();
}
}
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
document.removeEventListener('pointerdown', this.handleClickOutside);
this.button?.removeEventListener('click', this.handleButtonClick);
this.button?.removeEventListener('keydown', this.handleButtonKeyDown);
this.menu?.removeEventListener('keydown', this.handleMenuKeyDown);
this.menu?.removeEventListener('click', this.handleMenuClick);
this.menu?.removeEventListener('focusin', this.handleMenuFocusIn);
}
private getItems(): HTMLLIElement[] {
if (!this.menu) return [];
return Array.from(this.menu.querySelectorAll<HTMLLIElement>('[role="menuitem"]'));
}
private getAvailableItems(): HTMLLIElement[] {
return this.getItems().filter((item) => item.getAttribute('aria-disabled') !== 'true');
}
private openMenu(focusPosition: 'first' | 'last') {
if (!this.button || !this.menu) return;
const availableItems = this.getAvailableItems();
this.isOpen = true;
this.button.setAttribute('aria-expanded', 'true');
this.menu.removeAttribute('hidden');
this.menu.removeAttribute('inert');
document.addEventListener('pointerdown', this.handleClickOutside);
if (availableItems.length === 0) {
this.focusedIndex = -1;
return;
}
const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
this.focusedIndex = targetIndex;
this.updateTabIndices();
availableItems[targetIndex]?.focus();
}
private closeMenu() {
if (!this.button || !this.menu) return;
this.isOpen = false;
this.focusedIndex = -1;
this.button.setAttribute('aria-expanded', 'false');
this.menu.setAttribute('hidden', '');
this.menu.setAttribute('inert', '');
// Clear type-ahead state
this.typeAheadBuffer = '';
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
document.removeEventListener('pointerdown', this.handleClickOutside);
// Reset tabindex
this.updateTabIndices();
}
private toggleMenu() {
if (this.isOpen) {
this.closeMenu();
} else {
this.openMenu('first');
}
}
private updateTabIndices() {
const items = this.getItems();
const availableItems = this.getAvailableItems();
items.forEach((item) => {
if (item.getAttribute('aria-disabled') === 'true') {
item.tabIndex = -1;
return;
}
const availableIndex = availableItems.indexOf(item);
item.tabIndex = availableIndex === this.focusedIndex ? 0 : -1;
});
}
private focusItem(index: number) {
const availableItems = this.getAvailableItems();
if (index >= 0 && index < availableItems.length) {
this.focusedIndex = index;
this.updateTabIndices();
availableItems[index]?.focus();
}
}
private handleTypeAhead(char: string) {
const availableItems = this.getAvailableItems();
if (availableItems.length === 0) return;
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
}
this.typeAheadBuffer += char.toLowerCase();
const buffer = this.typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let startIndex: number;
let searchStr: string;
if (isSameChar) {
this.typeAheadBuffer = buffer[0];
searchStr = buffer[0];
startIndex = this.focusedIndex >= 0 ? (this.focusedIndex + 1) % availableItems.length : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = this.focusedIndex >= 0 ? (this.focusedIndex + 1) % availableItems.length : 0;
} else {
searchStr = buffer;
startIndex = this.focusedIndex >= 0 ? this.focusedIndex : 0;
}
for (let i = 0; i < availableItems.length; i++) {
const index = (startIndex + i) % availableItems.length;
const item = availableItems[index];
const label = item.textContent?.trim().toLowerCase() || '';
if (label.startsWith(searchStr)) {
this.focusItem(index);
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleButtonClick = () => {
this.toggleMenu();
};
private handleButtonKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.openMenu('first');
break;
case 'ArrowDown':
event.preventDefault();
this.openMenu('first');
break;
case 'ArrowUp':
event.preventDefault();
this.openMenu('last');
break;
}
};
private handleMenuClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
if (!item || item.getAttribute('aria-disabled') === 'true') return;
const itemId = item.dataset.itemId;
if (itemId) {
this.dispatchEvent(
new CustomEvent('itemselect', {
detail: { itemId },
bubbles: true,
})
);
}
this.closeMenu();
this.button?.focus();
};
private handleMenuFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement;
const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
if (!item || item.getAttribute('aria-disabled') === 'true') return;
const availableItems = this.getAvailableItems();
const index = availableItems.indexOf(item);
if (index >= 0 && index !== this.focusedIndex) {
this.focusedIndex = index;
this.updateTabIndices();
}
};
private handleMenuKeyDown = (event: KeyboardEvent) => {
const availableItems = this.getAvailableItems();
// Handle Escape even with no available items
if (event.key === 'Escape') {
event.preventDefault();
this.closeMenu();
this.button?.focus();
return;
}
if (availableItems.length === 0) return;
const target = event.target as HTMLElement;
const currentItem = target.closest('[role="menuitem"]') as HTMLLIElement | null;
const currentIndex = currentItem ? availableItems.indexOf(currentItem) : -1;
// If focus is on disabled item, only handle Escape (already handled above)
if (currentIndex < 0) return;
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = (currentIndex + 1) % availableItems.length;
this.focusItem(nextIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = currentIndex === 0 ? availableItems.length - 1 : currentIndex - 1;
this.focusItem(prevIndex);
break;
}
case 'Home': {
event.preventDefault();
this.focusItem(0);
break;
}
case 'End': {
event.preventDefault();
this.focusItem(availableItems.length - 1);
break;
}
case 'Tab': {
this.closeMenu();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
const item = availableItems[currentIndex];
const itemId = item?.dataset.itemId;
if (itemId) {
this.dispatchEvent(
new CustomEvent('itemselect', {
detail: { itemId },
bubbles: true,
})
);
}
this.closeMenu();
this.button?.focus();
break;
}
default: {
// Type-ahead: single printable character
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.handleTypeAhead(event.key);
}
}
}
};
private handleClickOutside = (event: PointerEvent) => {
if (this.container && !this.container.contains(event.target as Node)) {
this.closeMenu();
}
};
}
if (!customElements.get('apg-menu-button')) {
customElements.define('apg-menu-button', ApgMenuButton);
}
</script>使い方
Example
---
import MenuButton from './MenuButton.astro';
const items = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
{ id: 'delete', label: 'Delete', disabled: true },
];
---
<!-- Basic usage -->
<MenuButton items={items} label="Actions" />
<script>
// Handle selection via custom event
document.querySelectorAll('apg-menu-button').forEach((menuButton) => {
menuButton.addEventListener('itemselect', (e) => {
const event = e as CustomEvent<{ itemId: string }>;
console.log('Selected:', event.detail.itemId);
});
});
</script>API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | MenuItem[] | required | メニュー項目の配列 |
label | string | required | ボタンのラベルテキスト |
defaultOpen | boolean | false | メニューが初期状態で開いているかどうか |
class | string | '' | コンテナの追加 CSS クラス |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
itemselect | { itemId: string } | メニュー項目が選択されたときに発行される |
テスト
テストでは、キーボード操作、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;
};
const getAllDisabledMenuButton = (page: import('@playwright/test').Page) => {
return page.getByRole('button', { name: 'Tools' });
};
const getEmptyMenuButton = (page: import('@playwright/test').Page) => {
return page.getByRole('button', { name: 'Bookmarks' });
};
// Wait for hydration to complete: Astro removes the `ssr` attribute from
// each <astro-island> once its component has hydrated.
const waitForHydration = async (page: import('@playwright/test').Page) => {
await expect(page.locator('astro-island[ssr]')).toHaveCount(0);
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Menu Button (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
await waitForHydration(page);
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('button has aria-haspopup="menu"', async ({ page }) => {
const button = getMenuButton(page);
await expect(button).toHaveAttribute('aria-haspopup', 'menu');
});
test('button has aria-expanded (false when closed)', async ({ page }) => {
const button = getMenuButton(page);
await expect(button).toHaveAttribute('aria-expanded', 'false');
});
test('button has aria-expanded (true when open)', async ({ page }) => {
const button = await openMenu(page);
await expect(button).toHaveAttribute('aria-expanded', 'true');
});
test('button has aria-controls referencing menu id', async ({ page }) => {
const button = getMenuButton(page);
// Wait for hydration - aria-controls may not be set immediately in Svelte
await expect
.poll(async () => {
const id = await button.getAttribute('aria-controls');
return id && id.length > 1 && !id.startsWith('-');
})
.toBe(true);
const menuId = await button.getAttribute('aria-controls');
expect(menuId).toBeTruthy();
await openMenu(page);
const menu = getMenu(page);
await expect(menu).toHaveAttribute('id', menuId!);
});
test('menu has role="menu"', async ({ page }) => {
await openMenu(page);
const menu = getMenu(page);
await expect(menu).toBeVisible();
await expect(menu).toHaveRole('menu');
});
test('menu has accessible name via aria-labelledby', async ({ page }) => {
await openMenu(page);
const menu = getMenu(page);
const labelledby = await menu.getAttribute('aria-labelledby');
expect(labelledby).toBeTruthy();
// Verify it references the button
const button = getMenuButton(page);
const buttonId = await button.getAttribute('id');
expect(labelledby).toBe(buttonId);
});
test('menu is named by its own button when several menu buttons are on the page', async ({
page,
}) => {
await page.getByRole('button', { name: /file/i }).click();
await expect(page.getByRole('menu', { name: 'File' })).toBeVisible();
});
test('menu items have role="menuitem"', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const count = await items.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(items.nth(i)).toHaveRole('menuitem');
}
});
test('disabled items have aria-disabled="true"', async ({ page }) => {
// Use the File menu demo which must have disabled item (Export)
const fileButton = page.getByRole('button', { name: /file/i });
await expect(fileButton).toBeVisible();
await fileButton.click();
await getMenu(page).waitFor({ state: 'visible' });
const disabledItem = page.getByRole('menuitem', { name: /export/i });
await expect(disabledItem).toBeVisible();
await expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction (Button)
// ------------------------------------------
test.describe('APG: Keyboard Interaction (Button)', () => {
test('Enter opens menu and focuses first item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('Enter');
await expect(getMenu(page)).toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'true');
// First item should be focused
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
});
test('Space opens menu and focuses first item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('Space');
await expect(getMenu(page)).toBeVisible();
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
});
test('ArrowDown opens menu and focuses first item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('ArrowDown');
await expect(getMenu(page)).toBeVisible();
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
});
test('ArrowUp opens menu and focuses last enabled item', async ({ page }) => {
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('ArrowUp');
await expect(getMenu(page)).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeFocused();
});
});
// ------------------------------------------
// 🟡 Medium Priority: Menus without enabled items
// ------------------------------------------
test.describe('Menus without enabled items', () => {
test('Enter opens a menu whose items are all disabled and keeps focus on the button', async ({
page,
}) => {
const button = getAllDisabledMenuButton(page);
await button.focus();
await button.press('Enter');
await expect(getMenu(page)).toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'true');
await expect(button).toBeFocused();
});
test('items of a menu whose items are all disabled keep tabindex="-1" when opened', async ({
page,
}) => {
const button = getAllDisabledMenuButton(page);
await button.click();
await expect(page.getByRole('menuitem', { name: 'Undo' })).toHaveAttribute(
'tabindex',
'-1'
);
await expect(page.getByRole('menuitem', { name: 'Redo' })).toHaveAttribute(
'tabindex',
'-1'
);
});
test('Enter opens an empty menu and keeps focus on the button', async ({ page }) => {
const button = getEmptyMenuButton(page);
await button.focus();
await button.press('Enter');
await expect(getMenu(page)).toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'true');
await expect(button).toBeFocused();
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction (Menu)
// ------------------------------------------
test.describe('APG: Keyboard Interaction (Menu)', () => {
test('ArrowDown moves to next item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
});
test('ArrowDown wraps from last to first', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
// Focus the first item, then use End to go to last
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('End');
// Get the last item and verify it's focused
const lastItem = items.last();
await expect(lastItem).toBeFocused();
const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
await lastItem.press('ArrowDown');
const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);
// Should have wrapped to a different item (first)
expect(focusedAfter).not.toBe(focusedBefore);
});
test('ArrowUp moves to previous item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
const secondItem = items.nth(1);
// Navigate to second item using keyboard
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
await expect(secondItem).toBeFocused();
await secondItem.press('ArrowUp');
await expect(firstItem).toBeFocused();
});
test('ArrowUp wraps from first to last', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
const focusedBefore = await page.evaluate(() => document.activeElement?.textContent);
await firstItem.press('ArrowUp');
const focusedAfter = await page.evaluate(() => document.activeElement?.textContent);
// Should have wrapped to last item
expect(focusedAfter).not.toBe(focusedBefore);
});
test('Home moves to first enabled item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
const secondItem = items.nth(1);
// Navigate to second item using keyboard
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
await expect(secondItem).toBeFocused();
await secondItem.press('Home');
await expect(firstItem).toBeFocused();
});
test('End moves to last enabled item', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('End');
// Focus should be on last item (or last enabled item)
const focusedItem = page.locator(':focus');
await expect(focusedItem).toHaveRole('menuitem');
// Should not be the first item anymore
const focusedText = await focusedItem.textContent();
const firstText = await firstItem.textContent();
expect(focusedText).not.toBe(firstText);
});
test('Escape closes menu and returns focus to button', async ({ page }) => {
const button = await openMenu(page);
await page.keyboard.press('Escape');
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'false');
await expect(button).toBeFocused();
});
test('Enter activates item and closes menu', async ({ page }) => {
const button = await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('Enter');
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toBeFocused();
});
test('Space activates item and closes menu', async ({ page }) => {
const button = await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('Space');
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toBeFocused();
});
test('Tab closes menu', async ({ page }) => {
await openMenu(page);
await page.keyboard.press('Tab');
await expect(getMenu(page)).not.toBeVisible();
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management (Roving Tabindex)
// ------------------------------------------
test.describe('APG: Focus Management', () => {
test('focused item has tabindex="0"', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.focus();
await expect(firstItem).toHaveAttribute('tabindex', '0');
});
test('non-focused items have tabindex="-1"', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const count = await items.count();
if (count > 1) {
const firstItem = items.first();
await firstItem.focus();
// Check second item has tabindex="-1"
const secondItem = items.nth(1);
await expect(secondItem).toHaveAttribute('tabindex', '-1');
}
});
test('disabled items are skipped during navigation', async ({ page }) => {
// Use the File menu demo which must have disabled items
const fileButton = page.getByRole('button', { name: /file/i });
await expect(fileButton).toBeVisible();
await fileButton.click();
await getMenu(page).waitFor({ state: 'visible' });
// Navigate through all items
const focusedTexts: string[] = [];
// Get first focused item
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
for (let i = 0; i < 10; i++) {
const focusedElement = page.locator(':focus');
const text = await focusedElement.textContent();
if (text && !focusedTexts.includes(text)) {
focusedTexts.push(text);
}
await focusedElement.press('ArrowDown');
}
// "Export" (disabled) should not be in the focused list
expect(focusedTexts).not.toContain('Export');
});
});
// ------------------------------------------
// 🔴 High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('click button opens menu', async ({ page }) => {
const button = getMenuButton(page);
await button.click();
await expect(getMenu(page)).toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'true');
});
test('click button again closes menu (toggle)', async ({ page }) => {
const button = getMenuButton(page);
await button.click();
await expect(getMenu(page)).toBeVisible();
await button.click();
await expect(getMenu(page)).not.toBeVisible();
await expect(button).toHaveAttribute('aria-expanded', 'false');
});
test('click menu item activates and closes menu', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
await firstItem.click();
await expect(getMenu(page)).not.toBeVisible();
});
test('click on a disabled item keeps the menu open', async ({ page }) => {
const fileButton = page.getByRole('button', { name: /file/i });
await fileButton.click();
await getMenu(page).waitFor({ state: 'visible' });
await page.getByRole('menuitem', { name: 'Export' }).click({ force: true });
await expect(getMenu(page)).toBeVisible();
await expect(fileButton).toHaveAttribute('aria-expanded', 'true');
});
test('click outside menu closes it', async ({ page }) => {
await openMenu(page);
const menu = getMenu(page);
const menuBox = await menu.boundingBox();
expect(menuBox).not.toBeNull();
const viewportSize = page.viewportSize();
expect(viewportSize).not.toBeNull();
// Find a safe position outside menu, handling edge cases
const candidates = [
// Above menu (if there's space)
{ x: menuBox!.x + menuBox!.width / 2, y: Math.max(1, menuBox!.y - 20) },
// Left of menu (if there's space)
{ x: Math.max(1, menuBox!.x - 20), y: menuBox!.y + menuBox!.height / 2 },
// Right of menu (if there's space)
{
x: Math.min(viewportSize!.width - 1, menuBox!.x + menuBox!.width + 20),
y: menuBox!.y + menuBox!.height / 2,
},
// Below menu (if there's space)
{
x: menuBox!.x + menuBox!.width / 2,
y: Math.min(viewportSize!.height - 1, menuBox!.y + menuBox!.height + 20),
},
];
// Find first candidate that's outside menu bounds
const isOutsideMenu = (x: number, y: number) =>
x < menuBox!.x ||
x > menuBox!.x + menuBox!.width ||
y < menuBox!.y ||
y > menuBox!.y + menuBox!.height;
const safePosition = candidates.find((pos) => isOutsideMenu(pos.x, pos.y));
if (safePosition) {
await page.mouse.click(safePosition.x, safePosition.y);
} else {
// Fallback: click at viewport corner (1,1)
await page.mouse.click(1, 1);
}
await expect(getMenu(page)).not.toBeVisible();
});
});
// ------------------------------------------
// 🟡 Medium Priority: Type-Ahead
// ------------------------------------------
test.describe('Type-Ahead', () => {
test('single character focuses matching item', async ({ page }) => {
await openMenu(page);
// Wait for first item to be focused (menu opens with focus on first item)
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
// Type 'p' to find "Paste" - use element.press() for single key
await firstItem.press('p');
// Wait for focus to move to item starting with 'p'
// Use trim() because some frameworks may include whitespace in textContent
await expect
.poll(async () => {
const text = await page.evaluate(
() => document.activeElement?.textContent?.trim().toLowerCase() || ''
);
return text.startsWith('p');
})
.toBe(true);
});
test('type-ahead wraps around', async ({ page }) => {
await openMenu(page);
const items = getMenuItems(page);
const firstItem = items.first();
// Navigate to last item using keyboard
await firstItem.focus();
await expect(firstItem).toBeFocused();
await firstItem.press('End');
const lastItem = items.last();
await expect(lastItem).toBeFocused();
// Type character that matches earlier item - use element.press() for single key
await lastItem.press('c');
// Wait for focus to wrap and find item starting with 'c'
// Use trim() because some frameworks may include whitespace in textContent
await expect
.poll(async () => {
const text = await page.evaluate(
() => document.activeElement?.textContent?.trim().toLowerCase() || ''
);
return text.startsWith('c');
})
.toBe(true);
});
test('repeated same character cycles through matching items', async ({ page }) => {
await openMenu(page);
const cutItem = page.getByRole('menuitem', { name: 'Cut' });
const copyItem = page.getByRole('menuitem', { name: 'Copy' });
await expect(cutItem).toBeFocused();
await cutItem.press('c');
await expect(copyItem).toBeFocused();
await copyItem.press('c');
await expect(cutItem).toBeFocused();
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations (closed)', async ({ page }) => {
const results = await new AxeBuilder({ page })
.include('.apg-menu-button')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('has no axe-core violations (open)', async ({ page }) => {
await openMenu(page);
const results = await new AxeBuilder({ page })
.include('.apg-menu-button')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Astro-specific: itemselect CustomEvent
// ============================================
test.describe('Menu Button (astro) - itemselect event', () => {
test.beforeEach(async ({ page }) => {
await page.goto('patterns/menu-button/astro/demo/');
await getMenuButton(page).waitFor();
await page.evaluate(() => {
const selected: string[] = [];
(window as unknown as { __selectedItems: string[] }).__selectedItems = selected;
document.addEventListener('itemselect', (e) => {
selected.push((e as CustomEvent<{ itemId: string }>).detail.itemId);
});
});
});
test('dispatches itemselect with the item id on item click', async ({ page }) => {
await openMenu(page);
await page.getByRole('menuitem', { name: 'Copy' }).click();
const selected = await page.evaluate(
() => (window as unknown as { __selectedItems: string[] }).__selectedItems
);
expect(selected).toEqual(['copy']);
});
test('dispatches itemselect with the item id on Enter', async ({ page }) => {
await openMenu(page);
await page.getByRole('menuitem', { name: 'Cut' }).press('Enter');
const selected = await page.evaluate(
() => (window as unknown as { __selectedItems: string[] }).__selectedItems
);
expect(selected).toEqual(['cut']);
});
test('dispatches itemselect with the item id on Space', async ({ page }) => {
await openMenu(page);
await page.getByRole('menuitem', { name: 'Cut' }).press('Space');
const selected = await page.evaluate(
() => (window as unknown as { __selectedItems: string[] }).__selectedItems
);
expect(selected).toEqual(['cut']);
});
test('does not dispatch itemselect on disabled item click', async ({ page }) => {
await page.getByRole('button', { name: /file/i }).click();
await getMenu(page).waitFor({ state: 'visible' });
await page.getByRole('menuitem', { name: 'Export' }).click({ force: true });
const selected = await page.evaluate(
() => (window as unknown as { __selectedItems: string[] }).__selectedItems
);
expect(selected).toEqual([]);
});
});
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Menu Button - Cross-framework Consistency', () => {
test('all frameworks have menu button with aria-haspopup="menu"', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
await waitForHydration(page);
const button = getMenuButton(page);
await expect(button).toHaveAttribute('aria-haspopup', 'menu');
}
});
test('all frameworks open menu on click', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
await waitForHydration(page);
const button = getMenuButton(page);
await button.click();
const menu = getMenu(page);
await expect(menu).toBeVisible();
// Close for next iteration
await page.keyboard.press('Escape');
}
});
test('all frameworks close menu on Escape', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
await waitForHydration(page);
await openMenu(page);
await expect(getMenu(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(getMenu(page)).not.toBeVisible();
}
});
test('all frameworks have consistent keyboard navigation', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/menu-button/${framework}/demo/`);
await getMenuButton(page).waitFor();
await waitForHydration(page);
const button = getMenuButton(page);
await button.focus();
await expect(button).toBeFocused();
await button.press('Enter');
const menu = getMenu(page);
await expect(menu).toBeVisible();
// First item should be focused
const firstItem = getMenuItems(page).first();
await expect(firstItem).toBeFocused();
// Arrow navigation
await firstItem.press('ArrowDown');
const secondItem = getMenuItems(page).nth(1);
await expect(secondItem).toBeFocused();
await page.keyboard.press('Escape');
}
});
});テストツール
- 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)
詳細はテスト戦略ガイドを参照してください。
MenuButton.test.astro.ts
/**
* MenuButton Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of MenuButton.astro.
* Web Component behavior (opening/closing, keyboard navigation, roving
* tabindex updates, type-ahead, click outside and the itemselect event)
* is covered by E2E tests in e2e/menu-button.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import MenuButton from './MenuButton.astro';
describe('MenuButton (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderMenuButton(props: Record<string, unknown>): Promise<Document> {
const html = await container.renderToString(MenuButton, { props });
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-menu-button custom element wrapper', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
expect(doc.querySelector('apg-menu-button')).not.toBeNull();
});
it('renders the trigger as a button with type="button" and the label as its text', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
const button = doc.querySelector('button');
expect(button?.getAttribute('type')).toBe('button');
expect(button?.textContent?.trim()).toBe('Actions');
});
it('renders a list with role="menu"', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
expect(doc.querySelector('ul[role="menu"]')).not.toBeNull();
});
it('renders each item as role="menuitem" with its label', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
],
label: 'Actions',
});
const labels = Array.from(doc.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim()
);
expect(labels).toEqual(['Cut', 'Copy', 'Paste']);
});
it('renders an empty menu when items is empty', async () => {
const doc = await renderMenuButton({ items: [], label: 'Actions' });
expect(doc.querySelector('[role="menu"]')).not.toBeNull();
expect(doc.querySelectorAll('[role="menuitem"]')).toHaveLength(0);
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has aria-haspopup="menu" on the button', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
expect(doc.querySelector('button')?.getAttribute('aria-haspopup')).toBe('menu');
});
it('has aria-expanded="false" on the button by default', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
expect(doc.querySelector('button')?.getAttribute('aria-expanded')).toBe('false');
});
it('has aria-expanded="true" on the button when defaultOpen is true', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
defaultOpen: true,
});
expect(doc.querySelector('button')?.getAttribute('aria-expanded')).toBe('true');
});
it('has aria-controls on the button referencing the menu id', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
const menuId = doc.querySelector('button')?.getAttribute('aria-controls') ?? '';
expect(doc.getElementById(menuId)?.getAttribute('role')).toBe('menu');
});
it('has aria-labelledby on the menu referencing the button', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
const buttonId = doc.querySelector('[role="menu"]')?.getAttribute('aria-labelledby') ?? '';
expect(doc.getElementById(buttonId)?.textContent?.trim()).toBe('Actions');
});
it('has aria-disabled="true" only on disabled items', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy', disabled: true },
{ id: 'paste', label: 'Paste' },
],
label: 'Actions',
});
const items = doc.querySelectorAll('[role="menuitem"]');
expect(items[0].hasAttribute('aria-disabled')).toBe(false);
expect(items[1].getAttribute('aria-disabled')).toBe('true');
expect(items[2].hasAttribute('aria-disabled')).toBe(false);
});
});
// 🔴 High Priority: Menu Visibility
describe('Menu Visibility', () => {
it('renders the menu hidden and inert by default', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
const menu = doc.querySelector('[role="menu"]');
expect(menu?.hasAttribute('hidden')).toBe(true);
expect(menu?.hasAttribute('inert')).toBe(true);
});
it('renders the menu visible and not inert when defaultOpen is true', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
defaultOpen: true,
});
const menu = doc.querySelector('[role="menu"]');
expect(menu?.hasAttribute('hidden')).toBe(false);
expect(menu?.hasAttribute('inert')).toBe(false);
});
});
// 🔴 High Priority: Focus Management (initial roving tabindex)
describe('Focus Management', () => {
it('has tabindex="-1" on all items when the menu is closed', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
],
label: 'Actions',
});
const items = doc.querySelectorAll('[role="menuitem"]');
expect(items[0].getAttribute('tabindex')).toBe('-1');
expect(items[1].getAttribute('tabindex')).toBe('-1');
});
it('has tabindex="0" only on the first item when defaultOpen is true', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
],
label: 'Actions',
defaultOpen: true,
});
const items = doc.querySelectorAll('[role="menuitem"]');
expect(items[0].getAttribute('tabindex')).toBe('0');
expect(items[1].getAttribute('tabindex')).toBe('-1');
});
it('has tabindex="0" on the first enabled item when the first item is disabled and defaultOpen is true', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut', disabled: true },
{ id: 'copy', label: 'Copy' },
],
label: 'Actions',
defaultOpen: true,
});
const items = doc.querySelectorAll('[role="menuitem"]');
expect(items[0].getAttribute('tabindex')).toBe('-1');
expect(items[1].getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" on all items when all items are disabled and defaultOpen is true', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut', disabled: true },
{ id: 'copy', label: 'Copy', disabled: true },
],
label: 'Actions',
defaultOpen: true,
});
const items = doc.querySelectorAll('[role="menuitem"]');
expect(items[0].getAttribute('tabindex')).toBe('-1');
expect(items[1].getAttribute('tabindex')).toBe('-1');
});
});
// 🟡 Medium Priority: Web Component Hydration Data
describe('Web Component Data Attributes', () => {
it('sets data-default-open="true" when defaultOpen is true', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
defaultOpen: true,
});
expect(doc.querySelector('apg-menu-button')?.getAttribute('data-default-open')).toBe('true');
});
it('does not set data-default-open when defaultOpen is false', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
});
expect(doc.querySelector('apg-menu-button')?.hasAttribute('data-default-open')).toBe(false);
});
it('sets data-item-id on each item', async () => {
const doc = await renderMenuButton({
items: [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
],
label: 'Actions',
});
const ids = Array.from(doc.querySelectorAll('[role="menuitem"]')).map((item) =>
item.getAttribute('data-item-id')
);
expect(ids).toEqual(['cut', 'copy']);
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('applies a custom class to the container', async () => {
const doc = await renderMenuButton({
items: [{ id: 'cut', label: 'Cut' }],
label: 'Actions',
class: 'custom-class',
});
expect(doc.querySelector('.apg-menu-button')?.classList.contains('custom-class')).toBe(true);
});
it('generates unique ids for each instance', async () => {
const first = await renderMenuButton({ items: [{ id: 'cut', label: 'Cut' }], label: 'A' });
const second = await renderMenuButton({ items: [{ id: 'cut', label: 'Cut' }], label: 'B' });
expect(first.querySelector('[role="menu"]')?.id).not.toBe(
second.querySelector('[role="menu"]')?.id
);
});
});
});リソース
- WAI-ARIA APG: Menu Button パターン(opens in new tab)
- AI Implementation Guide (llm.md)(opens in new tab) - ARIA specs, keyboard support, test checklist