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.vue
<template>
  <ul
    ref="containerRef"
    role="menubar"
    :class="`apg-menubar ${className}`.trim()"
    :aria-label="ariaLabel"
    :aria-labelledby="ariaLabelledby"
  >
    <li v-for="(menubarItem, index) in items" :key="menubarItem.id" role="none">
      <span
        :id="`${instanceId}-menubar-${menubarItem.id}`"
        :ref="(el) => setMenubarItemRef(index, el)"
        role="menuitem"
        aria-haspopup="menu"
        :aria-expanded="state.openMenubarIndex === index"
        :tabindex="index === menubarFocusIndex ? 0 : -1"
        class="apg-menubar-trigger"
        @click="handleMenubarClick(index)"
        @keydown="(e) => handleMenubarKeyDown(e, index)"
        @mouseenter="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="state.openMenubarIndex !== index"
      >
        <template v-if="state.openMenubarIndex === index">
          <MenuItems
            :items="menubarItem.items"
            :parentId="menubarItem.id"
            :isSubmenu="false"
            :instanceId="instanceId"
            :state="state"
            :checkboxStates="checkboxStates"
            :radioStates="radioStates"
            :menuItemRefs="menuItemRefs"
          />
        </template>
      </ul>
    </li>
  </ul>
</template>

<script setup lang="ts">
import {
  ref,
  reactive,
  computed,
  onUnmounted,
  nextTick,
  watch,
  useId,
  h,
  type FunctionalComponent,
} from 'vue';

// 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 MenubarPropsBase {
  items: MenubarItem[];
  className?: string;
}

type MenubarProps = MenubarPropsBase &
  (
    | { 'aria-label': string; 'aria-labelledby'?: never }
    | { 'aria-label'?: never; 'aria-labelledby': string }
  );

const props = withDefaults(defineProps<MenubarProps>(), {
  className: '',
});

const emit = defineEmits<{
  itemSelect: [itemId: string];
}>();

defineOptions({
  inheritAttrs: false,
});

// State
interface MenuState {
  openMenubarIndex: number;
  openSubmenuPath: string[];
  focusedItemPath: string[];
}

const instanceId = useId();
const containerRef = ref<HTMLUListElement>();
const menubarItemRefs = ref<Record<number, HTMLSpanElement>>({});
const menuItemRefs = ref<Record<string, HTMLSpanElement>>({});
const menubarFocusIndex = ref(0);
const typeAheadBuffer = ref('');
const typeAheadTimeoutId = ref<number | null>(null);
const typeAheadTimeout = 500;

const state = reactive<MenuState>({
  openMenubarIndex: -1,
  openSubmenuPath: [],
  focusedItemPath: [],
});

// Checkbox and radio states
const checkboxStates = ref<Map<string, boolean>>(new Map());
const radioStates = ref<Map<string, string>>(new Map());

// Initialize checkbox/radio states
const initStates = () => {
  const collectStates = (menuItems: MenuItem[]) => {
    menuItems.forEach((item) => {
      if (item.type === 'checkbox') {
        checkboxStates.value.set(item.id, item.checked ?? false);
      } else if (item.type === 'radiogroup') {
        const checked = item.items.find((r) => r.checked);
        if (checked) {
          radioStates.value.set(item.name, checked.id);
        }
        item.items.forEach((radio) => {
          // Track individual radio items too
        });
      } else if (item.type === 'submenu') {
        collectStates(item.items);
      }
    });
  };
  props.items.forEach((menubarItem) => collectStates(menubarItem.items));
};
initStates();

// Computed
const ariaLabel = computed(() => props['aria-label']);
const ariaLabelledby = computed(() => props['aria-labelledby']);
const isMenuOpen = computed(() => state.openMenubarIndex >= 0);

// Helper to set refs
const setMenubarItemRef = (index: number, el: unknown) => {
  if (el instanceof HTMLSpanElement) {
    menubarItemRefs.value[index] = el;
  } else if (el === null) {
    delete menubarItemRefs.value[index];
  }
};

// Close all menus
const closeAllMenus = () => {
  state.openMenubarIndex = -1;
  state.openSubmenuPath = [];
  state.focusedItemPath = [];
  typeAheadBuffer.value = '';
  if (typeAheadTimeoutId.value !== null) {
    clearTimeout(typeAheadTimeoutId.value);
    typeAheadTimeoutId.value = null;
  }
};

// Get first focusable item from menu items
const 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
const 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;
};

// Open menubar menu
const openMenubarMenu = (index: number, focusPosition: 'first' | 'last' = 'first') => {
  const menubarItem = props.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 ?? '';
  }

  state.openMenubarIndex = index;
  state.openSubmenuPath = [];
  state.focusedItemPath = focusedId ? [focusedId] : [];
  menubarFocusIndex.value = index;
};

// Get focusable items
const 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;
};

// Focus effect for menu items
watch(
  () => state.focusedItemPath,
  async (path) => {
    if (path.length === 0) return;
    const focusedId = path[path.length - 1];
    await nextTick();
    menuItemRefs.value[focusedId]?.focus();
  },
  { deep: true }
);

// Click outside
const handleClickOutside = (event: MouseEvent) => {
  if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
    closeAllMenus();
  }
};

watch(isMenuOpen, (newValue) => {
  if (newValue) {
    document.addEventListener('mousedown', handleClickOutside);
  } else {
    document.removeEventListener('mousedown', handleClickOutside);
  }
});

onUnmounted(() => {
  document.removeEventListener('mousedown', handleClickOutside);
  if (typeAheadTimeoutId.value !== null) {
    clearTimeout(typeAheadTimeoutId.value);
  }
});

// Handle type-ahead
const handleTypeAhead = (char: string, focusableItems: MenuItem[]) => {
  const enabledItems = focusableItems.filter((item) => !('disabled' in item && item.disabled));
  if (enabledItems.length === 0) return;

  if (typeAheadTimeoutId.value !== null) {
    clearTimeout(typeAheadTimeoutId.value);
  }

  typeAheadBuffer.value += char.toLowerCase();
  const buffer = typeAheadBuffer.value;
  const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

  let searchStr: string;
  let startIndex: number;

  const currentId = state.focusedItemPath[state.focusedItemPath.length - 1];
  const currentIndex = enabledItems.findIndex((item) => item.id === currentId);

  if (isSameChar) {
    typeAheadBuffer.value = 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)) {
      state.focusedItemPath = [...state.focusedItemPath.slice(0, -1), item.id];
      break;
    }
  }

  typeAheadTimeoutId.value = window.setTimeout(() => {
    typeAheadBuffer.value = '';
    typeAheadTimeoutId.value = null;
  }, typeAheadTimeout);
};

// Menubar keyboard navigation
const handleMenubarKeyDown = async (event: KeyboardEvent, index: number) => {
  switch (event.key) {
    case 'ArrowRight': {
      event.preventDefault();
      const nextIndex = (index + 1) % props.items.length;
      menubarFocusIndex.value = nextIndex;
      if (isMenuOpen.value) {
        openMenubarMenu(nextIndex, 'first');
      } else {
        await nextTick();
        menubarItemRefs.value[nextIndex]?.focus();
      }
      break;
    }
    case 'ArrowLeft': {
      event.preventDefault();
      const prevIndex = index === 0 ? props.items.length - 1 : index - 1;
      menubarFocusIndex.value = prevIndex;
      if (isMenuOpen.value) {
        openMenubarMenu(prevIndex, 'first');
      } else {
        await nextTick();
        menubarItemRefs.value[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.value = 0;
      await nextTick();
      menubarItemRefs.value[0]?.focus();
      break;
    }
    case 'End': {
      event.preventDefault();
      const lastIndex = props.items.length - 1;
      menubarFocusIndex.value = lastIndex;
      await nextTick();
      menubarItemRefs.value[lastIndex]?.focus();
      break;
    }
    case 'Escape': {
      event.preventDefault();
      closeAllMenus();
      break;
    }
    case 'Tab': {
      closeAllMenus();
      break;
    }
  }
};

// Menubar click
const handleMenubarClick = (index: number) => {
  if (state.openMenubarIndex === index) {
    closeAllMenus();
  } else {
    openMenubarMenu(index, 'first');
  }
};

// Menubar hover
const handleMenubarHover = (index: number) => {
  if (isMenuOpen.value && state.openMenubarIndex !== index) {
    openMenubarMenu(index, 'first');
  }
};

// Handle menu item activation
const handleActivate = (item: MenuItem, radioGroupName?: string) => {
  if ('disabled' in item && item.disabled) return;

  if (item.type === 'item') {
    emit('itemSelect', item.id);
    closeAllMenus();
    nextTick(() => {
      menubarItemRefs.value[state.openMenubarIndex]?.focus();
    });
  } else if (item.type === 'checkbox') {
    const newChecked = !checkboxStates.value.get(item.id);
    checkboxStates.value.set(item.id, newChecked);
    item.onCheckedChange?.(newChecked);
    // Menu stays open
  } else if (item.type === 'radio' && radioGroupName) {
    radioStates.value.set(radioGroupName, item.id);
    // Menu stays open
  } else if (item.type === 'submenu') {
    // Open submenu and focus first item
    const firstItem = getFirstFocusableItem(item.items);
    state.openSubmenuPath = [...state.openSubmenuPath, item.id];
    if (firstItem) {
      state.focusedItemPath = [...state.focusedItemPath, firstItem.id];
    }
  }
};

// Handle menu keyboard navigation
const handleMenuKeyDown = async (
  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) {
        state.focusedItemPath = [...state.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) {
        state.focusedItemPath = [...state.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);
        state.openSubmenuPath = [...state.openSubmenuPath, item.id];
        if (firstItem) {
          state.focusedItemPath = [...state.focusedItemPath, firstItem.id];
        }
      } else if (!isSubmenu) {
        const nextMenubarIndex = (state.openMenubarIndex + 1) % props.items.length;
        openMenubarMenu(nextMenubarIndex, 'first');
      }
      break;
    }
    case 'ArrowLeft': {
      event.preventDefault();
      if (isSubmenu) {
        state.openSubmenuPath = state.openSubmenuPath.slice(0, -1);
        state.focusedItemPath = state.focusedItemPath.slice(0, -1);
      } else {
        const prevMenubarIndex =
          state.openMenubarIndex === 0 ? props.items.length - 1 : state.openMenubarIndex - 1;
        openMenubarMenu(prevMenubarIndex, 'first');
      }
      break;
    }
    case 'Home': {
      event.preventDefault();
      const firstEnabled = enabledItems[0];
      if (firstEnabled) {
        state.focusedItemPath = [...state.focusedItemPath.slice(0, -1), firstEnabled.id];
      }
      break;
    }
    case 'End': {
      event.preventDefault();
      const lastEnabled = enabledItems[enabledItems.length - 1];
      if (lastEnabled) {
        state.focusedItemPath = [...state.focusedItemPath.slice(0, -1), lastEnabled.id];
      }
      break;
    }
    case 'Escape': {
      event.preventDefault();
      if (isSubmenu) {
        state.openSubmenuPath = state.openSubmenuPath.slice(0, -1);
        state.focusedItemPath = state.focusedItemPath.slice(0, -1);
      } else {
        const menubarIndex = state.openMenubarIndex;
        closeAllMenus();
        await nextTick();
        menubarItemRefs.value[menubarIndex]?.focus();
      }
      break;
    }
    case 'Tab': {
      closeAllMenus();
      break;
    }
    case 'Enter':
    case ' ': {
      event.preventDefault();
      handleActivate(item, radioGroupName);
      break;
    }
    default: {
      const { key, ctrlKey, metaKey, altKey } = event;
      if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
        event.preventDefault();
        handleTypeAhead(key, focusableItems);
      }
    }
  }
};

// MenuItems component for recursive rendering
const MenuItems: FunctionalComponent<{
  items: MenuItem[];
  parentId: string;
  isSubmenu: boolean;
  instanceId: string;
  state: MenuState;
  checkboxStates: Map<string, boolean>;
  radioStates: Map<string, string>;
  menuItemRefs: Record<string, HTMLSpanElement>;
}> = (innerProps) => {
  return innerProps.items.map((item) => {
    if (item.type === 'separator') {
      return h('li', { key: item.id, role: 'none' }, [
        h('hr', { role: 'separator', class: 'apg-menubar-separator' }),
      ]);
    }

    if (item.type === 'radiogroup') {
      return h('li', { key: item.id, role: 'none' }, [
        h(
          'ul',
          { role: 'group', 'aria-label': item.label, class: 'apg-menubar-group' },
          item.items.map((radioItem) => {
            const isChecked = innerProps.radioStates.get(item.name) === radioItem.id;
            const isFocused =
              innerProps.state.focusedItemPath[innerProps.state.focusedItemPath.length - 1] ===
              radioItem.id;
            return h('li', { key: radioItem.id, role: 'none' }, [
              h(
                'span',
                {
                  ref: (el: unknown) => {
                    if (el instanceof HTMLSpanElement) {
                      innerProps.menuItemRefs[radioItem.id] = el;
                    }
                  },
                  role: 'menuitemradio',
                  'aria-checked': isChecked,
                  'aria-disabled': radioItem.disabled || undefined,
                  tabindex: isFocused ? 0 : -1,
                  class: 'apg-menubar-menuitem apg-menubar-menuitemradio',
                  onClick: () => handleActivate(radioItem, item.name),
                  onKeydown: (e: KeyboardEvent) =>
                    handleMenuKeyDown(
                      e,
                      radioItem,
                      innerProps.items,
                      innerProps.isSubmenu,
                      item.name
                    ),
                },
                radioItem.label
              ),
            ]);
          })
        ),
      ]);
    }

    if (item.type === 'checkbox') {
      const isChecked = innerProps.checkboxStates.get(item.id) ?? false;
      const isFocused =
        innerProps.state.focusedItemPath[innerProps.state.focusedItemPath.length - 1] === item.id;
      return h('li', { key: item.id, role: 'none' }, [
        h(
          'span',
          {
            ref: (el: unknown) => {
              if (el instanceof HTMLSpanElement) {
                innerProps.menuItemRefs[item.id] = el;
              }
            },
            role: 'menuitemcheckbox',
            'aria-checked': isChecked,
            'aria-disabled': item.disabled || undefined,
            tabindex: isFocused ? 0 : -1,
            class: 'apg-menubar-menuitem apg-menubar-menuitemcheckbox',
            onClick: () => handleActivate(item),
            onKeydown: (e: KeyboardEvent) =>
              handleMenuKeyDown(e, item, innerProps.items, innerProps.isSubmenu),
          },
          item.label
        ),
      ]);
    }

    if (item.type === 'submenu') {
      const isExpanded = innerProps.state.openSubmenuPath.includes(item.id);
      const isFocused =
        innerProps.state.focusedItemPath[innerProps.state.focusedItemPath.length - 1] === item.id;
      return h('li', { key: item.id, role: 'none' }, [
        h(
          'span',
          {
            id: `${innerProps.instanceId}-menuitem-${item.id}`,
            ref: (el: unknown) => {
              if (el instanceof HTMLSpanElement) {
                innerProps.menuItemRefs[item.id] = el;
              }
            },
            role: 'menuitem',
            'aria-haspopup': 'menu',
            'aria-expanded': isExpanded,
            'aria-disabled': item.disabled || undefined,
            tabindex: isFocused ? 0 : -1,
            class: 'apg-menubar-menuitem apg-menubar-submenu-trigger',
            onClick: () => handleActivate(item),
            onKeydown: (e: KeyboardEvent) =>
              handleMenuKeyDown(e, item, innerProps.items, innerProps.isSubmenu),
          },
          [
            item.label,
            h(
              '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',
              },
              h('path', { d: 'm9 18 6-6-6-6' })
            ),
          ]
        ),
        h(
          'ul',
          {
            id: `${innerProps.instanceId}-submenu-${item.id}`,
            role: 'menu',
            'aria-labelledby': `${innerProps.instanceId}-menuitem-${item.id}`,
            class: 'apg-menubar-submenu',
            'aria-hidden': !isExpanded,
          },
          isExpanded
            ? [
                h(MenuItems, {
                  items: item.items,
                  parentId: item.id,
                  isSubmenu: true,
                  instanceId: innerProps.instanceId,
                  state: innerProps.state,
                  checkboxStates: innerProps.checkboxStates,
                  radioStates: innerProps.radioStates,
                  menuItemRefs: innerProps.menuItemRefs,
                }),
              ]
            : []
        ),
      ]);
    }

    // Regular menuitem
    const isFocused =
      innerProps.state.focusedItemPath[innerProps.state.focusedItemPath.length - 1] === item.id;
    return h('li', { key: item.id, role: 'none' }, [
      h(
        'span',
        {
          ref: (el: unknown) => {
            if (el instanceof HTMLSpanElement) {
              innerProps.menuItemRefs[item.id] = el;
            }
          },
          role: 'menuitem',
          'aria-disabled': item.disabled || undefined,
          tabindex: isFocused ? 0 : -1,
          class: 'apg-menubar-menuitem',
          onClick: () => handleActivate(item),
          onKeydown: (e: KeyboardEvent) =>
            handleMenuKeyDown(e, item, innerProps.items, innerProps.isSubmenu),
        },
        item.label
      ),
    ]);
  });
};

// Define props for the functional component to help Vue pass them correctly
MenuItems.props = [
  'items',
  'parentId',
  'isSubmenu',
  'instanceId',
  'state',
  'checkboxStates',
  'radioStates',
  'menuItemRefs',
];
</script>

使い方

使用例
<script setup lang="ts">
import Menubar, { type MenubarItem } from './Menubar.vue';
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>

<template>
  <Menubar :items="menuItems" aria-label="Application" @item-select="handleItemSelect" />
</template>

API

Props

Prop 説明
items MenubarItem[] トップレベルのメニュー項目の配列
aria-label string アクセシブルな名前

イベント

イベント ペイロード 説明
item-select string (itemId) 項目がアクティブ化されたときに発行

テスト

テストは、キーボード操作、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) を参照してください。

リソース