メニューバー
ドロップダウンメニュー、サブメニュー、チェックボックス、ラジオグループをサポートする、アプリケーションスタイルの水平メニューバー。
デモ
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
menubar | 水平コンテナ(<ul>) | トップレベルのメニューバー(常に表示) |
menu | 垂直コンテナ(<ul>) | ドロップダウンメニューまたはサブメニュー |
menuitem | アイテム(<span>) | 標準的なアクションアイテム |
menuitemcheckbox | チェックボックスアイテム | トグル可能なオプション |
menuitemradio | ラジオアイテム | グループ内の排他的なオプション |
separator | 区切り線(<hr>) | 視覚的な区切り(フォーカス不可) |
group | グループコンテナ | ラジオアイテムをラベル付きでグループ化 |
none | <li> elements | スクリーンリーダーからリストセマンティクスを隠す |
WAI-ARIA プロパティ
aria-haspopup
アイテムがメニューを開くことを示す(“true”ではなく”menu”を使用)
- 値
menu- 必須
- はい*
aria-expanded
メニューが開いているかどうかを示す
- 値
- true | false
- 必須
- はい*
aria-labelledby
親のmenuitemを参照する
- 値
- ID参照
- 必須
- はい**
aria-label
アクセシブルな名前を提供する
- 値
- 文字列
- 必須
- はい**
aria-checked
チェック状態を示す
- 値
- true | false
- 必須
- はい
aria-disabled
アイテムが無効であることを示す
- 値
- true
- 必須
- いいえ
aria-hidden
閉じているときメニューをスクリーンリーダーから隠す
- 値
- true | false
- 必須
- はい
キーボードサポート
メニューバーナビゲーション
| キー | アクション |
|---|---|
| Right Arrow | 次のメニューバーアイテムにフォーカスを移動(最後から最初にラップ) |
| Left Arrow | 前のメニューバーアイテムにフォーカスを移動(最初から最後にラップ) |
| Down Arrow | サブメニューを開き、最初のアイテムにフォーカス |
| Up Arrow | サブメニューを開き、最後のアイテムにフォーカス |
| Enter / Space | サブメニューを開き、最初のアイテムにフォーカス |
| Home | 最初のメニューバーアイテムにフォーカスを移動 |
| End | 最後のメニューバーアイテムにフォーカスを移動 |
| Tab | すべてのメニューを閉じてフォーカスを外に移動 |
メニュー/サブメニューナビゲーション
| キー | アクション |
|---|---|
| Down Arrow | 次のアイテムにフォーカスを移動(最後から最初にラップ) |
| Up Arrow | 前のアイテムにフォーカスを移動(最初から最後にラップ) |
| Right Arrow | サブメニューがあれば開く、またはトップレベルメニューでは次のメニューバーアイテムのメニューに移動 |
| Left Arrow | サブメニューを閉じて親に戻る、またはトップレベルメニューでは前のメニューバーアイテムのメニューに移動 |
| Enter / Space | アイテムを実行してメニューを閉じる;チェックボックス/ラジオは状態を切り替えてメニューを開いたままにする |
| Escape | メニューを閉じてフォーカスを親(メニューバーアイテムまたは親menuitem)に戻す |
| Home | 最初のアイテムにフォーカスを移動 |
| End | 最後のアイテムにフォーカスを移動 |
| Character | 先行入力: 入力された文字で始まるアイテムにフォーカスを移動 |
- 閉じているとき、メニューはaria-hidden=“true”とCSSを使用して、スクリーンリーダーから隠す(aria-hidden)、視覚的に隠す(visibility: hidden)、ポインター操作を防ぐ(pointer-events: none)、開閉時のスムーズなCSSアニメーションを可能にします。
- 開いているとき、メニューはaria-hidden=“false”とvisibility: visibleになります。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| 初期フォーカス | 一度に1つのメニューバーアイテムのみがtabindex="0"を持つ |
| その他のアイテム | その他のアイテムはtabindex="-1"を持つ |
| 矢印キーナビゲーション | 矢印キーでアイテム間のフォーカス移動(ラップあり) |
| 無効なアイテム | 無効なアイテムはフォーカス可能だが実行不可(APG推奨) |
| 区切り線 | 区切り線はフォーカス不可 |
| メニューを閉じる | メニューが閉じると、フォーカスは呼び出し元に戻る |
参考資料
ソースコード
Menubar.astro
---
/**
* APG Menubar Pattern - Astro Implementation
*
* A horizontal bar of menu triggers that open dropdown menus.
* Uses Web Components for enhanced control and proper focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
*/
// MenuItem interface with discriminated union pattern
// type can be: 'item' | 'separator' | 'checkbox' | 'radio' | 'radiogroup' | 'submenu'
export interface MenuItem {
type: string;
id: string;
label?: string;
disabled?: boolean;
checked?: boolean;
name?: string; // for radiogroup
items?: MenuItem[]; // for submenu and radiogroup
}
// Type guard helper for action items in submenus
type MenuItemAction = MenuItem & { type: 'item' | 'separator' };
export interface MenubarItem {
id: string;
label: string;
items: MenuItem[];
}
export interface Props {
items: MenubarItem[];
class?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
}
const {
items = [],
class: className = '',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `menubar-${Math.random().toString(36).slice(2, 11)}`;
---
<apg-menubar>
<nav class={`apg-menubar ${className}`.trim()}>
<ul
role="menubar"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class="apg-menubar-list"
data-menubar
>
{
items.map((menubarItem, index) => {
const triggerId = `${instanceId}-${menubarItem.id}-trigger`;
const menuId = `${instanceId}-${menubarItem.id}-menu`;
const isFirstItem = index === 0;
return (
<li role="none">
<span
id={triggerId}
role="menuitem"
tabindex={isFirstItem ? 0 : -1}
aria-haspopup="menu"
aria-expanded="false"
class="apg-menubar-trigger"
data-menubar-trigger
data-menubar-index={index}
>
{menubarItem.label}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="position: relative; top: 1px; opacity: 0.7"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
<ul
id={menuId}
role="menu"
aria-labelledby={triggerId}
class="apg-menubar-menu"
aria-hidden="true"
data-menubar-menu
data-menubar-index={index}
>
{menubarItem.items.map((item) => {
if (item.type === 'separator') {
return (
<li role="none">
<hr role="separator" class="apg-menubar-separator" />
</li>
);
}
if (item.type === 'radiogroup') {
return (
<li role="none">
<ul role="group" aria-label={item.label} class="apg-menubar-group">
{item.items?.map((radioItem) => (
<li role="none">
<span
role="menuitemradio"
tabindex="-1"
aria-checked={radioItem.checked ? 'true' : 'false'}
aria-disabled={radioItem.disabled || undefined}
class="apg-menubar-menuitem apg-menubar-menuitemradio"
data-item-id={radioItem.id}
data-radio-group={item.name}
>
{radioItem.label}
</span>
</li>
))}
</ul>
</li>
);
}
if (item.type === 'checkbox') {
return (
<li role="none">
<span
role="menuitemcheckbox"
tabindex="-1"
aria-checked={item.checked ? 'true' : 'false'}
aria-disabled={item.disabled || undefined}
class="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
data-item-id={item.id}
>
{item.label}
</span>
</li>
);
}
if (item.type === 'submenu') {
const submenuTriggerId = `${instanceId}-${item.id}-submenu-trigger`;
const submenuId = `${instanceId}-${item.id}-submenu`;
return (
<li role="none" class="apg-menubar-submenu-container">
<span
id={submenuTriggerId}
role="menuitem"
tabindex="-1"
aria-haspopup="menu"
aria-expanded="false"
aria-disabled={item.disabled || undefined}
class="apg-menubar-menuitem apg-menubar-submenu-trigger"
data-item-id={item.id}
data-submenu-trigger
>
{item.label}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="margin-left: auto; position: relative; top: 1px"
>
<path d="m9 18 6-6-6-6" />
</svg>
</span>
<ul
id={submenuId}
role="menu"
aria-labelledby={submenuTriggerId}
class="apg-menubar-submenu"
aria-hidden="true"
data-submenu
>
{(item.items ?? [])
.filter(
(subItem): subItem is MenuItemAction =>
subItem.type === 'item' || subItem.type === 'separator'
)
.map((subItem) => {
if (subItem.type === 'separator') {
return (
<li role="none">
<hr role="separator" class="apg-menubar-separator" />
</li>
);
}
return (
<li role="none">
<span
role="menuitem"
tabindex="-1"
aria-disabled={subItem.disabled || undefined}
class="apg-menubar-menuitem"
data-item-id={subItem.id}
>
{subItem.label}
</span>
</li>
);
})}
</ul>
</li>
);
}
// Default: action item
return (
<li role="none">
<span
role="menuitem"
tabindex="-1"
aria-disabled={item.disabled || undefined}
class="apg-menubar-menuitem"
data-item-id={item.id}
>
{item.label}
</span>
</li>
);
})}
</ul>
</li>
);
})
}
</ul>
</nav>
</apg-menubar>
<script>
class ApgMenubar extends HTMLElement {
private menubar: HTMLUListElement | null = null;
private menubarItems: HTMLElement[] = [];
private rafId: number | null = null;
// State
private openMenuIndex = -1;
private focusedMenubarIndex = 0;
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private readonly typeAheadTimeout = 500;
// Checkbox/Radio states (stored separately since we're modifying aria-checked)
private checkboxStates: Map<string, boolean> = new Map();
private radioStates: Map<string, string> = new Map(); // group name -> checked id
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.menubar = this.querySelector('[data-menubar]');
if (!this.menubar) {
console.warn('apg-menubar: menubar element not found');
return;
}
this.menubarItems = Array.from(
this.menubar.querySelectorAll<HTMLElement>('[data-menubar-trigger]')
);
// Initialize checkbox/radio states from aria-checked
this.initializeCheckboxRadioStates();
// Attach event listeners
this.menubar.addEventListener('click', this.handleClick);
this.menubar.addEventListener('keydown', this.handleKeyDown);
this.menubar.addEventListener('mouseover', this.handleMouseOver);
document.addEventListener('pointerdown', this.handleClickOutside);
}
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.menubar?.removeEventListener('click', this.handleClick);
this.menubar?.removeEventListener('keydown', this.handleKeyDown);
this.menubar?.removeEventListener('mouseover', this.handleMouseOver);
}
private initializeCheckboxRadioStates() {
// Checkboxes
const checkboxes = this.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]');
checkboxes.forEach((cb) => {
const id = cb.dataset.itemId;
if (id) {
this.checkboxStates.set(id, cb.getAttribute('aria-checked') === 'true');
}
});
// Radio groups
const radios = this.querySelectorAll<HTMLElement>('[role="menuitemradio"]');
radios.forEach((radio) => {
const id = radio.dataset.itemId;
const group = radio.dataset.radioGroup;
if (id && group && radio.getAttribute('aria-checked') === 'true') {
this.radioStates.set(group, id);
}
});
}
private getMenuForIndex(index: number): HTMLElement | null {
return this.querySelector(`[data-menubar-menu][data-menubar-index="${index}"]`);
}
private getMenuItems(menu: HTMLElement): HTMLElement[] {
// Get direct menu items (not submenu items)
// Note: Disabled items ARE included - they should be focusable but not activatable per APG
const items: HTMLElement[] = [];
const allItems = menu.querySelectorAll<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
);
allItems.forEach((item) => {
// Exclude items that are inside a nested submenu
const parentMenu = item.closest('[role="menu"]');
if (parentMenu === menu) {
items.push(item);
}
});
return items;
}
private openMenu(menubarIndex: number, focusPosition: 'first' | 'last' = 'first') {
// Close any open menu first
if (this.openMenuIndex >= 0 && this.openMenuIndex !== menubarIndex) {
this.closeMenu();
}
const trigger = this.menubarItems[menubarIndex];
const menu = this.getMenuForIndex(menubarIndex);
if (!trigger || !menu) return;
this.openMenuIndex = menubarIndex;
trigger.setAttribute('aria-expanded', 'true');
menu.setAttribute('aria-hidden', 'false');
// Focus first/last available item
const menuItems = this.getMenuItems(menu);
if (menuItems.length > 0) {
const targetIndex = focusPosition === 'first' ? 0 : menuItems.length - 1;
menuItems[targetIndex]?.focus();
}
}
private closeMenu() {
if (this.openMenuIndex < 0) return;
const trigger = this.menubarItems[this.openMenuIndex];
const menu = this.getMenuForIndex(this.openMenuIndex);
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
}
if (menu) {
menu.setAttribute('aria-hidden', 'true');
// Close any open submenus
const submenus = menu.querySelectorAll('[data-submenu]');
submenus.forEach((submenu) => {
submenu.setAttribute('aria-hidden', 'true');
const submenuTrigger = submenu
.closest('.apg-menubar-submenu-container')
?.querySelector('[data-submenu-trigger]');
submenuTrigger?.setAttribute('aria-expanded', 'false');
});
}
// Clear type-ahead
this.typeAheadBuffer = '';
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
this.openMenuIndex = -1;
}
private closeAllMenus() {
this.closeMenu();
}
private updateMenubarTabindex(newIndex: number) {
this.menubarItems.forEach((item, idx) => {
item.tabIndex = idx === newIndex ? 0 : -1;
});
this.focusedMenubarIndex = newIndex;
}
private handleClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
// Handle menubar trigger click
const menubarTrigger = target.closest('[data-menubar-trigger]');
if (menubarTrigger instanceof HTMLElement) {
const index = parseInt(menubarTrigger.dataset.menubarIndex || '0', 10);
if (this.openMenuIndex === index) {
this.closeMenu();
} else {
this.openMenu(index);
}
return;
}
// Handle submenu trigger click
const submenuTrigger = target.closest('[data-submenu-trigger]');
if (
submenuTrigger instanceof HTMLElement &&
submenuTrigger.getAttribute('aria-disabled') !== 'true'
) {
const isExpanded = submenuTrigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
const container = submenuTrigger.closest('.apg-menubar-submenu-container');
const submenu = container?.querySelector('[data-submenu]');
if (submenu instanceof HTMLElement) {
this.closeSubmenu(submenu);
}
} else {
this.openSubmenu(submenuTrigger);
}
return;
}
// Handle menu item click
const menuItem = target.closest(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
);
if (menuItem instanceof HTMLElement && menuItem.getAttribute('aria-disabled') !== 'true') {
this.activateItem(menuItem);
}
};
private handleMouseOver = (event: MouseEvent) => {
// Only handle hover switching when a menu is already open
if (this.openMenuIndex < 0) return;
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
const menubarTrigger = target.closest('[data-menubar-trigger]');
if (menubarTrigger instanceof HTMLElement) {
const index = parseInt(menubarTrigger.dataset.menubarIndex || '0', 10);
if (index !== this.openMenuIndex) {
this.openMenu(index);
}
}
};
private handleKeyDown = (event: KeyboardEvent) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
// Determine if we're on menubar or inside menu
const isOnMenubar = target.hasAttribute('data-menubar-trigger');
const isInMenu = target.closest('[data-menubar-menu]') || target.closest('[data-submenu]');
if (isOnMenubar) {
this.handleMenubarKeyDown(event, target);
} else if (isInMenu) {
this.handleMenuKeyDown(event, target);
}
};
private handleMenubarKeyDown(event: KeyboardEvent, target: HTMLElement) {
const index = parseInt(target.dataset.menubarIndex || '0', 10);
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
const nextIndex = (index + 1) % this.menubarItems.length;
this.updateMenubarTabindex(nextIndex);
this.menubarItems[nextIndex]?.focus();
if (this.openMenuIndex >= 0) {
this.openMenu(nextIndex);
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
const prevIndex = index === 0 ? this.menubarItems.length - 1 : index - 1;
this.updateMenubarTabindex(prevIndex);
this.menubarItems[prevIndex]?.focus();
if (this.openMenuIndex >= 0) {
this.openMenu(prevIndex);
}
break;
}
case 'ArrowDown':
case 'Enter':
case ' ': {
event.preventDefault();
this.openMenu(index, 'first');
break;
}
case 'ArrowUp': {
event.preventDefault();
this.openMenu(index, 'last');
break;
}
case 'Home': {
event.preventDefault();
this.updateMenubarTabindex(0);
this.menubarItems[0]?.focus();
break;
}
case 'End': {
event.preventDefault();
const lastIndex = this.menubarItems.length - 1;
this.updateMenubarTabindex(lastIndex);
this.menubarItems[lastIndex]?.focus();
break;
}
case 'Escape': {
event.preventDefault();
this.closeMenu();
break;
}
case 'Tab': {
// Close menus when Tab is pressed from menubar
this.closeAllMenus();
break;
}
}
}
private handleMenuKeyDown(event: KeyboardEvent, target: HTMLElement) {
const isSubmenuTrigger = target.hasAttribute('data-submenu-trigger');
// Get the current menu context
const currentMenu = target.closest('[role="menu"]');
if (!(currentMenu instanceof HTMLElement)) {
return;
}
const menuItems = this.getMenuItems(currentMenu);
const currentIndex = menuItems.indexOf(target);
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
if (currentIndex >= 0) {
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex]?.focus();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (currentIndex >= 0) {
const prevIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[prevIndex]?.focus();
}
break;
}
case 'ArrowRight': {
event.preventDefault();
if (isSubmenuTrigger) {
// Open submenu
this.openSubmenu(target);
} else {
// Move to next menubar item
const nextIndex = (this.openMenuIndex + 1) % this.menubarItems.length;
this.updateMenubarTabindex(nextIndex);
this.openMenu(nextIndex);
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
const parentSubmenu = target.closest('[data-submenu]');
if (parentSubmenu instanceof HTMLElement) {
// Close submenu and return to parent
this.closeSubmenu(parentSubmenu);
} else {
// Move to previous menubar item
const prevIndex =
this.openMenuIndex === 0 ? this.menubarItems.length - 1 : this.openMenuIndex - 1;
this.updateMenubarTabindex(prevIndex);
this.openMenu(prevIndex);
}
break;
}
case 'Home': {
event.preventDefault();
menuItems[0]?.focus();
break;
}
case 'End': {
event.preventDefault();
menuItems[menuItems.length - 1]?.focus();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
if (isSubmenuTrigger) {
this.openSubmenu(target);
} else {
this.activateItem(target);
}
break;
}
case 'Escape': {
event.preventDefault();
const parentSubmenu = target.closest('[data-submenu]');
if (parentSubmenu instanceof HTMLElement) {
this.closeSubmenu(parentSubmenu);
} else {
this.closeMenu();
this.menubarItems[this.focusedMenubarIndex]?.focus();
}
break;
}
case 'Tab': {
this.closeAllMenus();
break;
}
default: {
// Type-ahead
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.handleTypeAhead(event.key, menuItems, currentIndex);
}
}
}
}
private openSubmenu(trigger: HTMLElement) {
const container = trigger.closest('.apg-menubar-submenu-container');
const submenu = container?.querySelector('[data-submenu]');
if (!(submenu instanceof HTMLElement)) {
return;
}
trigger.setAttribute('aria-expanded', 'true');
submenu.setAttribute('aria-hidden', 'false');
// Focus first item in submenu
const items = this.getMenuItems(submenu);
items[0]?.focus();
}
private closeSubmenu(submenu: HTMLElement) {
const container = submenu.closest('.apg-menubar-submenu-container');
const trigger = container?.querySelector('[data-submenu-trigger]');
if (trigger instanceof HTMLElement) {
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
submenu.setAttribute('aria-hidden', 'true');
}
private activateItem(item: HTMLElement) {
// Disabled items should not be activatable
if (item.getAttribute('aria-disabled') === 'true') {
return;
}
const role = item.getAttribute('role');
const itemId = item.dataset.itemId;
if (role === 'menuitemcheckbox') {
// Toggle checkbox
const currentChecked = item.getAttribute('aria-checked') === 'true';
const newChecked = !currentChecked;
item.setAttribute('aria-checked', String(newChecked));
if (itemId) {
this.checkboxStates.set(itemId, newChecked);
}
this.dispatchEvent(
new CustomEvent('checkboxchange', {
detail: { itemId, checked: newChecked },
bubbles: true,
})
);
// Don't close menu for checkbox
return;
}
if (role === 'menuitemradio') {
// Update radio group
const group = item.dataset.radioGroup;
if (group) {
// Uncheck all radios in the group
const groupRadios = this.querySelectorAll<HTMLElement>(
`[role="menuitemradio"][data-radio-group="${group}"]`
);
groupRadios.forEach((radio) => {
radio.setAttribute('aria-checked', 'false');
});
// Check the selected one
item.setAttribute('aria-checked', 'true');
if (itemId) {
this.radioStates.set(group, itemId);
}
this.dispatchEvent(
new CustomEvent('radiochange', {
detail: { group, itemId },
bubbles: true,
})
);
}
// Don't close menu for radio
return;
}
// Regular menu item - dispatch event and close
if (itemId) {
this.dispatchEvent(
new CustomEvent('itemselect', {
detail: { itemId },
bubbles: true,
})
);
}
this.closeAllMenus();
this.menubarItems[this.focusedMenubarIndex]?.focus();
}
private handleTypeAhead(char: string, menuItems: HTMLElement[], currentIndex: number) {
if (menuItems.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 = currentIndex >= 0 ? (currentIndex + 1) % menuItems.length : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = currentIndex >= 0 ? (currentIndex + 1) % menuItems.length : 0;
} else {
searchStr = buffer;
startIndex = currentIndex >= 0 ? currentIndex : 0;
}
for (let i = 0; i < menuItems.length; i++) {
const index = (startIndex + i) % menuItems.length;
const item = menuItems[index];
const label = item.textContent?.trim().toLowerCase() || '';
if (label.startsWith(searchStr)) {
item.focus();
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleClickOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (this.openMenuIndex >= 0 && !this.contains(target)) {
this.closeAllMenus();
}
};
}
if (!customElements.get('apg-menubar')) {
customElements.define('apg-menubar', ApgMenubar);
}
</script> 使い方
Example
---
import Menubar, { type MenubarItem } from '@patterns/menubar/Menubar.astro';
import '@patterns/menubar/menubar.css';
const menuItems: MenubarItem[] = [
{
id: 'file',
label: 'File',
items: [
{ type: 'item', id: 'new', label: 'New' },
{ type: 'item', id: 'save', label: 'Save' },
],
},
{
id: 'edit',
label: 'Edit',
items: [
{ type: 'item', id: 'cut', label: 'Cut' },
{ type: 'item', id: 'copy', label: 'Copy' },
],
},
];
---
<Menubar items={menuItems} aria-label="Application" /> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | MenubarItem[] | required | トップレベルのメニュー項目の配列 |
aria-label | string | - | アクセシブルな名前(aria-labelledbyがない場合は必須) |
aria-labelledby | string | - | ラベリング要素のID |
class | string | '' | 追加のCSSクラス |
Astro実装はインタラクティブ性のためにWeb Components(Custom Elements)を使用しています。
<apg-menubar>カスタム要素がクライアント側で全てのキーボードナビゲーション、メニューの開閉、状態管理を処理します。 テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。Menubarコンポーネントは2層テスト戦略を使用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングと操作を検証します。これらのテストは分離された環境での正しいコンポーネント動作を確認します。
- HTML構造と要素階層
- 初期属性値(role、aria-haspopup、aria-expanded)
- クリックイベント処理
- CSSクラスの適用
E2Eテスト(Playwright)
4つのフレームワーク全体で実際のブラウザ環境でのコンポーネント動作を検証します。これらのテストは完全なブラウザコンテキストを必要とする操作をカバーします。
- キーボードナビゲーション(矢印キー、Enter、Space、Escape、Tab)
- サブメニューの開閉
- メニューバーの水平ナビゲーション
- チェックボックスとラジオアイテムの切り替え
- ホバーによるメニュー切り替え
- 先行入力検索
- フォーカス管理とローヴィングタブインデックス
- クロスフレームワークの一貫性
テストカテゴリ
高優先度: APG ARIA属性
| テスト | 説明 |
|---|---|
role="menubar" | コンテナがmenubarロールを持つ |
role="menu" | ドロップダウンがmenuロールを持つ |
role="menuitem" | アイテムがmenuitemロールを持つ |
role="menuitemcheckbox" | チェックボックスアイテムが正しいロールを持つ |
role="menuitemradio" | ラジオアイテムが正しいロールを持つ |
role="separator" | 区切り線がseparatorロールを持つ |
role="group" | ラジオグループがaria-labelを持つgroupロールを持つ |
role="none" | すべてのli要素がrole=noneを持つ |
aria-haspopup | サブメニューを持つアイテムがaria-haspopup=menuを持つ |
aria-expanded | サブメニューを持つアイテムが開閉状態を反映する |
aria-labelledby | サブメニューが親のmenuitemを参照する |
aria-checked | チェックボックス/ラジオアイテムが正しいチェック状態を持つ |
aria-hidden | メニューが閉じているときaria-hidden=true、開いているときfalse |
高優先度: APGキーボード操作 - メニューバー
| テスト | 説明 |
|---|---|
ArrowRight | 次のメニューバーアイテムにフォーカスを移動(ラップ) |
ArrowLeft | 前のメニューバーアイテムにフォーカスを移動(ラップ) |
ArrowDown | サブメニューを開き、最初のアイテムにフォーカス |
ArrowUp | サブメニューを開き、最後のアイテムにフォーカス |
Enter/Space | サブメニューを開く |
Home | 最初のメニューバーアイテムにフォーカスを移動 |
End | 最後のメニューバーアイテムにフォーカスを移動 |
Tab | すべてのメニューを閉じ、フォーカスを外に移動 |
高優先度: APGキーボード操作 - メニュー
| テスト | 説明 |
|---|---|
ArrowDown | 次のアイテムにフォーカスを移動(ラップ) |
ArrowUp | 前のアイテムにフォーカスを移動(ラップ) |
ArrowRight | サブメニューがあれば開く、または次のメニューバーメニューに移動 |
ArrowLeft | サブメニューを閉じる、または前のメニューバーメニューに移動 |
Enter/Space | アイテムを実行してメニューを閉じる |
Escape | メニューを閉じ、フォーカスを親に戻す |
Home/End | 最初/最後のアイテムにフォーカスを移動 |
高優先度: チェックボックスとラジオアイテム
| テスト | 説明 |
|---|---|
Checkbox toggle | Space/Enterでチェックボックスを切り替え |
Checkbox keeps open | 切り替えでメニューが閉じない |
aria-checked update | 切り替えでaria-checkedが更新される |
Radio select | Space/Enterでラジオを選択 |
Radio keeps open | 選択でメニューが閉じない |
Exclusive selection | グループ内で1つのラジオのみがチェック可能 |
高優先度: フォーカス管理(Roving Tabindex)
| テスト | 説明 |
|---|---|
tabIndex=0 | 最初のメニューバーアイテムがtabIndex=0を持つ |
tabIndex=-1 | 他のアイテムがtabIndex=-1を持つ |
Separator | 区切り線はフォーカス不可 |
Disabled items | 無効なアイテムはフォーカス可能だが実行不可 |
高優先度: 先行入力検索
| テスト | 説明 |
|---|---|
Character match | 入力された文字で始まるアイテムにフォーカス |
Wrap around | 検索が末尾から先頭にラップ |
Skip separator | 検索時に区切り線をスキップ |
Skip disabled | 検索時に無効なアイテムをスキップ |
Buffer reset | 500ms後にバッファがリセット |
高優先度: ポインタ操作
| テスト | 説明 |
|---|---|
Click open | メニューバーアイテムをクリックでメニューを開く |
Click toggle | 再度クリックでメニューを閉じる |
Hover switch | メニューが開いているとき他のアイテムにホバーでメニュー切り替え |
Item click | menuitemをクリックで実行してメニューを閉じる |
Click outside | 外側をクリックでメニューを閉じる |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe closed | メニューバーが閉じているとき違反なし |
axe menu open | メニューが開いているとき違反なし |
axe submenu open | サブメニューが開いているとき違反なし |
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
詳細は testing-strategy.md (opens in new tab) を参照してください。
Menubar.test.astro.ts
/**
* Menubar Web Component Tests
*
* Note: These are limited unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Menubar (Web Component)', () => {
let container: HTMLElement;
// Simplified mock for testing basic structure
// Full behavior tests are in E2E tests
class TestApgMenubar extends HTMLElement {
private menubar: HTMLUListElement | null = null;
private openMenuIndex: number = -1;
connectedCallback() {
requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.menubar = this.querySelector('[role="menubar"]');
if (!this.menubar) return;
this.menubar.addEventListener('click', this.handleClick);
this.menubar.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
this.menubar?.removeEventListener('click', this.handleClick);
this.menubar?.removeEventListener('keydown', this.handleKeyDown);
}
private getMenubarItems(): HTMLElement[] {
if (!this.menubar) return [];
return Array.from(
this.menubar.querySelectorAll<HTMLElement>(':scope > li > [role="menuitem"]')
);
}
private handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const menuitem = target.closest('[role="menuitem"]');
if (!menuitem) return;
const menubarItems = this.getMenubarItems();
const index = menubarItems.indexOf(menuitem as HTMLElement);
if (index >= 0) {
this.toggleMenu(index);
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const menuitem = target.closest('[role="menuitem"]');
if (!menuitem) return;
const menubarItems = this.getMenubarItems();
const index = menubarItems.indexOf(menuitem as HTMLElement);
if (index < 0) return;
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
const nextIndex = (index + 1) % menubarItems.length;
menubarItems[nextIndex]?.focus();
break;
}
case 'ArrowLeft': {
event.preventDefault();
const prevIndex = index === 0 ? menubarItems.length - 1 : index - 1;
menubarItems[prevIndex]?.focus();
break;
}
case 'ArrowDown':
case 'Enter':
case ' ': {
event.preventDefault();
this.openMenu(index);
break;
}
}
};
private toggleMenu(index: number) {
if (this.openMenuIndex === index) {
this.closeMenu();
} else {
this.openMenu(index);
}
}
private openMenu(index: number) {
const menubarItems = this.getMenubarItems();
const item = menubarItems[index];
if (!item) return;
// Close previous menu
if (this.openMenuIndex >= 0) {
const prevItem = menubarItems[this.openMenuIndex];
prevItem?.setAttribute('aria-expanded', 'false');
const prevMenu = prevItem?.parentElement?.querySelector('[role="menu"]');
prevMenu?.setAttribute('hidden', '');
}
// Open new menu
item.setAttribute('aria-expanded', 'true');
const menu = item.parentElement?.querySelector('[role="menu"]');
menu?.removeAttribute('hidden');
this.openMenuIndex = index;
// Focus first menu item
const firstItem = menu?.querySelector('[role="menuitem"]') as HTMLElement;
firstItem?.focus();
}
private closeMenu() {
if (this.openMenuIndex >= 0) {
const menubarItems = this.getMenubarItems();
const item = menubarItems[this.openMenuIndex];
item?.setAttribute('aria-expanded', 'false');
const menu = item?.parentElement?.querySelector('[role="menu"]');
menu?.setAttribute('hidden', '');
this.openMenuIndex = -1;
}
}
}
function createMenubarHTML() {
return `
<apg-menubar>
<ul role="menubar" aria-label="Application">
<li role="none">
<span
id="file-menu-trigger"
role="menuitem"
tabindex="0"
aria-haspopup="menu"
aria-expanded="false"
>
File
</span>
<ul role="menu" aria-labelledby="file-menu-trigger" hidden>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="new">New</span>
</li>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="open">Open</span>
</li>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="save">Save</span>
</li>
</ul>
</li>
<li role="none">
<span
id="edit-menu-trigger"
role="menuitem"
tabindex="-1"
aria-haspopup="menu"
aria-expanded="false"
>
Edit
</span>
<ul role="menu" aria-labelledby="edit-menu-trigger" hidden>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="cut">Cut</span>
</li>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="copy">Copy</span>
</li>
</ul>
</li>
</ul>
</apg-menubar>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
if (!customElements.get('apg-menubar')) {
customElements.define('apg-menubar', TestApgMenubar);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('Initial Rendering', () => {
it('renders with role="menubar" on container', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
expect(menubar).toBeTruthy();
});
it('renders with aria-label on menubar', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
expect(menubar?.getAttribute('aria-label')).toBe('Application');
});
it('has role="none" on all li elements', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const listItems = container.querySelectorAll('li');
listItems.forEach((li) => {
expect(li.getAttribute('role')).toBe('none');
});
});
it('has aria-haspopup="menu" on menubar items (not "true")', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
items?.forEach((item) => {
expect(item.getAttribute('aria-haspopup')).toBe('menu');
});
});
it('has aria-expanded="false" on menubar items', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
items?.forEach((item) => {
expect(item.getAttribute('aria-expanded')).toBe('false');
});
});
it('has hidden dropdown menus', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menus = container.querySelectorAll('[role="menu"]');
menus.forEach((menu) => {
expect(menu.hasAttribute('hidden')).toBe(true);
});
});
it('first menubar item has tabindex="0"', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
expect(items?.[0].getAttribute('tabindex')).toBe('0');
});
it('other menubar items have tabindex="-1"', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
expect(items?.[1].getAttribute('tabindex')).toBe('-1');
});
it('dropdown menu has aria-labelledby referencing parent menuitem', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileMenu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
expect(fileMenu).toBeTruthy();
expect(fileMenu?.getAttribute('role')).toBe('menu');
});
});
describe('Menu Open/Close', () => {
it('opens menu on menubar item click', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.click();
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
expect(menu?.hasAttribute('hidden')).toBe(false);
});
it('closes menu on second click (toggle)', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.click(); // Open
fileItem.click(); // Close
expect(fileItem.getAttribute('aria-expanded')).toBe('false');
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
expect(menu?.hasAttribute('hidden')).toBe(true);
});
it('closes previous menu when opening another', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
const editItem = container.querySelector('#edit-menu-trigger') as HTMLElement;
fileItem.click();
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
editItem.click();
expect(fileItem.getAttribute('aria-expanded')).toBe('false');
expect(editItem.getAttribute('aria-expanded')).toBe('true');
});
});
describe('Keyboard Navigation', () => {
it('ArrowRight moves to next menubar item', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
const editItem = container.querySelector('#edit-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
// Check that edit item would receive focus (jsdom limitation)
// In real browser, editItem.focus() is called
expect(editItem).toBeTruthy();
});
it('ArrowDown opens submenu', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
});
it('Enter opens submenu', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
});
it('Space opens submenu', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
});
});
describe('Menu Items', () => {
it('dropdown menu contains menuitem elements', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.click();
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
const items = menu?.querySelectorAll('[role="menuitem"]');
expect(items?.length).toBe(3);
expect(items?.[0].textContent?.trim()).toBe('New');
expect(items?.[1].textContent?.trim()).toBe('Open');
expect(items?.[2].textContent?.trim()).toBe('Save');
});
it('menu items have tabindex="-1" when menu opens', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
const items = menu?.querySelectorAll('[role="menuitem"]');
items?.forEach((item) => {
expect(item.getAttribute('tabindex')).toBe('-1');
});
});
});
}); リソース
- WAI-ARIA APG: Menubar パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist