Menu Button
A button that opens a menu of actions or options.
🤖 AI Implementation GuideDemo
Basic Menu Button
Click the button or use keyboard to open the menu.
Last action: None
With Disabled Items
Disabled items are skipped during keyboard navigation.
Last action: None
Note: "Export" is disabled and will be skipped during keyboard navigation
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
button | Trigger (<button>) | The trigger that opens the menu (implicit via <button> element) |
menu | Container (<ul>) | A widget offering a list of choices to the user |
menuitem | Each item (<li>) | An option in a menu |
WAI-ARIA menu role (opens in new tab)
WAI-ARIA Properties (Button)
| Attribute | Values | Required | Description |
|---|---|---|---|
aria-haspopup | "menu" | Yes | Indicates the button opens a menu |
aria-expanded | true | false | Yes | Indicates whether the menu is open |
aria-controls | ID reference | No | References the menu element |
WAI-ARIA Properties (Menu)
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-labelledby | menu | ID reference | Yes* | References the button that opens the menu |
aria-label | menu | String | Yes* | Provides an accessible name for the menu |
aria-disabled | menuitem | true | No | Indicates the menu item is disabled |
* Either aria-labelledby or aria-label is required for an accessible name
Keyboard Support
Button (Closed Menu)
| Key | Action |
|---|---|
| Enter / Space | Open menu and focus first item |
| Down Arrow | Open menu and focus first item |
| Up Arrow | Open menu and focus last item |
Menu (Open)
| Key | Action |
|---|---|
| Down Arrow | Move focus to next item (wraps to first) |
| Up Arrow | Move focus to previous item (wraps to last) |
| Home | Move focus to first item |
| End | Move focus to last item |
| Escape | Close menu and return focus to button |
| Tab | Close menu and move focus to next focusable element |
| Enter / Space | Activate focused item and close menu |
| Type character | Type-ahead: focus item starting with typed character(s) |
Focus Management
This component uses the Roving Tabindex pattern for focus management:
- Only one menu item has
tabindex="0"at a time - Other menu items have
tabindex="-1" - Arrow keys move focus between items with wrapping
- Disabled items are skipped during navigation
- Focus returns to button when menu closes
Hidden State
When closed, the menu uses both hidden and inert attributes to:
- Hide the menu from visual display
- Remove the menu from the accessibility tree
- Prevent keyboard and mouse interaction with hidden items
Source Code
MenuButton.astro
---
/**
* APG Menu Button Pattern - Astro Implementation
*
* A button that opens a menu of actions or functions.
* Uses Web Components for enhanced control and proper focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
*/
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
}
export interface Props {
/** Array of menu items */
items: MenuItem[];
/** Button label */
label: string;
/** Whether menu is initially open */
defaultOpen?: boolean;
/** Additional CSS class */
class?: string;
}
const { items = [], label, defaultOpen = false, class: className = '' } = Astro.props;
// Generate unique ID for this instance
const instanceId = `menu-button-${Math.random().toString(36).slice(2, 11)}`;
const buttonId = `${instanceId}-button`;
const menuId = `${instanceId}-menu`;
// Calculate available items and initial focus
const availableItems = items.filter((item) => !item.disabled);
const initialFocusIndex = defaultOpen && availableItems.length > 0 ? 0 : -1;
---
<apg-menu-button
data-default-open={defaultOpen ? 'true' : undefined}
data-initial-focus-index={initialFocusIndex}
>
<div class={`apg-menu-button ${className}`.trim()}>
<button
id={buttonId}
type="button"
class="apg-menu-button-trigger"
aria-haspopup="menu"
aria-expanded={defaultOpen}
aria-controls={menuId}
data-menu-trigger
>
{label}
</button>
<ul
id={menuId}
role="menu"
aria-labelledby={buttonId}
class="apg-menu-button-menu"
hidden={!defaultOpen || undefined}
inert={!defaultOpen || undefined}
data-menu-list
>
{
items.map((item, index) => {
const availableIndex = availableItems.findIndex((i) => i.id === item.id);
const isFocusTarget = availableIndex === initialFocusIndex;
const tabIndex = item.disabled ? -1 : isFocusTarget ? 0 : -1;
return (
<li
role="menuitem"
data-item-id={item.id}
tabindex={tabIndex}
aria-disabled={item.disabled || undefined}
class="apg-menu-button-item"
>
{item.label}
</li>
);
})
}
</ul>
</div>
</apg-menu-button>
<script>
class ApgMenuButton extends HTMLElement {
private container: HTMLDivElement | null = null;
private button: HTMLButtonElement | null = null;
private menu: HTMLUListElement | null = null;
private rafId: number | null = null;
private isOpen = false;
private focusedIndex = -1;
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private typeAheadTimeout = 500;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.container = this.querySelector('.apg-menu-button');
this.button = this.querySelector('[data-menu-trigger]');
this.menu = this.querySelector('[data-menu-list]');
if (!this.button || !this.menu) {
console.warn('apg-menu-button: required elements not found');
return;
}
// Initialize state from data attributes
this.isOpen = this.dataset.defaultOpen === 'true';
this.focusedIndex = parseInt(this.dataset.initialFocusIndex || '-1', 10);
// Attach event listeners
this.button.addEventListener('click', this.handleButtonClick);
this.button.addEventListener('keydown', this.handleButtonKeyDown);
this.menu.addEventListener('keydown', this.handleMenuKeyDown);
this.menu.addEventListener('click', this.handleMenuClick);
this.menu.addEventListener('focusin', this.handleMenuFocusIn);
// Click outside listener (only when open)
if (this.isOpen) {
document.addEventListener('pointerdown', this.handleClickOutside);
// Initialize roving tabindex and focus first item for APG compliance
this.updateTabIndices();
const availableItems = this.getAvailableItems();
if (this.focusedIndex >= 0 && availableItems[this.focusedIndex]) {
availableItems[this.focusedIndex].focus();
}
}
}
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.button?.removeEventListener('click', this.handleButtonClick);
this.button?.removeEventListener('keydown', this.handleButtonKeyDown);
this.menu?.removeEventListener('keydown', this.handleMenuKeyDown);
this.menu?.removeEventListener('click', this.handleMenuClick);
this.menu?.removeEventListener('focusin', this.handleMenuFocusIn);
}
private getItems(): HTMLLIElement[] {
if (!this.menu) return [];
return Array.from(this.menu.querySelectorAll<HTMLLIElement>('[role="menuitem"]'));
}
private getAvailableItems(): HTMLLIElement[] {
return this.getItems().filter((item) => item.getAttribute('aria-disabled') !== 'true');
}
private openMenu(focusPosition: 'first' | 'last') {
if (!this.button || !this.menu) return;
const availableItems = this.getAvailableItems();
this.isOpen = true;
this.button.setAttribute('aria-expanded', 'true');
this.menu.removeAttribute('hidden');
this.menu.removeAttribute('inert');
document.addEventListener('pointerdown', this.handleClickOutside);
if (availableItems.length === 0) {
this.focusedIndex = -1;
return;
}
const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
this.focusedIndex = targetIndex;
this.updateTabIndices();
availableItems[targetIndex]?.focus();
}
private closeMenu() {
if (!this.button || !this.menu) return;
this.isOpen = false;
this.focusedIndex = -1;
this.button.setAttribute('aria-expanded', 'false');
this.menu.setAttribute('hidden', '');
this.menu.setAttribute('inert', '');
// Clear type-ahead state
this.typeAheadBuffer = '';
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
document.removeEventListener('pointerdown', this.handleClickOutside);
// Reset tabindex
this.updateTabIndices();
}
private toggleMenu() {
if (this.isOpen) {
this.closeMenu();
} else {
this.openMenu('first');
}
}
private updateTabIndices() {
const items = this.getItems();
const availableItems = this.getAvailableItems();
items.forEach((item) => {
if (item.getAttribute('aria-disabled') === 'true') {
item.tabIndex = -1;
return;
}
const availableIndex = availableItems.indexOf(item);
item.tabIndex = availableIndex === this.focusedIndex ? 0 : -1;
});
}
private focusItem(index: number) {
const availableItems = this.getAvailableItems();
if (index >= 0 && index < availableItems.length) {
this.focusedIndex = index;
this.updateTabIndices();
availableItems[index]?.focus();
}
}
private handleTypeAhead(char: string) {
const availableItems = this.getAvailableItems();
if (availableItems.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 = this.focusedIndex >= 0 ? (this.focusedIndex + 1) % availableItems.length : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = this.focusedIndex >= 0 ? (this.focusedIndex + 1) % availableItems.length : 0;
} else {
searchStr = buffer;
startIndex = this.focusedIndex >= 0 ? this.focusedIndex : 0;
}
for (let i = 0; i < availableItems.length; i++) {
const index = (startIndex + i) % availableItems.length;
const item = availableItems[index];
const label = item.textContent?.trim().toLowerCase() || '';
if (label.startsWith(searchStr)) {
this.focusItem(index);
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleButtonClick = () => {
this.toggleMenu();
};
private handleButtonKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.openMenu('first');
break;
case 'ArrowDown':
event.preventDefault();
this.openMenu('first');
break;
case 'ArrowUp':
event.preventDefault();
this.openMenu('last');
break;
}
};
private handleMenuClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
if (!item || item.getAttribute('aria-disabled') === 'true') return;
const itemId = item.dataset.itemId;
if (itemId) {
this.dispatchEvent(
new CustomEvent('itemselect', {
detail: { itemId },
bubbles: true,
})
);
}
this.closeMenu();
this.button?.focus();
};
private handleMenuFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement;
const item = target.closest('[role="menuitem"]') as HTMLLIElement | null;
if (!item || item.getAttribute('aria-disabled') === 'true') return;
const availableItems = this.getAvailableItems();
const index = availableItems.indexOf(item);
if (index >= 0 && index !== this.focusedIndex) {
this.focusedIndex = index;
this.updateTabIndices();
}
};
private handleMenuKeyDown = (event: KeyboardEvent) => {
const availableItems = this.getAvailableItems();
// Handle Escape even with no available items
if (event.key === 'Escape') {
event.preventDefault();
this.closeMenu();
this.button?.focus();
return;
}
if (availableItems.length === 0) return;
const target = event.target as HTMLElement;
const currentItem = target.closest('[role="menuitem"]') as HTMLLIElement | null;
const currentIndex = currentItem ? availableItems.indexOf(currentItem) : -1;
// If focus is on disabled item, only handle Escape (already handled above)
if (currentIndex < 0) return;
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = (currentIndex + 1) % availableItems.length;
this.focusItem(nextIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = currentIndex === 0 ? availableItems.length - 1 : currentIndex - 1;
this.focusItem(prevIndex);
break;
}
case 'Home': {
event.preventDefault();
this.focusItem(0);
break;
}
case 'End': {
event.preventDefault();
this.focusItem(availableItems.length - 1);
break;
}
case 'Tab': {
this.closeMenu();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
const item = availableItems[currentIndex];
const itemId = item?.dataset.itemId;
if (itemId) {
this.dispatchEvent(
new CustomEvent('itemselect', {
detail: { itemId },
bubbles: true,
})
);
}
this.closeMenu();
this.button?.focus();
break;
}
default: {
// Type-ahead: single printable character
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.handleTypeAhead(event.key);
}
}
}
};
private handleClickOutside = (event: PointerEvent) => {
if (this.container && !this.container.contains(event.target as Node)) {
this.closeMenu();
}
};
}
if (!customElements.get('apg-menu-button')) {
customElements.define('apg-menu-button', ApgMenuButton);
}
</script> Usage
Example
---
import MenuButton from './MenuButton.astro';
const items = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
{ id: 'delete', label: 'Delete', disabled: true },
];
---
<!-- Basic usage -->
<MenuButton items={items} label="Actions" />
<script>
// Handle selection via custom event
document.querySelectorAll('apg-menu-button').forEach((menuButton) => {
menuButton.addEventListener('itemselect', (e) => {
const event = e as CustomEvent<{ itemId: string }>;
console.log('Selected:', event.detail.itemId);
});
});
</script> API
MenuButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | MenuItem[] | required | Array of menu items |
label | string | required | Button label text |
defaultOpen | boolean | false | Whether menu is initially open |
class | string | '' | Additional CSS class for the container |
Custom Events
| Event | Detail | Description |
|---|---|---|
itemselect | { itemId: string } | Dispatched when a menu item is selected |
MenuItem Interface
Types
interface MenuItem {
id: string;
label: string;
disabled?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Mouse Interaction
| Test | Description |
|---|---|
Button click | Opens menu on button click |
Toggle | Clicking button again closes menu |
Item click | Clicking menu item activates and closes menu |
Disabled item click | Clicking disabled item does nothing |
Click outside | Clicking outside menu closes it |
High Priority: APG Keyboard Interaction (Button)
| Test | Description |
|---|---|
Enter | Opens menu, focuses first enabled item |
Space | Opens menu, focuses first enabled item |
ArrowDown | Opens menu, focuses first enabled item |
ArrowUp | Opens menu, focuses last enabled item |
High Priority: APG Keyboard Interaction (Menu)
| Test | Description |
|---|---|
ArrowDown | Moves focus to next enabled item (wraps) |
ArrowUp | Moves focus to previous enabled item (wraps) |
Home | Moves focus to first enabled item |
End | Moves focus to last enabled item |
Escape | Closes menu, returns focus to button |
Tab | Closes menu, moves focus out |
Enter/Space | Activates item and closes menu |
Disabled skip | Skips disabled items during navigation |
High Priority: Type-Ahead Search
| Test | Description |
|---|---|
Single character | Focuses first item starting with typed character |
Multiple characters | Typed within 500ms form prefix search string |
Wrap around | Search wraps from end to beginning |
Buffer reset | Buffer resets after 500ms of inactivity |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
aria-haspopup | Button has aria-haspopup="menu" |
aria-expanded | Button reflects open state (true/false) |
aria-controls | Button references menu ID |
role="menu" | Menu container has menu role |
role="menuitem" | Each item has menuitem role |
aria-labelledby | Menu references button for accessible name |
aria-disabled | Disabled items have aria-disabled="true" |
High Priority: Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabIndex=0 | Focused item has tabIndex=0 |
tabIndex=-1 | Non-focused items have tabIndex=-1 |
Initial focus | First enabled item receives focus when menu opens |
Focus return | Focus returns to button when menu closes |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
Resources
- WAI-ARIA APG: Menu Button Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist