APG Patterns
English GitHub
English GitHub

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 サブメニュー開いた状態 サブメニューが開いているとき違反なし

テストツール

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

リソース