Menubar
A horizontal menu bar that provides application-style navigation with dropdown menus, submenus, checkbox items, and radio groups.
Demo
Last action: None
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
import type { HTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
// 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[];
}
type MenubarLabelProps =
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-label'?: never; 'aria-labelledby': string };
export type MenubarProps = Omit<
HTMLAttributes<HTMLUListElement>,
'role' | 'aria-label' | 'aria-labelledby'
> &
MenubarLabelProps & {
items: MenubarItem[];
onItemSelect?: (itemId: string) => void;
};
interface MenuState {
openMenubarIndex: number;
openSubmenuPath: string[];
focusedItemPath: string[];
}
export function Menubar({
items,
onItemSelect,
className = '',
...restProps
}: MenubarProps): ReactElement {
const instanceId = useId();
const [state, setState] = useState<MenuState>({
openMenubarIndex: -1,
openSubmenuPath: [],
focusedItemPath: [],
});
const [checkboxStates, setCheckboxStates] = useState<Map<string, boolean>>(() => {
const map = new Map<string, boolean>();
const collectCheckboxStates = (menuItems: MenuItem[]) => {
menuItems.forEach((item) => {
if (item.type === 'checkbox') {
map.set(item.id, item.checked ?? false);
} else if (item.type === 'submenu') {
collectCheckboxStates(item.items);
} else if (item.type === 'radiogroup') {
item.items.forEach((radio) => {
map.set(radio.id, radio.checked ?? false);
});
}
});
};
items.forEach((menubarItem) => collectCheckboxStates(menubarItem.items));
return map;
});
const [radioStates, setRadioStates] = useState<Map<string, string>>(() => {
const map = new Map<string, string>();
const collectRadioStates = (menuItems: MenuItem[]) => {
menuItems.forEach((item) => {
if (item.type === 'radiogroup') {
const checked = item.items.find((r) => r.checked);
if (checked) {
map.set(item.name, checked.id);
}
} else if (item.type === 'submenu') {
collectRadioStates(item.items);
}
});
};
items.forEach((menubarItem) => collectRadioStates(menubarItem.items));
return map;
});
const containerRef = useRef<HTMLUListElement>(null);
const menubarItemRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
const menuItemRefs = useRef<Map<string, HTMLSpanElement>>(new Map());
const typeAheadBuffer = useRef<string>('');
const typeAheadTimeoutId = useRef<number | null>(null);
const typeAheadTimeout = 500;
const [menubarFocusIndex, setMenubarFocusIndex] = useState(0);
const isMenuOpen = state.openMenubarIndex >= 0;
// Close all menus
const closeAllMenus = useCallback(() => {
setState({
openMenubarIndex: -1,
openSubmenuPath: [],
focusedItemPath: [],
});
typeAheadBuffer.current = '';
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
typeAheadTimeoutId.current = null;
}
}, []);
// Open a menubar item's menu
const openMenubarMenu = useCallback(
(index: number, focusPosition: 'first' | 'last' = 'first') => {
const menubarItem = items[index];
if (!menubarItem) return;
// 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;
};
const focusableItems = getAllFocusableItems(menubarItem.items);
let focusedId = '';
if (focusPosition === 'first') {
focusedId = focusableItems[0]?.id ?? '';
} else {
focusedId = focusableItems[focusableItems.length - 1]?.id ?? '';
}
setState({
openMenubarIndex: index,
openSubmenuPath: [],
focusedItemPath: focusedId ? [focusedId] : [],
});
setMenubarFocusIndex(index);
},
[items]
);
// Get all focusable items from a menu
const getFocusableItems = useCallback((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
useEffect(() => {
if (state.focusedItemPath.length === 0) return;
const focusedId = state.focusedItemPath[state.focusedItemPath.length - 1];
const element = menuItemRefs.current.get(focusedId);
element?.focus();
}, [state.focusedItemPath]);
// Focus effect for menubar items
useEffect(() => {
if (!isMenuOpen) return;
const element = menubarItemRefs.current.get(state.openMenubarIndex);
if (state.focusedItemPath.length === 0) {
element?.focus();
}
}, [isMenuOpen, state.openMenubarIndex, state.focusedItemPath.length]);
// Click outside to close
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
event.target instanceof Node &&
!containerRef.current.contains(event.target)
) {
closeAllMenus();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isMenuOpen, closeAllMenus]);
// Cleanup type-ahead timeout on unmount
useEffect(() => {
return () => {
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
}
};
}, []);
// Handle type-ahead
const handleTypeAhead = useCallback(
(char: string, focusableItems: MenuItem[]) => {
const enabledItems = focusableItems.filter((item) =>
'disabled' in item ? !item.disabled : true
);
if (enabledItems.length === 0) return;
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
}
typeAheadBuffer.current += char.toLowerCase();
const buffer = typeAheadBuffer.current;
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.current = 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)) {
setState((prev) => ({
...prev,
focusedItemPath: [...prev.focusedItemPath.slice(0, -1), item.id],
}));
break;
}
}
typeAheadTimeoutId.current = window.setTimeout(() => {
typeAheadBuffer.current = '';
typeAheadTimeoutId.current = null;
}, typeAheadTimeout);
},
[state.focusedItemPath]
);
// Handle menubar item keyboard navigation
const handleMenubarKeyDown = useCallback(
(event: KeyboardEvent<HTMLSpanElement>, index: number) => {
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
const nextIndex = (index + 1) % items.length;
setMenubarFocusIndex(nextIndex);
if (isMenuOpen) {
openMenubarMenu(nextIndex, 'first');
} else {
menubarItemRefs.current.get(nextIndex)?.focus();
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
const prevIndex = index === 0 ? items.length - 1 : index - 1;
setMenubarFocusIndex(prevIndex);
if (isMenuOpen) {
openMenubarMenu(prevIndex, 'first');
} else {
menubarItemRefs.current.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();
setMenubarFocusIndex(0);
menubarItemRefs.current.get(0)?.focus();
break;
}
case 'End': {
event.preventDefault();
const lastIndex = items.length - 1;
setMenubarFocusIndex(lastIndex);
menubarItemRefs.current.get(lastIndex)?.focus();
break;
}
case 'Escape': {
event.preventDefault();
closeAllMenus();
break;
}
case 'Tab': {
closeAllMenus();
break;
}
}
},
[items.length, isMenuOpen, openMenubarMenu, closeAllMenus]
);
// Handle menubar item click
const handleMenubarClick = useCallback(
(index: number) => {
if (state.openMenubarIndex === index) {
closeAllMenus();
} else {
openMenubarMenu(index, 'first');
}
},
[state.openMenubarIndex, closeAllMenus, openMenubarMenu]
);
// Handle menubar item hover
const handleMenubarHover = useCallback(
(index: number) => {
if (isMenuOpen && state.openMenubarIndex !== index) {
openMenubarMenu(index, 'first');
}
},
[isMenuOpen, state.openMenubarIndex, openMenubarMenu]
);
// Get first focusable item from menu items
const getFirstFocusableItem = useCallback((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;
}, []);
// Handle menu item activation
const activateMenuItem = useCallback(
(item: MenuItem, radioGroupName?: string) => {
if ('disabled' in item && item.disabled) return;
if (item.type === 'item') {
onItemSelect?.(item.id);
closeAllMenus();
menubarItemRefs.current.get(state.openMenubarIndex)?.focus();
} else if (item.type === 'checkbox') {
const newChecked = !checkboxStates.get(item.id);
setCheckboxStates((prev) => new Map(prev).set(item.id, newChecked));
item.onCheckedChange?.(newChecked);
// Menu stays open for checkbox
} else if (item.type === 'radio' && radioGroupName) {
setRadioStates((prev) => new Map(prev).set(radioGroupName, item.id));
// Menu stays open for radio
} else if (item.type === 'submenu') {
// Open submenu and focus first item
const firstItem = getFirstFocusableItem(item.items);
setState((prev) => ({
...prev,
openSubmenuPath: [...prev.openSubmenuPath, item.id],
focusedItemPath: firstItem
? [...prev.focusedItemPath, firstItem.id]
: prev.focusedItemPath,
}));
}
},
[onItemSelect, closeAllMenus, checkboxStates, state.openMenubarIndex, getFirstFocusableItem]
);
// Handle menu item keyboard navigation
const handleMenuKeyDown = useCallback(
(
event: KeyboardEvent<HTMLSpanElement>,
item: MenuItem,
menuItems: MenuItem[],
isSubmenu: boolean,
radioGroupName?: string
) => {
const focusableItems = getFocusableItems(menuItems);
const currentIndex = focusableItems.findIndex((i) => i.id === item.id);
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
// Disabled items are focusable per APG
const nextIndex = (currentIndex + 1) % focusableItems.length;
const nextItem = focusableItems[nextIndex];
if (nextItem) {
setState((prev) => ({
...prev,
focusedItemPath: [...prev.focusedItemPath.slice(0, -1), nextItem.id],
}));
}
break;
}
case 'ArrowUp': {
event.preventDefault();
// Disabled items are focusable per APG
const prevIndex = currentIndex === 0 ? focusableItems.length - 1 : currentIndex - 1;
const prevItem = focusableItems[prevIndex];
if (prevItem) {
setState((prev) => ({
...prev,
focusedItemPath: [...prev.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);
setState((prev) => ({
...prev,
openSubmenuPath: [...prev.openSubmenuPath, item.id],
focusedItemPath: firstItem
? [...prev.focusedItemPath, firstItem.id]
: prev.focusedItemPath,
}));
} else if (!isSubmenu) {
// Move to next menubar item
const nextMenubarIndex = (state.openMenubarIndex + 1) % items.length;
openMenubarMenu(nextMenubarIndex, 'first');
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
if (isSubmenu) {
// Close submenu, return to parent submenu trigger
// The parent ID is the last entry in openSubmenuPath (the submenu trigger that opened this submenu)
const parentId = state.openSubmenuPath[state.openSubmenuPath.length - 1];
setState((prev) => ({
...prev,
openSubmenuPath: prev.openSubmenuPath.slice(0, -1),
focusedItemPath: parentId
? [...prev.focusedItemPath.slice(0, -1).filter((id) => id !== parentId), parentId]
: prev.focusedItemPath.slice(0, -1),
}));
// Explicitly focus parent after state update
if (parentId) {
setTimeout(() => {
menuItemRefs.current.get(parentId)?.focus();
}, 0);
}
} else {
// Move to previous menubar item
const prevMenubarIndex =
state.openMenubarIndex === 0 ? items.length - 1 : state.openMenubarIndex - 1;
openMenubarMenu(prevMenubarIndex, 'first');
}
break;
}
case 'Home': {
event.preventDefault();
// Disabled items are focusable per APG
const firstItem = focusableItems[0];
if (firstItem) {
setState((prev) => ({
...prev,
focusedItemPath: [...prev.focusedItemPath.slice(0, -1), firstItem.id],
}));
}
break;
}
case 'End': {
event.preventDefault();
// Disabled items are focusable per APG
const lastItem = focusableItems[focusableItems.length - 1];
if (lastItem) {
setState((prev) => ({
...prev,
focusedItemPath: [...prev.focusedItemPath.slice(0, -1), lastItem.id],
}));
}
break;
}
case 'Escape': {
event.preventDefault();
if (isSubmenu) {
// Close submenu, return to parent
setState((prev) => ({
...prev,
openSubmenuPath: prev.openSubmenuPath.slice(0, -1),
focusedItemPath: prev.focusedItemPath.slice(0, -1),
}));
} else {
// Close menu, return to menubar item
closeAllMenus();
menubarItemRefs.current.get(state.openMenubarIndex)?.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);
}
}
}
},
[
getFocusableItems,
getFirstFocusableItem,
state.openMenubarIndex,
state.openSubmenuPath,
items.length,
openMenubarMenu,
closeAllMenus,
activateMenuItem,
handleTypeAhead,
]
);
// Render menu items recursively
const renderMenuItems = useCallback(
(
menuItems: MenuItem[],
parentId: string,
isSubmenu: boolean,
depth: number = 0
): ReactElement[] => {
const elements: ReactElement[] = [];
menuItems.forEach((item) => {
if (item.type === 'separator') {
elements.push(
<li key={item.id} role="none">
<hr className="apg-menubar-separator" />
</li>
);
} else if (item.type === 'radiogroup') {
const { name, label, id } = item;
elements.push(
<li key={id} role="none">
<ul role="group" aria-label={label} className="apg-menubar-group">
{item.items.map((radioItem) => {
const { id: radioItemId, label: radioItemLabel, disabled } = radioItem;
const isChecked = radioStates.get(name) === radioItemId;
const isFocused =
state.focusedItemPath[state.focusedItemPath.length - 1] === radioItemId;
return (
<li key={radioItemId} role="none">
<span
ref={(el) => {
if (el) {
menuItemRefs.current.set(radioItemId, el);
} else {
menuItemRefs.current.delete(radioItemId);
}
}}
role="menuitemradio"
aria-checked={isChecked}
aria-disabled={disabled || undefined}
tabIndex={isFocused ? 0 : -1}
className="apg-menubar-menuitem apg-menubar-menuitemradio"
onClick={() => activateMenuItem(radioItem, name)}
onKeyDown={(e) =>
handleMenuKeyDown(e, radioItem, menuItems, isSubmenu, name)
}
>
{radioItemLabel}
</span>
</li>
);
})}
</ul>
</li>
);
} else if (item.type === 'checkbox') {
const isChecked = checkboxStates.get(item.id) ?? false;
const isFocused = state.focusedItemPath[state.focusedItemPath.length - 1] === item.id;
elements.push(
<li key={item.id} role="none">
<span
ref={(el) => {
if (el) {
menuItemRefs.current.set(item.id, el);
} else {
menuItemRefs.current.delete(item.id);
}
}}
role="menuitemcheckbox"
aria-checked={isChecked}
aria-disabled={item.disabled || undefined}
tabIndex={isFocused ? 0 : -1}
className="apg-menubar-menuitem apg-menubar-menuitemcheckbox"
onClick={() => activateMenuItem(item)}
onKeyDown={(e) => handleMenuKeyDown(e, item, menuItems, isSubmenu)}
>
{item.label}
</span>
</li>
);
} else if (item.type === 'submenu') {
const isExpanded = state.openSubmenuPath.includes(item.id);
const isFocused = state.focusedItemPath[state.focusedItemPath.length - 1] === item.id;
const submenuId = `${instanceId}-submenu-${item.id}`;
elements.push(
<li key={item.id} role="none">
<span
id={`${instanceId}-menuitem-${item.id}`}
ref={(el) => {
if (el) {
menuItemRefs.current.set(item.id, el);
} else {
menuItemRefs.current.delete(item.id);
}
}}
role="menuitem"
aria-haspopup="menu"
aria-expanded={isExpanded}
aria-disabled={item.disabled || undefined}
tabIndex={isFocused ? 0 : -1}
className="apg-menubar-menuitem apg-menubar-submenu-trigger"
onClick={() => activateMenuItem(item)}
onKeyDown={(e) => handleMenuKeyDown(e, item, menuItems, isSubmenu)}
>
{item.label}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ marginLeft: 'auto', position: 'relative', top: '1px' }}
>
<path d="m9 18 6-6-6-6" />
</svg>
</span>
<ul
id={submenuId}
role="menu"
aria-labelledby={`${instanceId}-menuitem-${item.id}`}
className="apg-menubar-submenu"
aria-hidden={!isExpanded}
>
{isExpanded && renderMenuItems(item.items, item.id, true, depth + 1)}
</ul>
</li>
);
} else {
// Regular menuitem
const isFocused = state.focusedItemPath[state.focusedItemPath.length - 1] === item.id;
elements.push(
<li key={item.id} role="none">
<span
ref={(el) => {
if (el) {
menuItemRefs.current.set(item.id, el);
} else {
menuItemRefs.current.delete(item.id);
}
}}
role="menuitem"
aria-disabled={item.disabled || undefined}
tabIndex={isFocused ? 0 : -1}
className="apg-menubar-menuitem"
onClick={() => activateMenuItem(item)}
onKeyDown={(e) => handleMenuKeyDown(e, item, menuItems, isSubmenu)}
>
{item.label}
</span>
</li>
);
}
});
return elements;
},
[
instanceId,
state.openSubmenuPath,
state.focusedItemPath,
checkboxStates,
radioStates,
activateMenuItem,
handleMenuKeyDown,
]
);
return (
<ul
ref={containerRef}
role="menubar"
className={`apg-menubar ${className}`.trim()}
{...restProps}
>
{items.map((menubarItem, index) => {
const isExpanded = state.openMenubarIndex === index;
const menuId = `${instanceId}-menu-${menubarItem.id}`;
const menubarItemId = `${instanceId}-menubar-${menubarItem.id}`;
return (
<li key={menubarItem.id} role="none">
<span
id={menubarItemId}
ref={(el) => {
if (el) {
menubarItemRefs.current.set(index, el);
} else {
menubarItemRefs.current.delete(index);
}
}}
role="menuitem"
aria-haspopup="menu"
aria-expanded={isExpanded}
tabIndex={index === menubarFocusIndex ? 0 : -1}
className="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"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="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={menubarItemId}
className="apg-menubar-menu"
aria-hidden={!isExpanded}
>
{isExpanded && renderMenuItems(menubarItem.items, menubarItem.id, false)}
</ul>
</li>
);
})}
</ul>
);
}
export default Menubar; Usage
import { Menubar, type MenubarItem } from './Menubar';
import '@patterns/menubar/menubar.css';
const menuItems: MenubarItem[] = [
{
id: 'file',
label: 'File',
items: [
{ type: 'item', id: 'new', label: 'New' },
{ type: 'item', id: 'open', label: 'Open' },
{ type: 'separator', id: 'sep1' },
{ type: 'item', id: 'save', label: 'Save' },
{ type: 'item', id: 'export', label: 'Export', disabled: true },
],
},
{
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: 'checkbox', id: 'toolbar', label: 'Show Toolbar', checked: true },
{ type: 'separator', id: 'sep2' },
{
type: 'radiogroup',
id: 'theme',
label: 'Theme',
items: [
{ type: 'radio', id: 'light', label: 'Light', checked: true },
{ type: 'radio', id: 'dark', label: 'Dark', checked: false },
],
},
],
},
];
<Menubar
items={menuItems}
aria-label="Application"
onItemSelect={(id) => console.log('Selected:', id)}
/> 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 (required if no aria-label) |
onItemSelect | (id: string) => void | - | Callback when an item is activated |
className | string | '' | Additional CSS class for the container |
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.
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Menubar, type MenubarItem, type MenuItem } from './Menubar';
afterEach(() => {
vi.useRealTimers();
});
// Helper function to create basic menubar items
const createBasicItems = (): MenubarItem[] => [
{
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 submenu
const createItemsWithSubmenu = (): MenubarItem[] => [
{
id: 'file',
label: 'File',
items: [
{ type: 'item', id: 'new', label: 'New' },
{
type: 'submenu',
id: 'open-recent',
label: 'Open Recent',
items: [
{ type: 'item', id: 'doc1', label: 'Document 1' },
{ type: 'item', id: 'doc2', label: 'Document 2' },
],
},
{ type: 'item', id: 'save', label: 'Save' },
],
},
{
id: 'edit',
label: 'Edit',
items: [{ type: 'item', id: 'cut', label: 'Cut' }],
},
];
// Items with checkbox and radio
const createItemsWithCheckboxRadio = (): MenubarItem[] => [
{
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 },
{ type: 'radio', id: 'system', label: 'System', checked: false },
],
},
],
},
];
// Items with disabled items
const createItemsWithDisabled = (): MenubarItem[] => [
{
id: 'file',
label: 'File',
items: [
{ type: 'item', id: 'new', label: 'New' },
{ type: 'item', id: 'open', label: 'Open', disabled: true },
{ type: 'item', id: 'save', label: 'Save' },
],
},
];
// Items with separator
const createItemsWithSeparator = (): MenubarItem[] => [
{
id: 'file',
label: 'File',
items: [
{ type: 'item', id: 'new', label: 'New' },
{ type: 'separator', id: 'sep1' },
{ type: 'item', id: 'save', label: 'Save' },
],
},
];
describe('Menubar', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG ARIA Attributes', () => {
it('has role="menubar" on container', () => {
render(<Menubar items={createBasicItems()} aria-label="Application" />);
expect(screen.getByRole('menubar')).toBeInTheDocument();
});
it('has role="menu" on dropdown', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
expect(screen.getByRole('menu')).toBeInTheDocument();
});
it('has role="menuitem" on items', () => {
render(<Menubar items={createBasicItems()} aria-label="Application" />);
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
});
it('has role="menuitemcheckbox" on checkbox items', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
});
it('has role="menuitemradio" on radio items', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
expect(screen.getAllByRole('menuitemradio')).toHaveLength(3);
});
it('has role="separator" on dividers', async () => {
const user = userEvent.setup();
render(<Menubar 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 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();
});
it('has role="none" on li elements', () => {
render(<Menubar 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 (not "true")', () => {
render(<Menubar 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 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 items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
});
it('has aria-checked on checkbox items', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
expect(autoSave).toHaveAttribute('aria-checked', 'false');
const wordWrap = screen.getByRole('menuitemcheckbox', { name: 'Word Wrap' });
expect(wordWrap).toHaveAttribute('aria-checked', 'true');
});
it('has aria-checked on radio items', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
const lightRadio = screen.getByRole('menuitemradio', { name: 'Light' });
expect(lightRadio).toHaveAttribute('aria-checked', 'true');
const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
expect(darkRadio).toHaveAttribute('aria-checked', 'false');
});
it('has accessible name on menubar', () => {
render(<Menubar items={createBasicItems()} aria-label="Application" />);
expect(screen.getByRole('menubar', { name: 'Application' })).toBeInTheDocument();
});
it('submenu has aria-labelledby referencing parent menuitem', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithSubmenu()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
openRecent.focus();
await user.keyboard('{ArrowRight}');
const submenu = screen.getAllByRole('menu')[1];
const labelledBy = submenu.getAttribute('aria-labelledby');
expect(labelledBy).toBe(openRecent.id);
});
it('closed menu has hidden or aria-hidden', () => {
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const menus = document.querySelectorAll('[role="menu"]');
menus.forEach((menu) => {
const hasHidden =
menu.hasAttribute('hidden') || menu.getAttribute('aria-hidden') === 'true';
expect(hasHidden).toBe(true);
});
});
});
// 🔴 High Priority: Keyboard Interaction - Menubar
describe('APG Keyboard Interaction - Menubar', () => {
it('ArrowRight moves to next menubar item', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
});
it('ArrowLeft moves to previous menubar item', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const editItem = screen.getByRole('menuitem', { name: 'Edit' });
editItem.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('menuitem', { name: 'File' })).toHaveFocus();
});
it('ArrowRight wraps from last to first', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
viewItem.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('menuitem', { name: 'File' })).toHaveFocus();
});
it('ArrowLeft wraps from first to last', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('menuitem', { name: 'View' })).toHaveFocus();
});
it('ArrowDown opens submenu and focuses first item', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard('{ArrowDown}');
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 items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard('{ArrowUp}');
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
it('Enter opens submenu', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard('{Enter}');
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('Space opens submenu', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard(' ');
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('Home moves to first menubar item', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
viewItem.focus();
await user.keyboard('{Home}');
expect(screen.getByRole('menuitem', { name: 'File' })).toHaveFocus();
});
it('End moves to last menubar item', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
fileItem.focus();
await user.keyboard('{End}');
expect(screen.getByRole('menuitem', { name: 'View' })).toHaveFocus();
});
it('Tab moves focus out and closes all menus', async () => {
const user = userEvent.setup();
render(
<div>
<Menubar items={createBasicItems()} aria-label="Application" />
<button>Outside</button>
</div>
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Tab}');
expect(fileItem).toHaveAttribute('aria-expanded', 'false');
});
it('Shift+Tab moves focus out and closes all menus', async () => {
const user = userEvent.setup();
render(
<div>
<button>Before</button>
<Menubar items={createBasicItems()} aria-label="Application" />
</div>
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
// Use fireEvent instead of user.keyboard for Shift+Tab due to jsdom limitations
// with user-event's tab destination calculation
fireEvent.keyDown(fileItem, { key: 'Tab', shiftKey: true });
expect(fileItem).toHaveAttribute('aria-expanded', 'false');
});
});
// 🔴 High Priority: Keyboard Interaction - Menu
describe('APG Keyboard Interaction - Menu', () => {
it('ArrowDown moves to next item', async () => {
const user = userEvent.setup();
render(<Menubar 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}');
expect(screen.getByRole('menuitem', { name: 'Open' })).toHaveFocus();
});
it('ArrowUp moves to previous item', async () => {
const user = userEvent.setup();
render(<Menubar 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}');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('ArrowDown wraps from last to first', async () => {
const user = userEvent.setup();
render(<Menubar 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('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('ArrowUp wraps from first to last', async () => {
const user = userEvent.setup();
render(<Menubar 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('{ArrowUp}');
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
it('ArrowRight opens submenu when item has one', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithSubmenu()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
openRecent.focus();
await user.keyboard('{ArrowRight}');
expect(openRecent).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Document 1' })).toHaveFocus();
});
it('ArrowRight moves to next menubar item when in top-level menu (item has no submenu)', async () => {
const user = userEvent.setup();
render(<Menubar 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('{ArrowRight}');
// File menu should close, Edit menu should open
expect(fileItem).toHaveAttribute('aria-expanded', 'false');
const editItem = screen.getByRole('menuitem', { name: 'Edit' });
expect(editItem).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveFocus();
});
it('ArrowLeft closes submenu and returns to parent', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithSubmenu()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
openRecent.focus();
await user.keyboard('{ArrowRight}');
expect(openRecent).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{ArrowLeft}');
expect(openRecent).toHaveAttribute('aria-expanded', 'false');
expect(openRecent).toHaveFocus();
});
it('ArrowLeft moves to previous menubar item when in top-level menu', async () => {
const user = userEvent.setup();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const editItem = screen.getByRole('menuitem', { name: 'Edit' });
await user.click(editItem);
const cutItem = screen.getByRole('menuitem', { name: 'Cut' });
cutItem.focus();
await user.keyboard('{ArrowLeft}');
// Edit menu should close, File menu should open
expect(editItem).toHaveAttribute('aria-expanded', 'false');
const fileItem = screen.getByRole('menuitem', { name: 'File' });
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('Enter activates menuitem and closes menu', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<Menubar items={createBasicItems()} aria-label="Application" onItemSelect={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('Space activates menuitem and closes menu', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const newItem = screen.getByRole('menuitem', { name: 'New' });
newItem.focus();
await user.keyboard(' ');
expect(onItemSelect).toHaveBeenCalledWith('new');
expect(fileItem).toHaveAttribute('aria-expanded', 'false');
});
it('Escape closes menu and returns focus to menubar', async () => {
const user = userEvent.setup();
render(<Menubar 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('Home moves to first item', async () => {
const user = userEvent.setup();
render(<Menubar 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}');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('End moves to last item', async () => {
const user = userEvent.setup();
render(<Menubar 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}');
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
});
// 🔴 High Priority: Checkbox and Radio Items
describe('Checkbox and Radio Items', () => {
it('Space toggles menuitemcheckbox', async () => {
const user = userEvent.setup();
const onCheckedChange = vi.fn();
const items = createItemsWithCheckboxRadio();
(items[0].items[0] as any).onCheckedChange = onCheckedChange;
render(<Menubar items={items} 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(' ');
expect(onCheckedChange).toHaveBeenCalledWith(true);
});
it('Enter toggles menuitemcheckbox', async () => {
const user = userEvent.setup();
const onCheckedChange = vi.fn();
const items = createItemsWithCheckboxRadio();
(items[0].items[0] as any).onCheckedChange = onCheckedChange;
render(<Menubar items={items} 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('{Enter}');
expect(onCheckedChange).toHaveBeenCalledWith(true);
});
it('Space on checkbox does not close menu', async () => {
const user = userEvent.setup();
render(<Menubar 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('Enter on checkbox does not close menu', async () => {
const user = userEvent.setup();
render(<Menubar 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('{Enter}');
// Menu should still be open
expect(viewItem).toHaveAttribute('aria-expanded', 'true');
});
it('updates aria-checked on toggle', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
const autoSave = screen.getByRole('menuitemcheckbox', { name: 'Auto Save' });
expect(autoSave).toHaveAttribute('aria-checked', 'false');
autoSave.focus();
await user.keyboard(' ');
expect(autoSave).toHaveAttribute('aria-checked', 'true');
});
it('Space selects menuitemradio', async () => {
const user = userEvent.setup();
render(<Menubar 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(' ');
expect(darkRadio).toHaveAttribute('aria-checked', 'true');
});
it('Space on radio does not close menu', async () => {
const user = userEvent.setup();
render(<Menubar 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('Enter on radio does not close menu', async () => {
const user = userEvent.setup();
render(<Menubar 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('{Enter}');
// 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 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(' ');
expect(lightRadio).toHaveAttribute('aria-checked', 'false');
expect(darkRadio).toHaveAttribute('aria-checked', 'true');
});
it('unchecks other radios in group when one is selected', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
const systemRadio = screen.getByRole('menuitemradio', { name: 'System' });
systemRadio.focus();
await user.keyboard(' ');
const lightRadio = screen.getByRole('menuitemradio', { name: 'Light' });
const darkRadio = screen.getByRole('menuitemradio', { name: 'Dark' });
expect(lightRadio).toHaveAttribute('aria-checked', 'false');
expect(darkRadio).toHaveAttribute('aria-checked', 'false');
expect(systemRadio).toHaveAttribute('aria-checked', 'true');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('first menubar item has tabIndex="0"', () => {
render(<Menubar 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 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');
});
it('separator is not focusable', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithSeparator()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const separator = screen.getByRole('separator');
expect(separator).not.toHaveAttribute('tabindex');
// Navigate through - should skip separator
const newItem = screen.getByRole('menuitem', { name: 'New' });
newItem.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
it('disabled items are focusable', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithDisabled()} 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}');
const openItem = screen.getByRole('menuitem', { name: 'Open' });
expect(openItem).toHaveFocus();
expect(openItem).toHaveAttribute('aria-disabled', 'true');
});
it('disabled items cannot be activated', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<Menubar
items={createItemsWithDisabled()}
aria-label="Application"
onItemSelect={onItemSelect}
/>
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const openItem = screen.getByRole('menuitem', { name: 'Open' });
openItem.focus();
await user.keyboard('{Enter}');
expect(onItemSelect).not.toHaveBeenCalled();
// Menu should still be open
expect(fileItem).toHaveAttribute('aria-expanded', 'true');
});
});
// 🔴 High Priority: Type-Ahead
describe('Type-Ahead', () => {
it('character focuses matching item', async () => {
const user = userEvent.setup();
render(<Menubar 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('s');
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
it('search wraps around', async () => {
const user = userEvent.setup();
render(<Menubar 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('n');
expect(screen.getByRole('menuitem', { name: 'New' })).toHaveFocus();
});
it('skips separator', async () => {
const user = userEvent.setup();
render(<Menubar items={createItemsWithSeparator()} 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('s');
// Should find Save, not get stuck on separator
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
it('skips disabled items', async () => {
const user = userEvent.setup();
const items: MenubarItem[] = [
{
id: 'file',
label: 'File',
items: [
{ type: 'item', id: 'open', label: 'Open', disabled: true },
{ type: 'item', id: 'options', label: 'Options' },
],
},
];
render(<Menubar items={items} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const optionsItem = screen.getByRole('menuitem', { name: 'Options' });
optionsItem.focus();
await user.keyboard('o');
// Should wrap to Options (skip disabled Open)
expect(screen.getByRole('menuitem', { name: 'Options' })).toHaveFocus();
});
it('resets after 500ms', async () => {
vi.useFakeTimers();
render(<Menubar items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
act(() => {
fireEvent.click(fileItem);
});
const newItem = screen.getByRole('menuitem', { name: 'New' });
act(() => {
newItem.focus();
});
// Type 'o' -> should focus 'Open'
act(() => {
fireEvent.keyDown(newItem, { key: 'o' });
});
expect(screen.getByRole('menuitem', { name: 'Open' })).toHaveFocus();
// Wait 500ms for reset
act(() => {
vi.advanceTimersByTime(500);
});
// Type 's' -> should focus 'Save' (not 'os')
const openItem = screen.getByRole('menuitem', { name: 'Open' });
act(() => {
fireEvent.keyDown(openItem, { key: 's' });
});
expect(screen.getByRole('menuitem', { name: 'Save' })).toHaveFocus();
});
});
// 🔴 High Priority: Pointer Interaction
describe('Pointer Interaction', () => {
it('click on menubar item opens menu', async () => {
const user = userEvent.setup();
render(<Menubar 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 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 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);
expect(fileItem).toHaveAttribute('aria-expanded', 'false');
expect(editItem).toHaveAttribute('aria-expanded', 'true');
});
it('click on menuitem activates and closes menu', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const newItem = screen.getByRole('menuitem', { name: 'New' });
await user.click(newItem);
expect(onItemSelect).toHaveBeenCalledWith('new');
expect(fileItem).toHaveAttribute('aria-expanded', 'false');
});
it('click outside closes menu', async () => {
const user = userEvent.setup();
render(
<div>
<Menubar items={createBasicItems()} aria-label="Application" />
<button>Outside</button>
</div>
);
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 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 items={createBasicItems()} aria-label="Application" />);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with submenu open', async () => {
const user = userEvent.setup();
const { container } = render(
<Menubar items={createItemsWithSubmenu()} aria-label="Application" />
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const openRecent = screen.getByRole('menuitem', { name: 'Open Recent' });
openRecent.focus();
await user.keyboard('{ArrowRight}');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with checkbox and radio', async () => {
const user = userEvent.setup();
const { container } = render(
<Menubar items={createItemsWithCheckboxRadio()} aria-label="Application" />
);
const viewItem = screen.getByRole('menuitem', { name: 'View' });
await user.click(viewItem);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('calls onItemSelect with correct id', async () => {
const user = userEvent.setup();
const onItemSelect = vi.fn();
render(
<Menubar items={createBasicItems()} aria-label="Application" onItemSelect={onItemSelect} />
);
const fileItem = screen.getByRole('menuitem', { name: 'File' });
await user.click(fileItem);
const saveItem = screen.getByRole('menuitem', { name: 'Save' });
await user.click(saveItem);
expect(onItemSelect).toHaveBeenCalledWith('save');
});
it('applies className to container', () => {
const { container } = render(
<Menubar items={createBasicItems()} aria-label="Application" className="custom-class" />
);
expect(container.querySelector('.apg-menubar')).toHaveClass('custom-class');
});
it('supports aria-labelledby instead of aria-label', () => {
render(
<div>
<h2 id="menu-heading">Application Menu</h2>
<Menubar items={createBasicItems()} aria-labelledby="menu-heading" />
</div>
);
const menubar = screen.getByRole('menubar');
expect(menubar).toHaveAttribute('aria-labelledby', 'menu-heading');
});
});
}); 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