APG Patterns
日本語
日本語

Menubar

A horizontal menu bar that provides application-style navigation with dropdown menus, submenus, checkbox items, and radio groups.

Demo

Last action: None

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
menubar Horizontal container (<ul>) Top-level menu bar, always visible
menu Vertical container (<ul>) Dropdown menu or submenu
menuitem Item (<span>) Standard action item
menuitemcheckbox Checkbox item Toggleable option
menuitemradio Radio item Exclusive option in a group
separator Divider (<hr>) Visual separator (not focusable)
group Group container Groups radio items with a label
none <li> elements Hides list semantics from screen readers

WAI-ARIA Properties

aria-haspopup

Indicates the item opens a menu (use “menu”, not “true”)

Values
menu
Required
Yes*

aria-expanded

Indicates whether the menu is open

Values
true | false
Required
Yes*

aria-labelledby

References the parent menuitem

Values
ID reference
Required
Yes**

aria-label

Provides an accessible name

Values
String
Required
Yes**

aria-checked

Indicates checked state

Values
true | false
Required
Yes

aria-disabled

Indicates the item is disabled

Values
true
Required
No

aria-hidden

Hides menu from screen readers when closed

Values
true | false
Required
Yes

Keyboard Support

Key Action
Right Arrow Move focus to next menubar item (wraps to first)
Left Arrow Move focus to previous menubar item (wraps to last)
Down Arrow Open submenu and focus first item
Up Arrow Open submenu and focus last item
Enter / Space Open submenu and focus first item
Home Move focus to first menubar item
End Move focus to last menubar item
Tab Close all menus and move focus out
Key Action
Down Arrow Move focus to next item (wraps to first)
Up Arrow Move focus to previous item (wraps to last)
Right Arrow Open submenu if present, or move to next menubar item’s menu (in top-level menu)
Left Arrow Close submenu and return to parent, or move to previous menubar item’s menu (in top-level menu)
Enter / Space Activate item and close menu; for checkbox/radio, toggle state and keep menu open
Escape Close menu and return focus to parent (menubar item or parent menuitem)
Home Move focus to first item
End Move focus to last item
Character Type-ahead: focus item starting with typed character(s)
  • When closed, the menu uses aria-hidden=“true” with CSS to hide from screen readers (aria-hidden), hide visually (visibility: hidden), prevent pointer interaction (pointer-events: none), and enable smooth CSS animations on open/close.
  • When open, the menu has aria-hidden=“false” with visibility: visible.

Focus Management

Event Behavior
Initial focus Only one menubar item has tabindex="0" at a time
Other items Other items have tabindex="-1"
Arrow key navigation Arrow keys move focus between items with wrapping
Disabled items Disabled items are focusable but not activatable (per APG recommendation)
Separator Separators are not focusable
Menu close Focus returns to invoker when menu closes

References

Source Code

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);
        }
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        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>

Usage

Example
<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' },
    ],
  },
  {
    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>

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

API

PropTypeDefaultDescription
itemsMenubarItem[]requiredArray of top-level menu items
aria-labelstring-Accessible name (required if no aria-labelledby)
aria-labelledbystring-ID of labelling element
classNamestring''Additional CSS class

Custom Events

EventDetailDescription
item-selectstring (itemId)Emitted when an item is activated

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Menubar component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.

  • HTML structure and element hierarchy
  • Initial attribute values (role, aria-haspopup, aria-expanded)
  • Click event handling
  • CSS class application

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.

  • Keyboard navigation (Arrow keys, Enter, Space, Escape, Tab)
  • Submenu opening and closing
  • Menubar horizontal navigation
  • Checkbox and radio item toggling
  • Hover-based menu switching
  • Type-ahead search
  • Focus management and roving tabindex
  • Cross-framework consistency

Test Categories

High Priority: APG ARIA Attributes

TestDescription
role="menubar"Container has menubar role
role="menu"Dropdown has menu role
role="menuitem"Items have menuitem role
role="menuitemcheckbox"Checkbox items have correct role
role="menuitemradio"Radio items have correct role
role="separator"Dividers have separator role
role="group"Radio groups have group role with aria-label
role="none"All li elements have role=none
aria-haspopupItems with submenu have aria-haspopup=menu
aria-expandedItems with submenu reflect open state
aria-labelledbySubmenu references parent menuitem
aria-checkedCheckbox/radio items have correct checked state
aria-hiddenMenu has aria-hidden=true when closed, false when open

High Priority: APG Keyboard Interaction - Menubar

TestDescription
ArrowRightMoves focus to next menubar item (wraps)
ArrowLeftMoves focus to previous menubar item (wraps)
ArrowDownOpens submenu and focuses first item
ArrowUpOpens submenu and focuses last item
Enter/SpaceOpens submenu
HomeMoves focus to first menubar item
EndMoves focus to last menubar item
TabCloses all menus, moves focus out

High Priority: APG Keyboard Interaction - Menu

TestDescription
ArrowDownMoves focus to next item (wraps)
ArrowUpMoves focus to previous item (wraps)
ArrowRightOpens submenu if present, or moves to next menubar menu
ArrowLeftCloses submenu, or moves to previous menubar menu
Enter/SpaceActivates item and closes menu
EscapeCloses menu, returns focus to parent
Home/EndMoves focus to first/last item

High Priority: Checkbox and Radio Items

TestDescription
Checkbox toggleSpace/Enter toggles checkbox
Checkbox keeps openToggle does not close menu
aria-checked updatearia-checked updates on toggle
Radio selectSpace/Enter selects radio
Radio keeps openSelection does not close menu
Exclusive selectionOnly one radio in group can be checked

High Priority: Focus Management (Roving Tabindex)

TestDescription
tabIndex=0First menubar item has tabIndex=0
tabIndex=-1Other items have tabIndex=-1
SeparatorSeparator is not focusable
Disabled itemsDisabled items are focusable but not activatable

High Priority: Type-Ahead Search

TestDescription
Character matchFocuses item starting with typed character
Wrap aroundSearch wraps from end to beginning
Skip separatorSkips separator during search
Skip disabledSkips disabled items during search
Buffer resetBuffer resets after 500ms

High Priority: Pointer Interaction

TestDescription
Click openClick menubar item opens menu
Click toggleClick menubar item again closes menu
Hover switchHover on another menubar item switches menu (when open)
Item clickClick menuitem activates and closes menu
Click outsideClick outside closes menu

Medium Priority: Accessibility

TestDescription
axe closedNo violations when menubar is closed
axe menu openNo violations with menu open
axe submenu openNo violations with submenu open

Testing Tools

See the Testing Strategy guide for details.

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

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 (Vue)', () => {
  // 🔴 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('has aria-expanded on items with submenu', () => {
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

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

    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('ArrowLeft moves to previous menubar item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const editItem = screen.getByRole('menuitem', { name: 'Edit' });
      editItem.focus();
      await user.keyboard('{ArrowLeft}');

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

    it('ArrowRight wraps from last to first', 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('{ArrowRight}');

      await vi.waitFor(() => {
        expect(screen.getByRole('menuitem', { name: 'File' })).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('ArrowDown moves to next item', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

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

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

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

    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');
    });

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

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

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

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

    it('Home moves to first item in 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);

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

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

    it('End moves to last item in 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);

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

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

  // 🔴 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('Space on radio 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 darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
      darkRadio.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' });
      const viewItem = screen.getByRole('menuitem', { name: 'View' });

      expect(editItem).toHaveAttribute('tabindex', '-1');
      expect(viewItem).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 on menubar item again closes 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');

      await user.click(fileItem);
      expect(fileItem).toHaveAttribute('aria-expanded', 'false');
    });

    it('hover on another menubar item switches menu when open', async () => {
      const user = userEvent.setup();
      render(Menubar, { props: { items: createBasicItems(), 'aria-label': 'Application' } });

      const fileItem = screen.getByRole('menuitem', { name: 'File' });
      const editItem = screen.getByRole('menuitem', { name: 'Edit' });

      await user.click(fileItem);
      expect(fileItem).toHaveAttribute('aria-expanded', 'true');

      await user.hover(editItem);

      await vi.waitFor(() => {
        expect(fileItem).toHaveAttribute('aria-expanded', 'false');
        expect(editItem).toHaveAttribute('aria-expanded', 'true');
      });
    });
  });

  // 🟡 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();
    });
  });
});

Resources