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

RoleTarget ElementDescription
menubarHorizontal container (<ul>)Top-level menu bar, always visible
menuVertical container (<ul>)Dropdown menu or submenu
menuitemItem (<span>)Standard action item
menuitemcheckboxCheckbox itemToggleable option
menuitemradioRadio itemExclusive option in a group
separatorDivider (<hr>)Visual separator (not focusable)
groupGroup containerGroups radio items with a label
none<li> elementsHides 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

KeyAction
Right ArrowMove focus to next menubar item (wraps to first)
Left ArrowMove focus to previous menubar item (wraps to last)
Down ArrowOpen submenu and focus first item
Up ArrowOpen submenu and focus last item
Enter / SpaceOpen submenu and focus first item
HomeMove focus to first menubar item
EndMove focus to last menubar item
TabClose all menus and move focus out
KeyAction
Down ArrowMove focus to next item (wraps to first)
Up ArrowMove focus to previous item (wraps to last)
Right ArrowOpen submenu if present, or move to next menubar item’s menu (in top-level menu)
Left ArrowClose submenu and return to parent, or move to previous menubar item’s menu (in top-level menu)
Enter / SpaceActivate item and close menu; for checkbox/radio, toggle state and keep menu open
EscapeClose menu and return focus to parent (menubar item or parent menuitem)
HomeMove focus to first item
EndMove focus to last item
CharacterType-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

EventBehavior
Initial focusOnly one menubar item has tabindex="0" at a time
Other itemsOther items have tabindex="-1"
Arrow key navigationArrow keys move focus between items with wrapping
Disabled itemsDisabled items are focusable but not activatable (per APG recommendation)
SeparatorSeparators are not focusable
Menu closeFocus returns to invoker when menu closes

References

Source Code

Menubar.svelte
<script lang="ts">
  import { onDestroy, tick } from 'svelte';
  import { SvelteMap } from 'svelte/reactivity';

  // Menu item types
  export interface MenuItemBase {
    id: string;
    label: string;
    disabled?: boolean;
  }

  export interface MenuItemAction extends MenuItemBase {
    type: 'item';
  }

  export interface MenuItemCheckbox extends MenuItemBase {
    type: 'checkbox';
    checked?: boolean;
    onCheckedChange?: (checked: boolean) => void;
  }

  export interface MenuItemRadio extends MenuItemBase {
    type: 'radio';
    checked?: boolean;
  }

  export interface MenuItemSeparator {
    type: 'separator';
    id: string;
  }

  export interface MenuItemRadioGroup {
    type: 'radiogroup';
    id: string;
    name: string;
    label: string;
    items: MenuItemRadio[];
  }

  export interface MenuItemSubmenu extends MenuItemBase {
    type: 'submenu';
    items: MenuItem[];
  }

  export type MenuItem =
    | MenuItemAction
    | MenuItemCheckbox
    | MenuItemRadio
    | MenuItemSeparator
    | MenuItemRadioGroup
    | MenuItemSubmenu;

  export interface MenubarItem {
    id: string;
    label: string;
    items: MenuItem[];
  }

  interface MenubarProps {
    items: MenubarItem[];
    'aria-label'?: string;
    'aria-labelledby'?: string;
    onItemSelect?: (itemId: string) => void;
    class?: string;
  }

  let {
    items = [],
    'aria-label': ariaLabel,
    'aria-labelledby': ariaLabelledby,
    onItemSelect = () => {},
    class: className = '',
    ...restProps
  }: MenubarProps = $props();

  // State
  const instanceId = `menubar-${Math.random().toString(36).slice(2, 11)}`;
  let menubarFocusIndex = $state(0);
  let openMenubarIndex = $state(-1);
  let openSubmenuPath = $state<string[]>([]);
  let focusedItemPath = $state<string[]>([]);
  // eslint-disable-next-line svelte/valid-compile -- SvelteMap is self-reactive, no $state() needed
  let checkboxStates = new SvelteMap<string, boolean>();
  // eslint-disable-next-line svelte/valid-compile -- SvelteMap is self-reactive, no $state() needed
  let radioStates = new SvelteMap<string, string>();
  let typeAheadBuffer = $state('');
  let typeAheadTimeoutId: number | null = null;
  const typeAheadTimeout = 500;

  // Refs
  let containerElement: HTMLUListElement;
  let menubarItemRefs = new SvelteMap<number, HTMLSpanElement>();
  let menuItemRefs = new SvelteMap<string, HTMLSpanElement>();

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

  let isMenuOpen = $derived(openMenubarIndex >= 0);

  onDestroy(() => {
    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
    }
    if (typeof document !== 'undefined') {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Focus effect for menu items
  $effect(() => {
    const path = focusedItemPath;
    if (path.length === 0) return;

    const focusedId = path[path.length - 1];
    tick().then(() => {
      if (focusedItemPath.length > 0) {
        menuItemRefs.get(focusedId)?.focus();
      }
    });
  });

  // Click outside effect
  $effect(() => {
    if (typeof document === 'undefined') return;

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

  function handleClickOutside(event: MouseEvent) {
    if (containerElement && !containerElement.contains(event.target as Node)) {
      closeAllMenus();
    }
  }

  // Ref tracking actions
  function trackMenubarItemRef(node: HTMLSpanElement, index: number) {
    menubarItemRefs.set(index, node);
    return {
      destroy() {
        menubarItemRefs.delete(index);
      },
    };
  }

  function trackMenuItemRef(node: HTMLSpanElement, itemId: string) {
    menuItemRefs.set(itemId, node);
    return {
      destroy() {
        menuItemRefs.delete(itemId);
      },
    };
  }

  // Helper functions
  function closeAllMenus() {
    openMenubarIndex = -1;
    openSubmenuPath = [];
    focusedItemPath = [];
    typeAheadBuffer = '';
    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
      typeAheadTimeoutId = null;
    }
  }

  // Get first focusable item from menu items
  function getFirstFocusableItem(menuItems: MenuItem[]): MenuItem | null {
    for (const item of menuItems) {
      if (item.type === 'separator') continue;
      if (item.type === 'radiogroup') {
        const enabledRadio = item.items.find((r) => !r.disabled);
        if (enabledRadio) return enabledRadio;
        continue;
      }
      if ('disabled' in item && item.disabled) continue;
      return item;
    }
    return null;
  }

  // Get all focusable items including radios from radiogroups
  function getAllFocusableItems(menuItems: MenuItem[]): MenuItem[] {
    const result: MenuItem[] = [];
    for (const item of menuItems) {
      if (item.type === 'separator') continue;
      if (item.type === 'radiogroup') {
        result.push(...item.items.filter((r) => !r.disabled));
      } else if (!('disabled' in item && item.disabled)) {
        result.push(item);
      }
    }
    return result;
  }

  function openMenubarMenu(index: number, focusPosition: 'first' | 'last' = 'first') {
    const menubarItem = items[index];
    if (!menubarItem) return;

    const focusableItems = getAllFocusableItems(menubarItem.items);

    let focusedId = '';
    if (focusPosition === 'first') {
      focusedId = focusableItems[0]?.id ?? '';
    } else {
      focusedId = focusableItems[focusableItems.length - 1]?.id ?? '';
    }

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

  function getFocusableItems(menuItems: MenuItem[]): MenuItem[] {
    const result: MenuItem[] = [];
    menuItems.forEach((item) => {
      if (item.type === 'separator') return;
      if (item.type === 'radiogroup') {
        result.push(...item.items);
      } else {
        result.push(item);
      }
    });
    return result;
  }

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

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

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

    let searchStr: string;
    let startIndex: number;

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

    if (isSameChar) {
      typeAheadBuffer = buffer[0];
      searchStr = buffer[0];
      startIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledItems.length : 0;
    } else if (buffer.length === 1) {
      searchStr = buffer;
      startIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledItems.length : 0;
    } else {
      searchStr = buffer;
      startIndex = currentIndex >= 0 ? currentIndex : 0;
    }

    for (let i = 0; i < enabledItems.length; i++) {
      const index = (startIndex + i) % enabledItems.length;
      const item = enabledItems[index];
      if ('label' in item && item.label.toLowerCase().startsWith(searchStr)) {
        focusedItemPath = [...focusedItemPath.slice(0, -1), item.id];
        break;
      }
    }

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

  async function handleMenubarKeyDown(event: KeyboardEvent, index: number) {
    switch (event.key) {
      case 'ArrowRight': {
        event.preventDefault();
        const nextIndex = (index + 1) % items.length;
        menubarFocusIndex = nextIndex;
        if (isMenuOpen) {
          openMenubarMenu(nextIndex, 'first');
        } else {
          await tick();
          menubarItemRefs.get(nextIndex)?.focus();
        }
        break;
      }
      case 'ArrowLeft': {
        event.preventDefault();
        const prevIndex = index === 0 ? items.length - 1 : index - 1;
        menubarFocusIndex = prevIndex;
        if (isMenuOpen) {
          openMenubarMenu(prevIndex, 'first');
        } else {
          await tick();
          menubarItemRefs.get(prevIndex)?.focus();
        }
        break;
      }
      case 'ArrowDown':
      case 'Enter':
      case ' ': {
        event.preventDefault();
        openMenubarMenu(index, 'first');
        break;
      }
      case 'ArrowUp': {
        event.preventDefault();
        openMenubarMenu(index, 'last');
        break;
      }
      case 'Home': {
        event.preventDefault();
        menubarFocusIndex = 0;
        await tick();
        menubarItemRefs.get(0)?.focus();
        break;
      }
      case 'End': {
        event.preventDefault();
        const lastIndex = items.length - 1;
        menubarFocusIndex = lastIndex;
        await tick();
        menubarItemRefs.get(lastIndex)?.focus();
        break;
      }
      case 'Escape': {
        event.preventDefault();
        closeAllMenus();
        break;
      }
      case 'Tab': {
        closeAllMenus();
        break;
      }
    }
  }

  function handleMenubarClick(index: number) {
    if (openMenubarIndex === index) {
      closeAllMenus();
    } else {
      openMenubarMenu(index, 'first');
    }
  }

  function handleMenubarHover(index: number) {
    if (isMenuOpen && openMenubarIndex !== index) {
      openMenubarMenu(index, 'first');
    }
  }

  function activateMenuItem(item: MenuItem, radioGroupName?: string) {
    if ('disabled' in item && item.disabled) return;

    if (item.type === 'item') {
      onItemSelect(item.id);
      closeAllMenus();
      tick().then(() => {
        menubarItemRefs.get(openMenubarIndex)?.focus();
      });
    } else if (item.type === 'checkbox') {
      const newChecked = !checkboxStates.get(item.id);
      checkboxStates.set(item.id, newChecked);
      checkboxStates = new SvelteMap(checkboxStates); // trigger reactivity
      item.onCheckedChange?.(newChecked);
      // Menu stays open
    } else if (item.type === 'radio' && radioGroupName) {
      radioStates.set(radioGroupName, item.id);
      radioStates = new SvelteMap(radioStates); // trigger reactivity
      // Menu stays open
    } else if (item.type === 'submenu') {
      // Open submenu and focus first item
      const firstItem = getFirstFocusableItem(item.items);
      openSubmenuPath = [...openSubmenuPath, item.id];
      if (firstItem) {
        focusedItemPath = [...focusedItemPath, firstItem.id];
      }
    }
  }

  async function handleMenuKeyDown(
    event: KeyboardEvent,
    item: MenuItem,
    menuItems: MenuItem[],
    isSubmenu: boolean,
    radioGroupName?: string
  ) {
    const focusableItems = getFocusableItems(menuItems);
    const enabledItems = focusableItems.filter((i) => !('disabled' in i && i.disabled));
    const currentIndex = focusableItems.findIndex((i) => i.id === item.id);

    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault();
        let nextIndex = currentIndex;
        do {
          nextIndex = (nextIndex + 1) % focusableItems.length;
        } while (
          focusableItems[nextIndex] &&
          'disabled' in focusableItems[nextIndex] &&
          (focusableItems[nextIndex] as MenuItemBase).disabled &&
          nextIndex !== currentIndex
        );

        const nextItem = focusableItems[nextIndex];
        if (nextItem) {
          focusedItemPath = [...focusedItemPath.slice(0, -1), nextItem.id];
        }
        break;
      }
      case 'ArrowUp': {
        event.preventDefault();
        let prevIndex = currentIndex;
        do {
          prevIndex = prevIndex === 0 ? focusableItems.length - 1 : prevIndex - 1;
        } while (
          focusableItems[prevIndex] &&
          'disabled' in focusableItems[prevIndex] &&
          (focusableItems[prevIndex] as MenuItemBase).disabled &&
          prevIndex !== currentIndex
        );

        const prevItem = focusableItems[prevIndex];
        if (prevItem) {
          focusedItemPath = [...focusedItemPath.slice(0, -1), prevItem.id];
        }
        break;
      }
      case 'ArrowRight': {
        event.preventDefault();
        if (item.type === 'submenu') {
          // Open submenu and focus first item
          const firstItem = getFirstFocusableItem(item.items);
          openSubmenuPath = [...openSubmenuPath, item.id];
          if (firstItem) {
            focusedItemPath = [...focusedItemPath, firstItem.id];
          }
        } else if (!isSubmenu) {
          const nextMenubarIndex = (openMenubarIndex + 1) % items.length;
          openMenubarMenu(nextMenubarIndex, 'first');
        }
        break;
      }
      case 'ArrowLeft': {
        event.preventDefault();
        if (isSubmenu) {
          openSubmenuPath = openSubmenuPath.slice(0, -1);
          focusedItemPath = focusedItemPath.slice(0, -1);
        } else {
          const prevMenubarIndex = openMenubarIndex === 0 ? items.length - 1 : openMenubarIndex - 1;
          openMenubarMenu(prevMenubarIndex, 'first');
        }
        break;
      }
      case 'Home': {
        event.preventDefault();
        const firstEnabled = enabledItems[0];
        if (firstEnabled) {
          focusedItemPath = [...focusedItemPath.slice(0, -1), firstEnabled.id];
        }
        break;
      }
      case 'End': {
        event.preventDefault();
        const lastEnabled = enabledItems[enabledItems.length - 1];
        if (lastEnabled) {
          focusedItemPath = [...focusedItemPath.slice(0, -1), lastEnabled.id];
        }
        break;
      }
      case 'Escape': {
        event.preventDefault();
        if (isSubmenu) {
          openSubmenuPath = openSubmenuPath.slice(0, -1);
          focusedItemPath = focusedItemPath.slice(0, -1);
        } else {
          const menubarIndex = openMenubarIndex;
          closeAllMenus();
          await tick();
          menubarItemRefs.get(menubarIndex)?.focus();
        }
        break;
      }
      case 'Tab': {
        closeAllMenus();
        break;
      }
      case 'Enter':
      case ' ': {
        event.preventDefault();
        activateMenuItem(item, radioGroupName);
        break;
      }
      default: {
        const { key, ctrlKey, metaKey, altKey } = event;
        if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
          event.preventDefault();
          handleTypeAhead(key, focusableItems);
        }
      }
    }
  }

  // Check if submenu is expanded
  function isSubmenuExpanded(itemId: string): boolean {
    return openSubmenuPath.includes(itemId);
  }

  // Check if item is focused (only the last item in the path)
  function isItemFocused(itemId: string): boolean {
    return focusedItemPath[focusedItemPath.length - 1] === itemId;
  }
</script>

<ul
  bind:this={containerElement}
  role="menubar"
  class="apg-menubar {className}"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  {...restProps}
>
  {#each items as menubarItem, index (menubarItem.id)}
    <li role="none">
      <span
        id="{instanceId}-menubar-{menubarItem.id}"
        use:trackMenubarItemRef={index}
        role="menuitem"
        aria-haspopup="menu"
        aria-expanded={openMenubarIndex === index}
        tabindex={index === menubarFocusIndex ? 0 : -1}
        class="apg-menubar-trigger"
        onclick={() => handleMenubarClick(index)}
        onkeydown={(e) => handleMenubarKeyDown(e, index)}
        onmouseenter={() => handleMenubarHover(index)}
      >
        {menubarItem.label}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="12"
          height="12"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
          style="position: relative; top: 1px; opacity: 0.7"
        >
          <path d="m6 9 6 6 6-6" />
        </svg>
      </span>
      <ul
        id="{instanceId}-menu-{menubarItem.id}"
        role="menu"
        aria-labelledby="{instanceId}-menubar-{menubarItem.id}"
        class="apg-menubar-menu"
        aria-hidden={openMenubarIndex !== index}
      >
        {#if openMenubarIndex === index}
          {#each menubarItem.items as item (item.id)}
            {#if item.type === 'separator'}
              <li role="none">
                <hr class="apg-menubar-separator" />
              </li>
            {:else if item.type === 'radiogroup'}
              <li role="none">
                <ul role="group" aria-label={item.label} class="apg-menubar-group">
                  {#each item.items as radioItem (radioItem.id)}
                    <li role="none">
                      <span
                        use:trackMenuItemRef={radioItem.id}
                        role="menuitemradio"
                        aria-checked={radioStates.get(item.name) === radioItem.id}
                        aria-disabled={radioItem.disabled || undefined}
                        tabindex={isItemFocused(radioItem.id) ? 0 : -1}
                        class="apg-menubar-menuitem apg-menubar-menuitemradio"
                        onclick={() => activateMenuItem(radioItem, item.name)}
                        onkeydown={(e) =>
                          handleMenuKeyDown(e, radioItem, menubarItem.items, false, item.name)}
                      >
                        {radioItem.label}
                      </span>
                    </li>
                  {/each}
                </ul>
              </li>
            {:else if item.type === 'checkbox'}
              <li role="none">
                <span
                  use:trackMenuItemRef={item.id}
                  role="menuitemcheckbox"
                  aria-checked={checkboxStates.get(item.id) ?? false}
                  aria-disabled={item.disabled || undefined}
                  tabindex={isItemFocused(item.id) ? 0 : -1}
                  class="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
                  onclick={() => activateMenuItem(item)}
                  onkeydown={(e) => handleMenuKeyDown(e, item, menubarItem.items, false)}
                >
                  {item.label}
                </span>
              </li>
            {:else if item.type === 'submenu'}
              <li role="none">
                <span
                  id="{instanceId}-menuitem-{item.id}"
                  use:trackMenuItemRef={item.id}
                  role="menuitem"
                  aria-haspopup="menu"
                  aria-expanded={isSubmenuExpanded(item.id)}
                  aria-disabled={item.disabled || undefined}
                  tabindex={isItemFocused(item.id) ? 0 : -1}
                  class="apg-menubar-menuitem apg-menubar-submenu-trigger"
                  onclick={() => activateMenuItem(item)}
                  onkeydown={(e) => handleMenuKeyDown(e, item, menubarItem.items, false)}
                >
                  {item.label}
                  <svg
                    xmlns="http://www.w3.org/2000/svg"
                    width="12"
                    height="12"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    aria-hidden="true"
                    style="margin-left: auto; position: relative; top: 1px"
                  >
                    <path d="m9 18 6-6-6-6" />
                  </svg>
                </span>
                <ul
                  id="{instanceId}-submenu-{item.id}"
                  role="menu"
                  aria-labelledby="{instanceId}-menuitem-{item.id}"
                  class="apg-menubar-submenu"
                  aria-hidden={!isSubmenuExpanded(item.id)}
                >
                  {#if isSubmenuExpanded(item.id)}
                    {#each item.items as subItem (subItem.id)}
                      {#if subItem.type === 'separator'}
                        <li role="none">
                          <hr class="apg-menubar-separator" />
                        </li>
                      {:else if subItem.type !== 'radiogroup'}
                        <li role="none">
                          <span
                            use:trackMenuItemRef={subItem.id}
                            role="menuitem"
                            aria-disabled={subItem.disabled || undefined}
                            tabindex={isItemFocused(subItem.id) ? 0 : -1}
                            class="apg-menubar-menuitem"
                            onclick={() => activateMenuItem(subItem)}
                            onkeydown={(e) => handleMenuKeyDown(e, subItem, item.items, true)}
                          >
                            {subItem.label}
                          </span>
                        </li>
                      {/if}
                    {/each}
                  {/if}
                </ul>
              </li>
            {:else}
              <li role="none">
                <span
                  use:trackMenuItemRef={item.id}
                  role="menuitem"
                  aria-disabled={item.disabled || undefined}
                  tabindex={isItemFocused(item.id) ? 0 : -1}
                  class="apg-menubar-menuitem"
                  onclick={() => activateMenuItem(item)}
                  onkeydown={(e) => handleMenuKeyDown(e, item, menubarItem.items, false)}
                >
                  {item.label}
                </span>
              </li>
            {/if}
          {/each}
        {/if}
      </ul>
    </li>
  {/each}
</ul>

Usage

Example
<script lang="ts">
  import Menubar, { type MenubarItem } from './Menubar.svelte';
  import '@patterns/menubar/menubar.css';

  const menuItems: MenubarItem[] = [
    {
      id: 'file',
      label: 'File',
      items: [
        { type: 'item', id: 'new', label: 'New' },
        { type: 'item', id: 'save', label: 'Save' },
      ],
    },
    {
      id: 'edit',
      label: 'Edit',
      items: [
        { type: 'item', id: 'cut', label: 'Cut' },
        { type: 'item', id: 'copy', label: 'Copy' },
      ],
    },
  ];

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

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

API

Prop Type Default Description
items MenubarItem[] required Array of top-level menu items
aria-label string - Accessible name (required if no aria-labelledby)
aria-labelledby string - ID of labelling element
onItemSelect (id: string) => void - Callback when an item is activated
class string '' Additional CSS class

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

Test Description
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-haspopup Items with submenu have aria-haspopup=menu
aria-expanded Items with submenu reflect open state
aria-labelledby Submenu references parent menuitem
aria-checked Checkbox/radio items have correct checked state
aria-hidden Menu has aria-hidden=true when closed, false when open

High Priority: APG Keyboard Interaction - Menubar

Test Description
ArrowRight Moves focus to next menubar item (wraps)
ArrowLeft Moves focus to previous menubar item (wraps)
ArrowDown Opens submenu and focuses first item
ArrowUp Opens submenu and focuses last item
Enter/Space Opens submenu
Home Moves focus to first menubar item
End Moves focus to last menubar item
Tab Closes all menus, moves focus out

High Priority: APG Keyboard Interaction - Menu

Test Description
ArrowDown Moves focus to next item (wraps)
ArrowUp Moves focus to previous item (wraps)
ArrowRight Opens submenu if present, or moves to next menubar menu
ArrowLeft Closes submenu, or moves to previous menubar menu
Enter/Space Activates item and closes menu
Escape Closes menu, returns focus to parent
Home/End Moves focus to first/last item

High Priority: Checkbox and Radio Items

Test Description
Checkbox toggle Space/Enter toggles checkbox
Checkbox keeps open Toggle does not close menu
aria-checked update aria-checked updates on toggle
Radio select Space/Enter selects radio
Radio keeps open Selection does not close menu
Exclusive selection Only one radio in group can be checked

High Priority: Focus Management (Roving Tabindex)

Test Description
tabIndex=0 First menubar item has tabIndex=0
tabIndex=-1 Other items have tabIndex=-1
Separator Separator is not focusable
Disabled items Disabled items are focusable but not activatable

High Priority: Type-Ahead Search

Test Description
Character match Focuses item starting with typed character
Wrap around Search wraps from end to beginning
Skip separator Skips separator during search
Skip disabled Skips disabled items during search
Buffer reset Buffer resets after 500ms

High Priority: Pointer Interaction

Test Description
Click open Click menubar item opens menu
Click toggle Click menubar item again closes menu
Hover switch Hover on another menubar item switches menu (when open)
Item click Click menuitem activates and closes menu
Click outside Click outside closes menu

Medium Priority: Accessibility

Test Description
axe closed No violations when menubar is closed
axe menu open No violations with menu open
axe submenu open No violations with submenu open

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Resources