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.svelte
<script lang="ts">
import { onDestroy, tick } from 'svelte';
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
}
interface MenuButtonProps {
items: MenuItem[];
label: string;
defaultOpen?: boolean;
onItemSelect?: (itemId: string) => void;
class?: string;
}
let {
items = [],
label,
defaultOpen = false,
onItemSelect = () => {},
class: className = '',
...restProps
}: MenuButtonProps = $props();
// State - capture defaultOpen value to avoid reactivity warning
const initialOpen = defaultOpen;
let isOpen = $state(initialOpen);
let focusedIndex = $state(-1);
// Generate ID immediately for SSR-safe aria-controls/aria-labelledby
const instanceId = `menu-button-${Math.random().toString(36).slice(2, 11)}`;
let typeAheadBuffer = $state('');
let typeAheadTimeoutId: number | null = null;
const typeAheadTimeout = 500;
// Refs
let containerElement: HTMLDivElement;
let buttonElement: HTMLButtonElement;
let menuItemRefs = new Map<string, HTMLLIElement>();
// Derived
let availableItems = $derived(items.filter((item) => !item.disabled));
// Map of item id to index in availableItems for O(1) lookup
let availableIndexMap = $derived.by(() => {
const map = new Map<string, number>();
availableItems.forEach(({ id }, index) => map.set(id, index));
return map;
});
let buttonId = $derived(`${instanceId}-button`);
let menuId = $derived(`${instanceId}-menu`);
onDestroy(() => {
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
}
if (typeof document !== 'undefined') {
document.removeEventListener('mousedown', handleClickOutside);
}
});
// Focus effect - explicitly track dependencies with stale check
$effect(() => {
const open = isOpen;
const index = focusedIndex;
const items = availableItems;
if (open && index >= 0) {
const targetItem = items[index];
if (targetItem) {
const targetId = targetItem.id;
tick().then(() => {
// Guard against stale focus: check menu is still open
if (isOpen && focusedIndex >= 0) {
menuItemRefs.get(targetId)?.focus();
}
});
}
}
});
// Click outside effect (browser-only)
$effect(() => {
if (typeof document === 'undefined') return;
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
});
// Action to track menu item refs
function trackItemRef(node: HTMLLIElement, itemId: string) {
menuItemRefs.set(itemId, node);
return {
destroy() {
menuItemRefs.delete(itemId);
},
};
}
function getTabIndex(item: MenuItem): number {
if (item.disabled) return -1;
const availableIndex = availableIndexMap.get(item.id) ?? -1;
return availableIndex === focusedIndex ? 0 : -1;
}
function closeMenu() {
isOpen = false;
focusedIndex = -1;
// Clear type-ahead state
typeAheadBuffer = '';
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
typeAheadTimeoutId = null;
}
}
function openMenu(focusPosition: 'first' | 'last') {
if (availableItems.length === 0) {
isOpen = true;
return;
}
isOpen = true;
const targetIndex = focusPosition === 'first' ? 0 : availableItems.length - 1;
focusedIndex = targetIndex;
}
function toggleMenu() {
if (isOpen) {
closeMenu();
} else {
openMenu('first');
}
}
function handleClickOutside(event: MouseEvent) {
if (containerElement && !containerElement.contains(event.target as Node)) {
closeMenu();
}
}
async function handleItemClick(item: MenuItem) {
if (item.disabled) return;
onItemSelect(item.id);
closeMenu();
await tick();
buttonElement?.focus();
}
function handleItemFocus(item: MenuItem) {
if (item.disabled) return;
const availableIndex = availableIndexMap.get(item.id) ?? -1;
if (availableIndex >= 0) {
focusedIndex = availableIndex;
}
}
function handleButtonKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
openMenu('first');
break;
case 'ArrowDown':
event.preventDefault();
openMenu('first');
break;
case 'ArrowUp':
event.preventDefault();
openMenu('last');
break;
}
}
function handleTypeAhead(char: string) {
if (availableItems.length === 0) return;
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
}
typeAheadBuffer += char.toLowerCase();
const buffer = typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let startIndex: number;
let searchStr: string;
if (isSameChar) {
typeAheadBuffer = buffer[0];
searchStr = buffer[0];
startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
} else if (buffer.length === 1) {
searchStr = buffer;
startIndex = focusedIndex >= 0 ? (focusedIndex + 1) % availableItems.length : 0;
} else {
searchStr = buffer;
startIndex = focusedIndex >= 0 ? focusedIndex : 0;
}
for (let i = 0; i < availableItems.length; i++) {
const index = (startIndex + i) % availableItems.length;
const option = availableItems[index];
if (option.label.toLowerCase().startsWith(searchStr)) {
focusedIndex = index;
break;
}
}
typeAheadTimeoutId = window.setTimeout(() => {
typeAheadBuffer = '';
typeAheadTimeoutId = null;
}, typeAheadTimeout);
}
async function handleMenuKeyDown(event: KeyboardEvent, item: MenuItem) {
const itemsLength = availableItems.length;
// Guard: no available items
if (itemsLength === 0) {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
await tick();
buttonElement?.focus();
}
return;
}
const currentIndex = availableIndexMap.get(item.id) ?? -1;
// Guard: disabled item received focus
if (currentIndex < 0) {
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
await tick();
buttonElement?.focus();
}
return;
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = (currentIndex + 1) % itemsLength;
focusedIndex = nextIndex;
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
focusedIndex = prevIndex;
break;
}
case 'Home': {
event.preventDefault();
focusedIndex = 0;
break;
}
case 'End': {
event.preventDefault();
focusedIndex = itemsLength - 1;
break;
}
case 'Escape': {
event.preventDefault();
closeMenu();
await tick();
buttonElement?.focus();
break;
}
case 'Tab': {
closeMenu();
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
if (!item.disabled) {
onItemSelect(item.id);
closeMenu();
await tick();
buttonElement?.focus();
}
break;
}
default: {
// Type-ahead: single printable character
const { key, ctrlKey, metaKey, altKey } = event;
if (key.length === 1 && !ctrlKey && !metaKey && !altKey) {
event.preventDefault();
handleTypeAhead(key);
}
}
}
}
</script>
<div bind:this={containerElement} class="apg-menu-button {className}">
<button
bind:this={buttonElement}
id={buttonId}
type="button"
class="apg-menu-button-trigger"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={menuId}
onclick={toggleMenu}
onkeydown={handleButtonKeyDown}
{...restProps}
>
{label}
</button>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<ul
id={menuId}
role="menu"
aria-labelledby={buttonId}
class="apg-menu-button-menu"
hidden={!isOpen ? true : undefined}
inert={!isOpen ? true : undefined}
>
{#each items as item}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<li
use:trackItemRef={item.id}
role="menuitem"
tabindex={getTabIndex(item)}
aria-disabled={item.disabled || undefined}
class="apg-menu-button-item"
onclick={() => handleItemClick(item)}
onkeydown={(e) => handleMenuKeyDown(e, item)}
onfocus={() => handleItemFocus(item)}
>
{item.label}
</li>
{/each}
</ul>
</div> Usage
Example
<script lang="ts">
import MenuButton from './MenuButton.svelte';
const items = [
{ id: 'cut', label: 'Cut' },
{ id: 'copy', label: 'Copy' },
{ id: 'paste', label: 'Paste' },
{ id: 'delete', label: 'Delete', disabled: true },
];
function handleItemSelect(itemId: string) {
console.log('Selected:', itemId);
}
</script>
<!-- Basic usage -->
<MenuButton {items} label="Actions" onItemSelect={handleItemSelect} />
<!-- With default open state -->
<MenuButton {items} label="Actions" defaultOpen onItemSelect={handleItemSelect} /> 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 |
onItemSelect | (id: string) => void | - | Callback when an item is selected |
class | string | '' | Additional CSS class for the container |
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