Menubar
ドロップダウンメニュー、サブメニュー、チェックボックス、ラジオグループをサポートする、アプリケーションスタイルの水平メニューバー。
🤖 AI 実装ガイドデモ
フル機能のメニューバー
サブメニュー、チェックボックス、ラジオグループ、区切り線、無効な項目を含みます。
Last action: None
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
menubar | 水平コンテナ(<ul>) | トップレベルのメニューバー(常に表示) |
menu | 垂直コンテナ(<ul>) | ドロップダウンメニューまたはサブメニュー |
menuitem | アイテム(<span>) | 標準的なアクションアイテム |
menuitemcheckbox | チェックボックスアイテム | トグル可能なオプション |
menuitemradio | ラジオアイテム | グループ内の排他的なオプション |
separator | 区切り線(<hr>) | 視覚的な区切り(フォーカス不可) |
group | グループコンテナ | ラジオアイテムをラベル付きでグループ化 |
none | <li>要素 | スクリーンリーダーからリストセマンティクスを隠す |
WAI-ARIA menubar role (opens in new tab)
WAI-ARIA プロパティ(メニューバーアイテム)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
aria-haspopup | "menu" | はい* | アイテムがメニューを開くことを示す("true"ではなく"menu"を使用) |
aria-expanded | true | false | はい* | メニューが開いているかどうかを示す |
* サブメニューを持つアイテムのみ
WAI-ARIA プロパティ(メニュー/サブメニュー)
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-labelledby | menu | ID参照 | はい** | 親のmenuitemを参照する |
aria-label | menubar/menu | 文字列 | はい** | アクセシブルな名前を提供する |
aria-checked | checkbox/radio | true | false | はい | チェック状態を示す |
aria-disabled | menuitem | true | いいえ | アイテムが無効であることを示す |
aria-hidden | menu/submenu | true | false | はい | 閉じているときメニューをスクリーンリーダーから隠す |
** aria-labelledbyまたはaria-labelのいずれかがアクセシブルな名前のために必須です
キーボードサポート
メニューバーナビゲーション
| キー | アクション |
|---|---|
| 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 | 最後のアイテムにフォーカスを移動 |
| 文字を入力 | 先行入力: 入力された文字で始まるアイテムにフォーカスを移動 |
フォーカス管理
このコンポーネントは、フォーカス管理にRoving Tabindexパターンを使用します:
- 一度に1つのメニューバーアイテムのみが
tabindex="0"を持つ - その他のアイテムは
tabindex="-1"を持つ - 矢印キーでアイテム間のフォーカス移動(ラップあり)
- 無効なアイテムはフォーカス可能だが実行不可(APG推奨)
- 区切り線はフォーカス不可
- メニューが閉じると、フォーカスは呼び出し元に戻る
Menu-Button との違い
| 機能 | Menu-Button | Menubar |
|---|---|---|
| トップレベル構造 | <button>トリガー | <ul role="menubar">(常に表示) |
| 水平ナビゲーション | なし | メニューバーアイテム間で←/→ |
| ホバー動作 | なし | メニューが開いているときに自動切り替え |
<li>のロール | 常に指定されるとは限らない | すべてにrole="none"が必須 |
非表示状態
閉じているとき、メニューはaria-hidden="true"とCSSを使用して以下を実現します:
- スクリーンリーダーからメニューを隠す(
aria-hidden) - 視覚的にメニューを隠す(
visibility: hidden) - ポインター操作を防ぐ(
pointer-events: none) - 開閉時のスムーズなCSSアニメーションを可能にする
開いているとき、メニューはaria-hidden="false"とvisibility: visibleになります。
ソースコード
Menubar.svelte
<script lang="ts">
import { onDestroy, tick } from 'svelte';
// Menu item types
export interface MenuItemBase {
id: string;
label: string;
disabled?: boolean;
}
export interface MenuItemAction extends MenuItemBase {
type: 'item';
}
export interface MenuItemCheckbox extends MenuItemBase {
type: 'checkbox';
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export interface MenuItemRadio extends MenuItemBase {
type: 'radio';
checked?: boolean;
}
export interface MenuItemSeparator {
type: 'separator';
id: string;
}
export interface MenuItemRadioGroup {
type: 'radiogroup';
id: string;
name: string;
label: string;
items: MenuItemRadio[];
}
export interface MenuItemSubmenu extends MenuItemBase {
type: 'submenu';
items: MenuItem[];
}
export type MenuItem =
| MenuItemAction
| MenuItemCheckbox
| MenuItemRadio
| MenuItemSeparator
| MenuItemRadioGroup
| MenuItemSubmenu;
export interface MenubarItem {
id: string;
label: string;
items: MenuItem[];
}
interface MenubarProps {
items: MenubarItem[];
'aria-label'?: string;
'aria-labelledby'?: string;
onItemSelect?: (itemId: string) => void;
class?: string;
}
let {
items = [],
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
onItemSelect = () => {},
class: className = '',
...restProps
}: MenubarProps = $props();
// State
const instanceId = `menubar-${Math.random().toString(36).slice(2, 11)}`;
let menubarFocusIndex = $state(0);
let openMenubarIndex = $state(-1);
let openSubmenuPath = $state<string[]>([]);
let focusedItemPath = $state<string[]>([]);
let checkboxStates = $state<Map<string, boolean>>(new Map());
let radioStates = $state<Map<string, string>>(new Map());
let typeAheadBuffer = $state('');
let typeAheadTimeoutId: number | null = null;
const typeAheadTimeout = 500;
// Refs
let containerElement: HTMLUListElement;
let menubarItemRefs = new Map<number, HTMLSpanElement>();
let menuItemRefs = new Map<string, HTMLSpanElement>();
// Initialize checkbox/radio states
const initStates = () => {
const collectStates = (menuItems: MenuItem[]) => {
menuItems.forEach((item) => {
if (item.type === 'checkbox') {
checkboxStates.set(item.id, item.checked ?? false);
} else if (item.type === 'radiogroup') {
const checked = item.items.find((r) => r.checked);
if (checked) {
radioStates.set(item.name, checked.id);
}
} else if (item.type === 'submenu') {
collectStates(item.items);
}
});
};
items.forEach((menubarItem) => collectStates(menubarItem.items));
};
initStates();
let isMenuOpen = $derived(openMenubarIndex >= 0);
onDestroy(() => {
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
}
if (typeof document !== 'undefined') {
document.removeEventListener('mousedown', handleClickOutside);
}
});
// Focus effect for menu items
$effect(() => {
const path = focusedItemPath;
if (path.length === 0) return;
const focusedId = path[path.length - 1];
tick().then(() => {
if (focusedItemPath.length > 0) {
menuItemRefs.get(focusedId)?.focus();
}
});
});
// Click outside effect
$effect(() => {
if (typeof document === 'undefined') return;
if (isMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
});
function handleClickOutside(event: MouseEvent) {
if (containerElement && !containerElement.contains(event.target as Node)) {
closeAllMenus();
}
}
// Ref tracking actions
function trackMenubarItemRef(node: HTMLSpanElement, index: number) {
menubarItemRefs.set(index, node);
return {
destroy() {
menubarItemRefs.delete(index);
},
};
}
function trackMenuItemRef(node: HTMLSpanElement, itemId: string) {
menuItemRefs.set(itemId, node);
return {
destroy() {
menuItemRefs.delete(itemId);
},
};
}
// Helper functions
function closeAllMenus() {
openMenubarIndex = -1;
openSubmenuPath = [];
focusedItemPath = [];
typeAheadBuffer = '';
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
typeAheadTimeoutId = null;
}
}
// Get first focusable item from menu items
function getFirstFocusableItem(menuItems: MenuItem[]): MenuItem | null {
for (const item of menuItems) {
if (item.type === 'separator') continue;
if (item.type === 'radiogroup') {
const enabledRadio = item.items.find((r) => !r.disabled);
if (enabledRadio) return enabledRadio;
continue;
}
if ('disabled' in item && item.disabled) continue;
return item;
}
return null;
}
// Get all focusable items including radios from radiogroups
function getAllFocusableItems(menuItems: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
for (const item of menuItems) {
if (item.type === 'separator') continue;
if (item.type === 'radiogroup') {
result.push(...item.items.filter((r) => !r.disabled));
} else if (!('disabled' in item && item.disabled)) {
result.push(item);
}
}
return result;
}
function openMenubarMenu(index: number, focusPosition: 'first' | 'last' = 'first') {
const menubarItem = items[index];
if (!menubarItem) return;
const focusableItems = getAllFocusableItems(menubarItem.items);
let focusedId = '';
if (focusPosition === 'first') {
focusedId = focusableItems[0]?.id ?? '';
} else {
focusedId = focusableItems[focusableItems.length - 1]?.id ?? '';
}
openMenubarIndex = index;
openSubmenuPath = [];
focusedItemPath = focusedId ? [focusedId] : [];
menubarFocusIndex = index;
}
function getFocusableItems(menuItems: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
menuItems.forEach((item) => {
if (item.type === 'separator') return;
if (item.type === 'radiogroup') {
result.push(...item.items);
} else {
result.push(item);
}
});
return result;
}
function handleTypeAhead(char: string, focusableItems: MenuItem[]) {
const enabledItems = focusableItems.filter((item) => !('disabled' in item && item.disabled));
if (enabledItems.length === 0) return;
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
}
typeAheadBuffer += char.toLowerCase();
const buffer = typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let searchStr: string;
let startIndex: number;
const currentId = focusedItemPath[focusedItemPath.length - 1];
const currentIndex = enabledItems.findIndex((item) => item.id === currentId);
if (isSameChar) {
typeAheadBuffer = buffer[0];
searchStr = buffer[0];
startIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledItems.length : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledItems.length : 0;
} else {
searchStr = buffer;
startIndex = currentIndex >= 0 ? currentIndex : 0;
}
for (let i = 0; i < enabledItems.length; i++) {
const index = (startIndex + i) % enabledItems.length;
const item = enabledItems[index];
if ('label' in item && item.label.toLowerCase().startsWith(searchStr)) {
focusedItemPath = [...focusedItemPath.slice(0, -1), item.id];
break;
}
}
typeAheadTimeoutId = window.setTimeout(() => {
typeAheadBuffer = '';
typeAheadTimeoutId = null;
}, typeAheadTimeout);
}
async function handleMenubarKeyDown(event: KeyboardEvent, index: number) {
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
const nextIndex = (index + 1) % items.length;
menubarFocusIndex = nextIndex;
if (isMenuOpen) {
openMenubarMenu(nextIndex, 'first');
} else {
await tick();
menubarItemRefs.get(nextIndex)?.focus();
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
const prevIndex = index === 0 ? items.length - 1 : index - 1;
menubarFocusIndex = prevIndex;
if (isMenuOpen) {
openMenubarMenu(prevIndex, 'first');
} else {
await tick();
menubarItemRefs.get(prevIndex)?.focus();
}
break;
}
case 'ArrowDown':
case 'Enter':
case ' ': {
event.preventDefault();
openMenubarMenu(index, 'first');
break;
}
case 'ArrowUp': {
event.preventDefault();
openMenubarMenu(index, 'last');
break;
}
case 'Home': {
event.preventDefault();
menubarFocusIndex = 0;
await tick();
menubarItemRefs.get(0)?.focus();
break;
}
case 'End': {
event.preventDefault();
const lastIndex = items.length - 1;
menubarFocusIndex = lastIndex;
await tick();
menubarItemRefs.get(lastIndex)?.focus();
break;
}
case 'Escape': {
event.preventDefault();
closeAllMenus();
break;
}
case 'Tab': {
closeAllMenus();
break;
}
}
}
function handleMenubarClick(index: number) {
if (openMenubarIndex === index) {
closeAllMenus();
} else {
openMenubarMenu(index, 'first');
}
}
function handleMenubarHover(index: number) {
if (isMenuOpen && openMenubarIndex !== index) {
openMenubarMenu(index, 'first');
}
}
function activateMenuItem(item: MenuItem, radioGroupName?: string) {
if ('disabled' in item && item.disabled) return;
if (item.type === 'item') {
onItemSelect(item.id);
closeAllMenus();
tick().then(() => {
menubarItemRefs.get(openMenubarIndex)?.focus();
});
} else if (item.type === 'checkbox') {
const newChecked = !checkboxStates.get(item.id);
checkboxStates.set(item.id, newChecked);
checkboxStates = new Map(checkboxStates); // trigger reactivity
item.onCheckedChange?.(newChecked);
// Menu stays open
} else if (item.type === 'radio' && radioGroupName) {
radioStates.set(radioGroupName, item.id);
radioStates = new Map(radioStates); // trigger reactivity
// Menu stays open
} else if (item.type === 'submenu') {
// Open submenu and focus first item
const firstItem = getFirstFocusableItem(item.items);
openSubmenuPath = [...openSubmenuPath, item.id];
if (firstItem) {
focusedItemPath = [...focusedItemPath, firstItem.id];
}
}
}
async function handleMenuKeyDown(
event: KeyboardEvent,
item: MenuItem,
menuItems: MenuItem[],
isSubmenu: boolean,
radioGroupName?: string
) {
const focusableItems = getFocusableItems(menuItems);
const enabledItems = focusableItems.filter((i) => !('disabled' in i && i.disabled));
const currentIndex = focusableItems.findIndex((i) => i.id === item.id);
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
let nextIndex = currentIndex;
do {
nextIndex = (nextIndex + 1) % focusableItems.length;
} while (
focusableItems[nextIndex] &&
'disabled' in focusableItems[nextIndex] &&
(focusableItems[nextIndex] as MenuItemBase).disabled &&
nextIndex !== currentIndex
);
const nextItem = focusableItems[nextIndex];
if (nextItem) {
focusedItemPath = [...focusedItemPath.slice(0, -1), nextItem.id];
}
break;
}
case 'ArrowUp': {
event.preventDefault();
let prevIndex = currentIndex;
do {
prevIndex = prevIndex === 0 ? focusableItems.length - 1 : prevIndex - 1;
} while (
focusableItems[prevIndex] &&
'disabled' in focusableItems[prevIndex] &&
(focusableItems[prevIndex] as MenuItemBase).disabled &&
prevIndex !== currentIndex
);
const prevItem = focusableItems[prevIndex];
if (prevItem) {
focusedItemPath = [...focusedItemPath.slice(0, -1), prevItem.id];
}
break;
}
case 'ArrowRight': {
event.preventDefault();
if (item.type === 'submenu') {
// Open submenu and focus first item
const firstItem = getFirstFocusableItem(item.items);
openSubmenuPath = [...openSubmenuPath, item.id];
if (firstItem) {
focusedItemPath = [...focusedItemPath, firstItem.id];
}
} else if (!isSubmenu) {
const nextMenubarIndex = (openMenubarIndex + 1) % items.length;
openMenubarMenu(nextMenubarIndex, 'first');
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
if (isSubmenu) {
openSubmenuPath = openSubmenuPath.slice(0, -1);
focusedItemPath = focusedItemPath.slice(0, -1);
} else {
const prevMenubarIndex = openMenubarIndex === 0 ? items.length - 1 : openMenubarIndex - 1;
openMenubarMenu(prevMenubarIndex, 'first');
}
break;
}
case 'Home': {
event.preventDefault();
const firstEnabled = enabledItems[0];
if (firstEnabled) {
focusedItemPath = [...focusedItemPath.slice(0, -1), firstEnabled.id];
}
break;
}
case 'End': {
event.preventDefault();
const lastEnabled = enabledItems[enabledItems.length - 1];
if (lastEnabled) {
focusedItemPath = [...focusedItemPath.slice(0, -1), lastEnabled.id];
}
break;
}
case 'Escape': {
event.preventDefault();
if (isSubmenu) {
openSubmenuPath = openSubmenuPath.slice(0, -1);
focusedItemPath = focusedItemPath.slice(0, -1);
} else {
const menubarIndex = openMenubarIndex;
closeAllMenus();
await tick();
menubarItemRefs.get(menubarIndex)?.focus();
}
break;
}
case 'Tab': {
closeAllMenus();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
activateMenuItem(item, radioGroupName);
break;
}
default: {
const { key, ctrlKey, metaKey, altKey } = event;
if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
event.preventDefault();
handleTypeAhead(key, focusableItems);
}
}
}
}
// Check if submenu is expanded
function isSubmenuExpanded(itemId: string): boolean {
return openSubmenuPath.includes(itemId);
}
// Check if item is focused (only the last item in the path)
function isItemFocused(itemId: string): boolean {
return focusedItemPath[focusedItemPath.length - 1] === itemId;
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<ul
bind:this={containerElement}
role="menubar"
class="apg-menubar {className}"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...restProps}
>
{#each items as menubarItem, index}
<li role="none">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<span
id="{instanceId}-menubar-{menubarItem.id}"
use:trackMenubarItemRef={index}
role="menuitem"
aria-haspopup="menu"
aria-expanded={openMenubarIndex === index}
tabindex={index === menubarFocusIndex ? 0 : -1}
class="apg-menubar-trigger"
onclick={() => handleMenubarClick(index)}
onkeydown={(e) => handleMenubarKeyDown(e, index)}
onmouseenter={() => handleMenubarHover(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>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<ul
id="{instanceId}-menu-{menubarItem.id}"
role="menu"
aria-labelledby="{instanceId}-menubar-{menubarItem.id}"
class="apg-menubar-menu"
aria-hidden={openMenubarIndex !== index}
>
{#if openMenubarIndex === index}
{#each menubarItem.items as item}
{#if item.type === 'separator'}
<li role="none">
<hr role="separator" class="apg-menubar-separator" />
</li>
{:else if item.type === 'radiogroup'}
<li role="none">
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<ul role="group" aria-label={item.label} class="apg-menubar-group">
{#each item.items as radioItem}
<li role="none">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<span
use:trackMenuItemRef={radioItem.id}
role="menuitemradio"
aria-checked={radioStates.get(item.name) === radioItem.id}
aria-disabled={radioItem.disabled || undefined}
tabindex={isItemFocused(radioItem.id) ? 0 : -1}
class="apg-menubar-menuitem apg-menubar-menuitemradio"
onclick={() => activateMenuItem(radioItem, item.name)}
onkeydown={(e) =>
handleMenuKeyDown(e, radioItem, menubarItem.items, false, item.name)}
>
{radioItem.label}
</span>
</li>
{/each}
</ul>
</li>
{:else if item.type === 'checkbox'}
<li role="none">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<span
use:trackMenuItemRef={item.id}
role="menuitemcheckbox"
aria-checked={checkboxStates.get(item.id) ?? false}
aria-disabled={item.disabled || undefined}
tabindex={isItemFocused(item.id) ? 0 : -1}
class="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
onclick={() => activateMenuItem(item)}
onkeydown={(e) => handleMenuKeyDown(e, item, menubarItem.items, false)}
>
{item.label}
</span>
</li>
{:else if item.type === 'submenu'}
<li role="none">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<span
id="{instanceId}-menuitem-{item.id}"
use:trackMenuItemRef={item.id}
role="menuitem"
aria-haspopup="menu"
aria-expanded={isSubmenuExpanded(item.id)}
aria-disabled={item.disabled || undefined}
tabindex={isItemFocused(item.id) ? 0 : -1}
class="apg-menubar-menuitem apg-menubar-submenu-trigger"
onclick={() => activateMenuItem(item)}
onkeydown={(e) => handleMenuKeyDown(e, item, menubarItem.items, false)}
>
{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>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<ul
id="{instanceId}-submenu-{item.id}"
role="menu"
aria-labelledby="{instanceId}-menuitem-{item.id}"
class="apg-menubar-submenu"
aria-hidden={!isSubmenuExpanded(item.id)}
>
{#if isSubmenuExpanded(item.id)}
{#each item.items as subItem}
{#if subItem.type === 'separator'}
<li role="none">
<hr role="separator" class="apg-menubar-separator" />
</li>
{:else if subItem.type !== 'radiogroup'}
<li role="none">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<span
use:trackMenuItemRef={subItem.id}
role="menuitem"
aria-disabled={subItem.disabled || undefined}
tabindex={isItemFocused(subItem.id) ? 0 : -1}
class="apg-menubar-menuitem"
onclick={() => activateMenuItem(subItem)}
onkeydown={(e) => handleMenuKeyDown(e, subItem, item.items, true)}
>
{subItem.label}
</span>
</li>
{/if}
{/each}
{/if}
</ul>
</li>
{:else}
<li role="none">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<span
use:trackMenuItemRef={item.id}
role="menuitem"
aria-disabled={item.disabled || undefined}
tabindex={isItemFocused(item.id) ? 0 : -1}
class="apg-menubar-menuitem"
onclick={() => activateMenuItem(item)}
onkeydown={(e) => handleMenuKeyDown(e, item, menubarItem.items, false)}
>
{item.label}
</span>
</li>
{/if}
{/each}
{/if}
</ul>
</li>
{/each}
</ul> 使い方
使用例
<script lang="ts">
import Menubar, { type MenubarItem } from './Menubar.svelte';
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' },
],
},
];
function handleItemSelect(id: string) {
console.log('Selected:', id);
}
</script>
<Menubar items={menuItems} aria-label="Application" onItemSelect={handleItemSelect} /> API
Props
| Prop | 型 | 説明 |
|---|---|---|
items | MenubarItem[] | トップレベルのメニュー項目の配列 |
aria-label | string | アクセシブルな名前 |
onItemSelect | (id: string) => void | 項目がアクティブ化されたときのコールバック |
テスト
テストは、キーボード操作、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 | 最初/最後のアイテムにフォーカスを移動 |
高優先度: チェックボックスとラジオアイテム
| テスト | 説明 |
|---|---|
チェックボックス切り替え | Space/Enterでチェックボックスを切り替え |
チェックボックスはメニューを開いたまま | 切り替えでメニューが閉じない |
aria-checked更新 | 切り替えでaria-checkedが更新される |
ラジオ選択 | Space/Enterでラジオを選択 |
ラジオはメニューを開いたまま | 選択でメニューが閉じない |
排他的選択 | グループ内で1つのラジオのみがチェック可能 |
高優先度: フォーカス管理(Roving Tabindex)
| テスト | 説明 |
|---|---|
tabIndex=0 | 最初のメニューバーアイテムがtabIndex=0を持つ |
tabIndex=-1 | 他のアイテムがtabIndex=-1を持つ |
区切り線 | 区切り線はフォーカス不可 |
無効なアイテム | 無効なアイテムはフォーカス可能だが実行不可 |
高優先度: 先行入力検索
| テスト | 説明 |
|---|---|
文字マッチ | 入力された文字で始まるアイテムにフォーカス |
ラップアラウンド | 検索が末尾から先頭にラップ |
区切り線スキップ | 検索時に区切り線をスキップ |
無効スキップ | 検索時に無効なアイテムをスキップ |
バッファリセット | 500ms後にバッファがリセット |
高優先度: ポインタ操作
| テスト | 説明 |
|---|---|
クリックで開く | メニューバーアイテムをクリックでメニューを開く |
クリックで切り替え | 再度クリックでメニューを閉じる |
ホバー切り替え | メニューが開いているとき他のアイテムにホバーでメニュー切り替え |
アイテムクリック | menuitemをクリックで実行してメニューを閉じる |
外側クリック | 外側をクリックでメニューを閉じる |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe 閉じた状態 | メニューバーが閉じているとき違反なし |
axe メニュー開いた状態 | メニューが開いているとき違反なし |
axe サブメニュー開いた状態 | サブメニューが開いているとき違反なし |
テストツール
- 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) を参照してください。