Menubar
A horizontal menu bar that provides application-style navigation with dropdown menus, submenus, checkbox items, and radio groups.
Demo
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
Menubar Navigation
| 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 |
Menu/Submenu Navigation
| 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
---
/**
* APG Menubar Pattern - Astro Implementation
*
* A horizontal bar of menu triggers that open dropdown menus.
* Uses Web Components for enhanced control and proper focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
*/
// MenuItem interface with discriminated union pattern
// type can be: 'item' | 'separator' | 'checkbox' | 'radio' | 'radiogroup' | 'submenu'
export interface MenuItem {
type: string;
id: string;
label?: string;
disabled?: boolean;
checked?: boolean;
name?: string; // for radiogroup
items?: MenuItem[]; // for submenu and radiogroup
}
// Type guard helper for action items in submenus
type MenuItemAction = MenuItem & { type: 'item' | 'separator' };
export interface MenubarItem {
id: string;
label: string;
items: MenuItem[];
}
export interface Props {
items: MenubarItem[];
class?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
}
const {
items = [],
class: className = '',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `menubar-${Math.random().toString(36).slice(2, 11)}`;
---
<apg-menubar>
<nav class={`apg-menubar ${className}`.trim()}>
<ul
role="menubar"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class="apg-menubar-list"
data-menubar
>
{
items.map((menubarItem, index) => {
const triggerId = `${instanceId}-${menubarItem.id}-trigger`;
const menuId = `${instanceId}-${menubarItem.id}-menu`;
const isFirstItem = index === 0;
return (
<li role="none">
<span
id={triggerId}
role="menuitem"
tabindex={isFirstItem ? 0 : -1}
aria-haspopup="menu"
aria-expanded="false"
class="apg-menubar-trigger"
data-menubar-trigger
data-menubar-index={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={menuId}
role="menu"
aria-labelledby={triggerId}
class="apg-menubar-menu"
aria-hidden="true"
data-menubar-menu
data-menubar-index={index}
>
{menubarItem.items.map((item) => {
if (item.type === 'separator') {
return (
<li role="none">
<hr role="separator" class="apg-menubar-separator" />
</li>
);
}
if (item.type === 'radiogroup') {
return (
<li role="none">
<ul role="group" aria-label={item.label} class="apg-menubar-group">
{item.items?.map((radioItem) => (
<li role="none">
<span
role="menuitemradio"
tabindex="-1"
aria-checked={radioItem.checked ? 'true' : 'false'}
aria-disabled={radioItem.disabled || undefined}
class="apg-menubar-menuitem apg-menubar-menuitemradio"
data-item-id={radioItem.id}
data-radio-group={item.name}
>
{radioItem.label}
</span>
</li>
))}
</ul>
</li>
);
}
if (item.type === 'checkbox') {
return (
<li role="none">
<span
role="menuitemcheckbox"
tabindex="-1"
aria-checked={item.checked ? 'true' : 'false'}
aria-disabled={item.disabled || undefined}
class="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
data-item-id={item.id}
>
{item.label}
</span>
</li>
);
}
if (item.type === 'submenu') {
const submenuTriggerId = `${instanceId}-${item.id}-submenu-trigger`;
const submenuId = `${instanceId}-${item.id}-submenu`;
return (
<li role="none" class="apg-menubar-submenu-container">
<span
id={submenuTriggerId}
role="menuitem"
tabindex="-1"
aria-haspopup="menu"
aria-expanded="false"
aria-disabled={item.disabled || undefined}
class="apg-menubar-menuitem apg-menubar-submenu-trigger"
data-item-id={item.id}
data-submenu-trigger
>
{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={submenuId}
role="menu"
aria-labelledby={submenuTriggerId}
class="apg-menubar-submenu"
aria-hidden="true"
data-submenu
>
{(item.items ?? [])
.filter(
(subItem): subItem is MenuItemAction =>
subItem.type === 'item' || subItem.type === 'separator'
)
.map((subItem) => {
if (subItem.type === 'separator') {
return (
<li role="none">
<hr role="separator" class="apg-menubar-separator" />
</li>
);
}
return (
<li role="none">
<span
role="menuitem"
tabindex="-1"
aria-disabled={subItem.disabled || undefined}
class="apg-menubar-menuitem"
data-item-id={subItem.id}
>
{subItem.label}
</span>
</li>
);
})}
</ul>
</li>
);
}
// Default: action item
return (
<li role="none">
<span
role="menuitem"
tabindex="-1"
aria-disabled={item.disabled || undefined}
class="apg-menubar-menuitem"
data-item-id={item.id}
>
{item.label}
</span>
</li>
);
})}
</ul>
</li>
);
})
}
</ul>
</nav>
</apg-menubar>
<script>
class ApgMenubar extends HTMLElement {
private menubar: HTMLUListElement | null = null;
private menubarItems: HTMLElement[] = [];
private rafId: number | null = null;
// State
private openMenuIndex = -1;
private focusedMenubarIndex = 0;
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private readonly typeAheadTimeout = 500;
// Checkbox/Radio states (stored separately since we're modifying aria-checked)
private checkboxStates: Map<string, boolean> = new Map();
private radioStates: Map<string, string> = new Map(); // group name -> checked id
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.menubar = this.querySelector('[data-menubar]');
if (!this.menubar) {
console.warn('apg-menubar: menubar element not found');
return;
}
this.menubarItems = Array.from(
this.menubar.querySelectorAll<HTMLElement>('[data-menubar-trigger]')
);
// Initialize checkbox/radio states from aria-checked
this.initializeCheckboxRadioStates();
// Attach event listeners
this.menubar.addEventListener('click', this.handleClick);
this.menubar.addEventListener('keydown', this.handleKeyDown);
this.menubar.addEventListener('mouseover', this.handleMouseOver);
document.addEventListener('pointerdown', this.handleClickOutside);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
document.removeEventListener('pointerdown', this.handleClickOutside);
this.menubar?.removeEventListener('click', this.handleClick);
this.menubar?.removeEventListener('keydown', this.handleKeyDown);
this.menubar?.removeEventListener('mouseover', this.handleMouseOver);
}
private initializeCheckboxRadioStates() {
// Checkboxes
const checkboxes = this.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]');
checkboxes.forEach((cb) => {
const id = cb.dataset.itemId;
if (id) {
this.checkboxStates.set(id, cb.getAttribute('aria-checked') === 'true');
}
});
// Radio groups
const radios = this.querySelectorAll<HTMLElement>('[role="menuitemradio"]');
radios.forEach((radio) => {
const id = radio.dataset.itemId;
const group = radio.dataset.radioGroup;
if (id && group && radio.getAttribute('aria-checked') === 'true') {
this.radioStates.set(group, id);
}
});
}
private getMenuForIndex(index: number): HTMLElement | null {
return this.querySelector(`[data-menubar-menu][data-menubar-index="${index}"]`);
}
private getMenuItems(menu: HTMLElement): HTMLElement[] {
// Get direct menu items (not submenu items)
// Note: Disabled items ARE included - they should be focusable but not activatable per APG
const items: HTMLElement[] = [];
const allItems = menu.querySelectorAll<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
);
allItems.forEach((item) => {
// Exclude items that are inside a nested submenu
const parentMenu = item.closest('[role="menu"]');
if (parentMenu === menu) {
items.push(item);
}
});
return items;
}
private openMenu(menubarIndex: number, focusPosition: 'first' | 'last' = 'first') {
// Close any open menu first
if (this.openMenuIndex >= 0 && this.openMenuIndex !== menubarIndex) {
this.closeMenu();
}
const trigger = this.menubarItems[menubarIndex];
const menu = this.getMenuForIndex(menubarIndex);
if (!trigger || !menu) return;
this.openMenuIndex = menubarIndex;
trigger.setAttribute('aria-expanded', 'true');
menu.setAttribute('aria-hidden', 'false');
// Focus first/last available item
const menuItems = this.getMenuItems(menu);
if (menuItems.length > 0) {
const targetIndex = focusPosition === 'first' ? 0 : menuItems.length - 1;
menuItems[targetIndex]?.focus();
}
}
private closeMenu() {
if (this.openMenuIndex < 0) return;
const trigger = this.menubarItems[this.openMenuIndex];
const menu = this.getMenuForIndex(this.openMenuIndex);
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
}
if (menu) {
menu.setAttribute('aria-hidden', 'true');
// Close any open submenus
const submenus = menu.querySelectorAll('[data-submenu]');
submenus.forEach((submenu) => {
submenu.setAttribute('aria-hidden', 'true');
const submenuTrigger = submenu
.closest('.apg-menubar-submenu-container')
?.querySelector('[data-submenu-trigger]');
submenuTrigger?.setAttribute('aria-expanded', 'false');
});
}
// Clear type-ahead
this.typeAheadBuffer = '';
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
this.openMenuIndex = -1;
}
private closeAllMenus() {
this.closeMenu();
}
private updateMenubarTabindex(newIndex: number) {
this.menubarItems.forEach((item, idx) => {
item.tabIndex = idx === newIndex ? 0 : -1;
});
this.focusedMenubarIndex = newIndex;
}
private handleClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
// Handle menubar trigger click
const menubarTrigger = target.closest('[data-menubar-trigger]');
if (menubarTrigger instanceof HTMLElement) {
const index = parseInt(menubarTrigger.dataset.menubarIndex || '0', 10);
if (this.openMenuIndex === index) {
this.closeMenu();
} else {
this.openMenu(index);
}
return;
}
// Handle submenu trigger click
const submenuTrigger = target.closest('[data-submenu-trigger]');
if (
submenuTrigger instanceof HTMLElement &&
submenuTrigger.getAttribute('aria-disabled') !== 'true'
) {
const isExpanded = submenuTrigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
const container = submenuTrigger.closest('.apg-menubar-submenu-container');
const submenu = container?.querySelector('[data-submenu]');
if (submenu instanceof HTMLElement) {
this.closeSubmenu(submenu);
}
} else {
this.openSubmenu(submenuTrigger);
}
return;
}
// Handle menu item click
const menuItem = target.closest(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
);
if (menuItem instanceof HTMLElement && menuItem.getAttribute('aria-disabled') !== 'true') {
this.activateItem(menuItem);
}
};
private handleMouseOver = (event: MouseEvent) => {
// Only handle hover switching when a menu is already open
if (this.openMenuIndex < 0) return;
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
const menubarTrigger = target.closest('[data-menubar-trigger]');
if (menubarTrigger instanceof HTMLElement) {
const index = parseInt(menubarTrigger.dataset.menubarIndex || '0', 10);
if (index !== this.openMenuIndex) {
this.openMenu(index);
}
}
};
private handleKeyDown = (event: KeyboardEvent) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
const target = event.target;
// Determine if we're on menubar or inside menu
const isOnMenubar = target.hasAttribute('data-menubar-trigger');
const isInMenu = target.closest('[data-menubar-menu]') || target.closest('[data-submenu]');
if (isOnMenubar) {
this.handleMenubarKeyDown(event, target);
} else if (isInMenu) {
this.handleMenuKeyDown(event, target);
}
};
private handleMenubarKeyDown(event: KeyboardEvent, target: HTMLElement) {
const index = parseInt(target.dataset.menubarIndex || '0', 10);
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
const nextIndex = (index + 1) % this.menubarItems.length;
this.updateMenubarTabindex(nextIndex);
this.menubarItems[nextIndex]?.focus();
if (this.openMenuIndex >= 0) {
this.openMenu(nextIndex);
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
const prevIndex = index === 0 ? this.menubarItems.length - 1 : index - 1;
this.updateMenubarTabindex(prevIndex);
this.menubarItems[prevIndex]?.focus();
if (this.openMenuIndex >= 0) {
this.openMenu(prevIndex);
}
break;
}
case 'ArrowDown':
case 'Enter':
case ' ': {
event.preventDefault();
this.openMenu(index, 'first');
break;
}
case 'ArrowUp': {
event.preventDefault();
this.openMenu(index, 'last');
break;
}
case 'Home': {
event.preventDefault();
this.updateMenubarTabindex(0);
this.menubarItems[0]?.focus();
break;
}
case 'End': {
event.preventDefault();
const lastIndex = this.menubarItems.length - 1;
this.updateMenubarTabindex(lastIndex);
this.menubarItems[lastIndex]?.focus();
break;
}
case 'Escape': {
event.preventDefault();
this.closeMenu();
break;
}
case 'Tab': {
// Close menus when Tab is pressed from menubar
this.closeAllMenus();
break;
}
}
}
private handleMenuKeyDown(event: KeyboardEvent, target: HTMLElement) {
const isSubmenuTrigger = target.hasAttribute('data-submenu-trigger');
// Get the current menu context
const currentMenu = target.closest('[role="menu"]');
if (!(currentMenu instanceof HTMLElement)) {
return;
}
const menuItems = this.getMenuItems(currentMenu);
const currentIndex = menuItems.indexOf(target);
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
if (currentIndex >= 0) {
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex]?.focus();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
if (currentIndex >= 0) {
const prevIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[prevIndex]?.focus();
}
break;
}
case 'ArrowRight': {
event.preventDefault();
if (isSubmenuTrigger) {
// Open submenu
this.openSubmenu(target);
} else {
// Move to next menubar item
const nextIndex = (this.openMenuIndex + 1) % this.menubarItems.length;
this.updateMenubarTabindex(nextIndex);
this.openMenu(nextIndex);
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
const parentSubmenu = target.closest('[data-submenu]');
if (parentSubmenu instanceof HTMLElement) {
// Close submenu and return to parent
this.closeSubmenu(parentSubmenu);
} else {
// Move to previous menubar item
const prevIndex =
this.openMenuIndex === 0 ? this.menubarItems.length - 1 : this.openMenuIndex - 1;
this.updateMenubarTabindex(prevIndex);
this.openMenu(prevIndex);
}
break;
}
case 'Home': {
event.preventDefault();
menuItems[0]?.focus();
break;
}
case 'End': {
event.preventDefault();
menuItems[menuItems.length - 1]?.focus();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
if (isSubmenuTrigger) {
this.openSubmenu(target);
} else {
this.activateItem(target);
}
break;
}
case 'Escape': {
event.preventDefault();
const parentSubmenu = target.closest('[data-submenu]');
if (parentSubmenu instanceof HTMLElement) {
this.closeSubmenu(parentSubmenu);
} else {
this.closeMenu();
this.menubarItems[this.focusedMenubarIndex]?.focus();
}
break;
}
case 'Tab': {
this.closeAllMenus();
break;
}
default: {
// Type-ahead
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.handleTypeAhead(event.key, menuItems, currentIndex);
}
}
}
}
private openSubmenu(trigger: HTMLElement) {
const container = trigger.closest('.apg-menubar-submenu-container');
const submenu = container?.querySelector('[data-submenu]');
if (!(submenu instanceof HTMLElement)) {
return;
}
trigger.setAttribute('aria-expanded', 'true');
submenu.setAttribute('aria-hidden', 'false');
// Focus first item in submenu
const items = this.getMenuItems(submenu);
items[0]?.focus();
}
private closeSubmenu(submenu: HTMLElement) {
const container = submenu.closest('.apg-menubar-submenu-container');
const trigger = container?.querySelector('[data-submenu-trigger]');
if (trigger instanceof HTMLElement) {
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
submenu.setAttribute('aria-hidden', 'true');
}
private activateItem(item: HTMLElement) {
// Disabled items should not be activatable
if (item.getAttribute('aria-disabled') === 'true') {
return;
}
const role = item.getAttribute('role');
const itemId = item.dataset.itemId;
if (role === 'menuitemcheckbox') {
// Toggle checkbox
const currentChecked = item.getAttribute('aria-checked') === 'true';
const newChecked = !currentChecked;
item.setAttribute('aria-checked', String(newChecked));
if (itemId) {
this.checkboxStates.set(itemId, newChecked);
}
this.dispatchEvent(
new CustomEvent('checkboxchange', {
detail: { itemId, checked: newChecked },
bubbles: true,
})
);
// Don't close menu for checkbox
return;
}
if (role === 'menuitemradio') {
// Update radio group
const group = item.dataset.radioGroup;
if (group) {
// Uncheck all radios in the group
const groupRadios = this.querySelectorAll<HTMLElement>(
`[role="menuitemradio"][data-radio-group="${group}"]`
);
groupRadios.forEach((radio) => {
radio.setAttribute('aria-checked', 'false');
});
// Check the selected one
item.setAttribute('aria-checked', 'true');
if (itemId) {
this.radioStates.set(group, itemId);
}
this.dispatchEvent(
new CustomEvent('radiochange', {
detail: { group, itemId },
bubbles: true,
})
);
}
// Don't close menu for radio
return;
}
// Regular menu item - dispatch event and close
if (itemId) {
this.dispatchEvent(
new CustomEvent('itemselect', {
detail: { itemId },
bubbles: true,
})
);
}
this.closeAllMenus();
this.menubarItems[this.focusedMenubarIndex]?.focus();
}
private handleTypeAhead(char: string, menuItems: HTMLElement[], currentIndex: number) {
if (menuItems.length === 0) return;
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
}
this.typeAheadBuffer += char.toLowerCase();
const buffer = this.typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let startIndex: number;
let searchStr: string;
if (isSameChar) {
this.typeAheadBuffer = buffer[0];
searchStr = buffer[0];
startIndex = currentIndex >= 0 ? (currentIndex + 1) % menuItems.length : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = currentIndex >= 0 ? (currentIndex + 1) % menuItems.length : 0;
} else {
searchStr = buffer;
startIndex = currentIndex >= 0 ? currentIndex : 0;
}
for (let i = 0; i < menuItems.length; i++) {
const index = (startIndex + i) % menuItems.length;
const item = menuItems[index];
const label = item.textContent?.trim().toLowerCase() || '';
if (label.startsWith(searchStr)) {
item.focus();
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleClickOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (this.openMenuIndex >= 0 && !this.contains(target)) {
this.closeAllMenus();
}
};
}
if (!customElements.get('apg-menubar')) {
customElements.define('apg-menubar', ApgMenubar);
}
</script> Usage
---
import Menubar, { type MenubarItem } from '@patterns/menubar/Menubar.astro';
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' },
],
},
];
---
<Menubar items={menuItems} aria-label="Application" /> 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 |
class | string | '' | Additional CSS class |
<apg-menubar> custom element handles all keyboard navigation, menu opening/closing, and state management on the client side. 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
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
/**
* Menubar Web Component Tests
*
* Note: These are limited unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Menubar (Web Component)', () => {
let container: HTMLElement;
// Simplified mock for testing basic structure
// Full behavior tests are in E2E tests
class TestApgMenubar extends HTMLElement {
private menubar: HTMLUListElement | null = null;
private openMenuIndex: number = -1;
connectedCallback() {
requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.menubar = this.querySelector('[role="menubar"]');
if (!this.menubar) return;
this.menubar.addEventListener('click', this.handleClick);
this.menubar.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
this.menubar?.removeEventListener('click', this.handleClick);
this.menubar?.removeEventListener('keydown', this.handleKeyDown);
}
private getMenubarItems(): HTMLElement[] {
if (!this.menubar) return [];
return Array.from(
this.menubar.querySelectorAll<HTMLElement>(':scope > li > [role="menuitem"]')
);
}
private handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const menuitem = target.closest('[role="menuitem"]');
if (!menuitem) return;
const menubarItems = this.getMenubarItems();
const index = menubarItems.indexOf(menuitem as HTMLElement);
if (index >= 0) {
this.toggleMenu(index);
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const menuitem = target.closest('[role="menuitem"]');
if (!menuitem) return;
const menubarItems = this.getMenubarItems();
const index = menubarItems.indexOf(menuitem as HTMLElement);
if (index < 0) return;
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
const nextIndex = (index + 1) % menubarItems.length;
menubarItems[nextIndex]?.focus();
break;
}
case 'ArrowLeft': {
event.preventDefault();
const prevIndex = index === 0 ? menubarItems.length - 1 : index - 1;
menubarItems[prevIndex]?.focus();
break;
}
case 'ArrowDown':
case 'Enter':
case ' ': {
event.preventDefault();
this.openMenu(index);
break;
}
}
};
private toggleMenu(index: number) {
if (this.openMenuIndex === index) {
this.closeMenu();
} else {
this.openMenu(index);
}
}
private openMenu(index: number) {
const menubarItems = this.getMenubarItems();
const item = menubarItems[index];
if (!item) return;
// Close previous menu
if (this.openMenuIndex >= 0) {
const prevItem = menubarItems[this.openMenuIndex];
prevItem?.setAttribute('aria-expanded', 'false');
const prevMenu = prevItem?.parentElement?.querySelector('[role="menu"]');
prevMenu?.setAttribute('hidden', '');
}
// Open new menu
item.setAttribute('aria-expanded', 'true');
const menu = item.parentElement?.querySelector('[role="menu"]');
menu?.removeAttribute('hidden');
this.openMenuIndex = index;
// Focus first menu item
const firstItem = menu?.querySelector('[role="menuitem"]') as HTMLElement;
firstItem?.focus();
}
private closeMenu() {
if (this.openMenuIndex >= 0) {
const menubarItems = this.getMenubarItems();
const item = menubarItems[this.openMenuIndex];
item?.setAttribute('aria-expanded', 'false');
const menu = item?.parentElement?.querySelector('[role="menu"]');
menu?.setAttribute('hidden', '');
this.openMenuIndex = -1;
}
}
}
function createMenubarHTML() {
return `
<apg-menubar>
<ul role="menubar" aria-label="Application">
<li role="none">
<span
id="file-menu-trigger"
role="menuitem"
tabindex="0"
aria-haspopup="menu"
aria-expanded="false"
>
File
</span>
<ul role="menu" aria-labelledby="file-menu-trigger" hidden>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="new">New</span>
</li>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="open">Open</span>
</li>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="save">Save</span>
</li>
</ul>
</li>
<li role="none">
<span
id="edit-menu-trigger"
role="menuitem"
tabindex="-1"
aria-haspopup="menu"
aria-expanded="false"
>
Edit
</span>
<ul role="menu" aria-labelledby="edit-menu-trigger" hidden>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="cut">Cut</span>
</li>
<li role="none">
<span role="menuitem" tabindex="-1" data-item-id="copy">Copy</span>
</li>
</ul>
</li>
</ul>
</apg-menubar>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
if (!customElements.get('apg-menubar')) {
customElements.define('apg-menubar', TestApgMenubar);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('Initial Rendering', () => {
it('renders with role="menubar" on container', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
expect(menubar).toBeTruthy();
});
it('renders with aria-label on menubar', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
expect(menubar?.getAttribute('aria-label')).toBe('Application');
});
it('has role="none" on all li elements', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const listItems = container.querySelectorAll('li');
listItems.forEach((li) => {
expect(li.getAttribute('role')).toBe('none');
});
});
it('has aria-haspopup="menu" on menubar items (not "true")', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
items?.forEach((item) => {
expect(item.getAttribute('aria-haspopup')).toBe('menu');
});
});
it('has aria-expanded="false" on menubar items', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
items?.forEach((item) => {
expect(item.getAttribute('aria-expanded')).toBe('false');
});
});
it('has hidden dropdown menus', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menus = container.querySelectorAll('[role="menu"]');
menus.forEach((menu) => {
expect(menu.hasAttribute('hidden')).toBe(true);
});
});
it('first menubar item has tabindex="0"', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
expect(items?.[0].getAttribute('tabindex')).toBe('0');
});
it('other menubar items have tabindex="-1"', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menubar = container.querySelector('[role="menubar"]');
const items = menubar?.querySelectorAll(':scope > li > [role="menuitem"]');
expect(items?.[1].getAttribute('tabindex')).toBe('-1');
});
it('dropdown menu has aria-labelledby referencing parent menuitem', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileMenu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
expect(fileMenu).toBeTruthy();
expect(fileMenu?.getAttribute('role')).toBe('menu');
});
});
describe('Menu Open/Close', () => {
it('opens menu on menubar item click', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.click();
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
expect(menu?.hasAttribute('hidden')).toBe(false);
});
it('closes menu on second click (toggle)', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.click(); // Open
fileItem.click(); // Close
expect(fileItem.getAttribute('aria-expanded')).toBe('false');
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
expect(menu?.hasAttribute('hidden')).toBe(true);
});
it('closes previous menu when opening another', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
const editItem = container.querySelector('#edit-menu-trigger') as HTMLElement;
fileItem.click();
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
editItem.click();
expect(fileItem.getAttribute('aria-expanded')).toBe('false');
expect(editItem.getAttribute('aria-expanded')).toBe('true');
});
});
describe('Keyboard Navigation', () => {
it('ArrowRight moves to next menubar item', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
const editItem = container.querySelector('#edit-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
// Check that edit item would receive focus (jsdom limitation)
// In real browser, editItem.focus() is called
expect(editItem).toBeTruthy();
});
it('ArrowDown opens submenu', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
});
it('Enter opens submenu', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
});
it('Space opens submenu', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.focus();
fileItem.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(fileItem.getAttribute('aria-expanded')).toBe('true');
});
});
describe('Menu Items', () => {
it('dropdown menu contains menuitem elements', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const fileItem = container.querySelector('#file-menu-trigger') as HTMLElement;
fileItem.click();
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
const items = menu?.querySelectorAll('[role="menuitem"]');
expect(items?.length).toBe(3);
expect(items?.[0].textContent?.trim()).toBe('New');
expect(items?.[1].textContent?.trim()).toBe('Open');
expect(items?.[2].textContent?.trim()).toBe('Save');
});
it('menu items have tabindex="-1" when menu opens', async () => {
container.innerHTML = createMenubarHTML();
await new Promise((r) => requestAnimationFrame(r));
const menu = container.querySelector('[aria-labelledby="file-menu-trigger"]');
const items = menu?.querySelectorAll('[role="menuitem"]');
items?.forEach((item) => {
expect(item.getAttribute('tabindex')).toBe('-1');
});
});
});
}); Resources
- WAI-ARIA APG: Menubar Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist