APG Patterns
English
English

メニューバー

ドロップダウンメニュー、サブメニュー、チェックボックス、ラジオグループをサポートする、アプリケーションスタイルの水平メニューバー。

デモ

Last action: None

デモのみ表示 →

アクセシビリティ

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.svelte
<script lang="ts">
  import { onDestroy, tick } from 'svelte';
  import { SvelteMap } from 'svelte/reactivity';

  // 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[]>([]);
  // eslint-disable-next-line svelte/valid-compile -- SvelteMap is self-reactive, no $state() needed
  let checkboxStates = new SvelteMap<string, boolean>();
  // eslint-disable-next-line svelte/valid-compile -- SvelteMap is self-reactive, no $state() needed
  let radioStates = new SvelteMap<string, string>();
  let typeAheadBuffer = $state('');
  let typeAheadTimeoutId: number | null = null;
  const typeAheadTimeout = 500;

  // Refs
  let containerElement: HTMLUListElement;
  let menubarItemRefs = new SvelteMap<number, HTMLSpanElement>();
  let menuItemRefs = new SvelteMap<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 SvelteMap(checkboxStates); // trigger reactivity
      item.onCheckedChange?.(newChecked);
      // Menu stays open
    } else if (item.type === 'radio' && radioGroupName) {
      radioStates.set(radioGroupName, item.id);
      radioStates = new SvelteMap(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>

<ul
  bind:this={containerElement}
  role="menubar"
  class="apg-menubar {className}"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  {...restProps}
>
  {#each items as menubarItem, index (menubarItem.id)}
    <li role="none">
      <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>
      <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 (item.id)}
            {#if item.type === 'separator'}
              <li role="none">
                <hr class="apg-menubar-separator" />
              </li>
            {:else if item.type === 'radiogroup'}
              <li role="none">
                <ul role="group" aria-label={item.label} class="apg-menubar-group">
                  {#each item.items as radioItem (radioItem.id)}
                    <li role="none">
                      <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">
                <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">
                <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>
                <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 (subItem.id)}
                      {#if subItem.type === 'separator'}
                        <li role="none">
                          <hr class="apg-menubar-separator" />
                        </li>
                      {:else if subItem.type !== 'radiogroup'}
                        <li role="none">
                          <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">
                <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>

使い方

Example
<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' },
      ],
    },
    {
      id: 'edit',
      label: 'Edit',
      items: [
        { type: 'item', id: 'cut', label: 'Cut' },
        { type: 'item', id: 'copy', label: 'Copy' },
      ],
    },
  ];

  function handleItemSelect(id: string) {
    console.log('Selected:', id);
  }
</script>

<Menubar
  items={menuItems}
  aria-label="Application"
  onItemSelect={handleItemSelect}
/>

API

プロパティ デフォルト 説明
items MenubarItem[] required トップレベルのメニュー項目の配列
aria-label string - アクセシブルな名前(aria-labelledbyがない場合は必須)
aria-labelledby string - ラベリング要素のID
onItemSelect (id: string) => void - 項目がアクティブ化されたときのコールバック
class string '' 追加のCSSクラス

テスト

テストは、キーボード操作、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 サブメニューが開いているとき違反なし

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

Menubar.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi, afterEach } from 'vitest';
import Menubar from './Menubar.svelte';
import MenubarTestWrapper from './MenubarTestWrapper.svelte';

afterEach(() => {
  vi.useRealTimers();
});

// Helper function to create basic menubar items
const createBasicItems = () => [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      { type: 'item', id: 'open', label: 'Open' },
      { type: 'item', id: 'save', label: 'Save' },
    ],
  },
  {
    id: 'edit',
    label: 'Edit',
    items: [
      { type: 'item', id: 'cut', label: 'Cut' },
      { type: 'item', id: 'copy', label: 'Copy' },
      { type: 'item', id: 'paste', label: 'Paste' },
    ],
  },
  {
    id: 'view',
    label: 'View',
    items: [
      { type: 'item', id: 'zoom-in', label: 'Zoom In' },
      { type: 'item', id: 'zoom-out', label: 'Zoom Out' },
    ],
  },
];

// Items with separator
const createItemsWithSeparator = () => [
  {
    id: 'file',
    label: 'File',
    items: [
      { type: 'item', id: 'new', label: 'New' },
      { type: 'separator', id: 'sep1' },
      { type: 'item', id: 'save', label: 'Save' },
    ],
  },
];

// Items with checkbox and radio
const createItemsWithCheckboxRadio = () => [
  {
    id: 'view',
    label: 'View',
    items: [
      { type: 'checkbox', id: 'auto-save', label: 'Auto Save', checked: false },
      { type: 'checkbox', id: 'word-wrap', label: 'Word Wrap', checked: true },
      { type: 'separator', id: 'sep1' },
      {
        type: 'radiogroup',
        id: 'theme-group',
        name: 'theme',
        label: 'Theme',
        items: [
          { type: 'radio', id: 'light', label: 'Light', checked: true },
          { type: 'radio', id: 'dark', label: 'Dark', checked: false },
        ],
      },
    ],
  },
];

describe('Menubar (Svelte)', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG ARIA Attributes', () => {
    it('has role="menubar" on container', () => {
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });
      expect(screen.getByRole('menubar')).toBeInTheDocument();
    });

    it('has role="menu" on dropdown', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);

      expect(screen.getByRole('menu')).toBeInTheDocument();
    });

    it('has role="none" on li elements', () => {
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const listItems = document.querySelectorAll('li');
      listItems.forEach((li) => {
        expect(li).toHaveAttribute('role', 'none');
      });
    });

    it('has aria-haspopup="menu" on items with submenu', () => {
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      expect(fileItem).toHaveAttribute('aria-haspopup', 'menu');
    });

    it('updates aria-expanded when submenu opens', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);

      expect(fileItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('has role="separator" on dividers', async () => {
      const user = userEvent.setup();
      render(Menubar, {
        props: { items: createItemsWithSeparator(), 'aria-label': 'Application' },
      });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);

      expect(screen.getByRole('separator')).toBeInTheDocument();
    });

    it('has role="group" on radio groups with aria-label', async () => {
      const user = userEvent.setup();
      render(Menubar, {
        props: { items: createItemsWithCheckboxRadio(), 'aria-label': 'Application' },
      });

      const viewItem = screen.getByRole('menuitem', { name: 'View' });
      await user.click(viewItem);

      const group = screen.getByRole('group', { name: 'Theme' });
      expect(group).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Menubar
  describe('APG Keyboard Interaction - Menubar', () => {
    it('ArrowRight moves to next menubar item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{ArrowRight}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
      });
    });

    it('ArrowDown opens submenu and focuses first item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{ArrowDown}');

      await vi.waitFor(() => {
        expect(fileItem).toHaveAttribute('aria-expanded', 'true');
        expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
      });
    });

    it('Enter opens submenu', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{Enter}');

      await vi.waitFor(() => {
        expect(fileItem).toHaveAttribute('aria-expanded', 'true');
        expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
      });
    });

    it('ArrowUp opens submenu and focuses last item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{ArrowUp}');

      await vi.waitFor(() => {
        expect(fileItem).toHaveAttribute('aria-expanded', 'true');
        expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
      });
    });

    it('Home moves to first menubar item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const viewItem = screen.getByRole('menuitem', { name: 'View' });
      viewItem.focus();
      await user.keyboard('{Home}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'File' })).toHaveFocus();
      });
    });

    it('End moves to last menubar item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      fileItem.focus();
      await user.keyboard('{End}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'View' })).toHaveFocus();
      });
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Menu
  describe('APG Keyboard Interaction - Menu', () => {
    it('Escape closes menu and returns focus to menubar', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);
      expect(fileItem).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Escape}');

      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
      expect(fileItem).toHaveFocus();
    });

    it('Enter activates menuitem and closes menu', async () => {
      const user = userEvent.setup();
      const onItemSelect = vi.fn();
      render(Menubar, {
        props: { items: createBasicItems(), 'aria-label': 'Application', onItemSelect },
      });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);

      const newItem = screen.getByRole('menuitem', { name: 'New' });
      newItem.focus();
      await user.keyboard('{Enter}');

      expect(onItemSelect).toHaveBeenCalledWith('new');
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🔴 High Priority: Checkbox and Radio Items
  describe('Checkbox and Radio Items', () => {
    it('Space on checkbox does not close menu', async () => {
      const user = userEvent.setup();
      render(Menubar, {
        props: { items: createItemsWithCheckboxRadio(), 'aria-label': 'Application' },
      });

      const viewItem = screen.getByRole('menuitem', { name: 'View' });
      await user.click(viewItem);

      const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
      autoSave.focus();
      await user.keyboard(' ');

      // Menu should still be open
      expect(viewItem).toHaveAttribute('aria-expanded', 'true');
    });

    it('only one radio in group can be checked', async () => {
      const user = userEvent.setup();
      render(Menubar, {
        props: { items: createItemsWithCheckboxRadio(), 'aria-label': 'Application' },
      });

      const viewItem = screen.getByRole('menuitem', { name: 'View' });
      await user.click(viewItem);

      const lightRadio = screen.getByRole('menuitemradio', { name: 'Light' });
      const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });

      expect(lightRadio).toHaveAttribute('aria-checked', 'true');
      expect(darkRadio).toHaveAttribute('aria-checked', 'false');

      darkRadio.focus();
      await user.keyboard(' ');

      await vi.waitFor(() => {
        expect(lightRadio).toHaveAttribute('aria-checked', 'false');
        expect(darkRadio).toHaveAttribute('aria-checked', 'true');
      });
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('first menubar item has tabIndex="0"', () => {
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      expect(fileItem).toHaveAttribute('tabindex', '0');
    });

    it('other menubar items have tabIndex="-1"', () => {
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const editItem = screen.getByRole('menuitem', { name: 'Edit' });
      expect(editItem).toHaveAttribute('tabindex', '-1');
    });
  });

  // 🔴 High Priority: Pointer Interaction
  describe('Pointer Interaction', () => {
    it('click on menubar item opens menu', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);

      expect(fileItem).toHaveAttribute('aria-expanded', 'true');
      expect(screen.getByRole('menu')).toBeInTheDocument();
    });

    it('click outside closes menu', async () => {
      const user = userEvent.setup();
      render(MenubarTestWrapper, { props: { items: createBasicItems() } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);
      expect(fileItem).toHaveAttribute('aria-expanded', 'true');

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations when closed', async () => {
      const { container } = render(Menubar, {
        props: { items: createBasicItems(), 'aria-label': 'Application' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with menu open', async () => {
      const user = userEvent.setup();
      const { container } = render(Menubar, {
        props: { items: createBasicItems(), 'aria-label': 'Application' },
      });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      await user.click(fileItem);

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });
});

リソース