Dropdown Menu
Build a fully-featured dropdown menu component with keyboard navigation and accessibility
Learn to build a production-ready dropdown menu with keyboard navigation, accessibility, and click-outside detection. This component is perfect for navigation menus, context menus, and action lists.
What We're Building
A dropdown menu that supports:
Click to toggle open/close
Keyboard navigation (Arrow keys, Enter, Escape)
Click outside to close
Accessible with ARIA attributes
Customizable trigger and content
Positioning options
Component Code
dropdown-menu.ts
import { bindable, IEventAggregator } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { queueTask } from '@aurelia/runtime';
import { IPlatform } from '@aurelia/runtime-html';
export class DropdownMenu {
@bindable open = false;
@bindable position: 'left' | 'right' = 'left';
@bindable disabled = false;
private platform = resolve(IPlatform);
private element?: HTMLElement;
private triggerButton?: HTMLButtonElement;
private menuElement?: HTMLElement;
private clickOutsideHandler?: (e: MouseEvent) => void;
binding() {
this.setupClickOutsideHandler();
}
attaching(initiator: HTMLElement) {
this.element = initiator;
this.triggerButton = this.element.querySelector('[data-dropdown-trigger]') as HTMLButtonElement;
this.menuElement = this.element.querySelector('[data-dropdown-menu]') as HTMLElement;
}
detaching() {
this.removeClickOutsideListener();
}
toggle() {
if (this.disabled) return;
this.open = !this.open;
if (this.open) {
this.addClickOutsideListener();
this.focusFirstItem();
} else {
this.removeClickOutsideListener();
}
}
close() {
if (this.open) {
this.open = false;
this.removeClickOutsideListener();
this.triggerButton?.focus();
}
}
handleKeyDown(event: KeyboardEvent) {
if (this.disabled) return;
const { key } = event;
// Toggle on Enter or Space when trigger is focused
if ((key === 'Enter' || key === ' ') && document.activeElement === this.triggerButton) {
event.preventDefault();
this.toggle();
return;
}
// Close on Escape
if (key === 'Escape' && this.open) {
event.preventDefault();
this.close();
return;
}
// Arrow navigation when menu is open
if (this.open && (key === 'ArrowDown' || key === 'ArrowUp')) {
event.preventDefault();
this.navigateItems(key === 'ArrowDown' ? 1 : -1);
return;
}
// Activate item on Enter when focused
if (key === 'Enter' && this.open && document.activeElement?.hasAttribute('role')) {
event.preventDefault();
(document.activeElement as HTMLElement).click();
}
}
private navigateItems(direction: 1 | -1) {
if (!this.menuElement) return;
const items = Array.from(this.menuElement.querySelectorAll('[role="menuitem"]')) as HTMLElement[];
if (items.length === 0) return;
const currentIndex = items.findIndex(item => item === document.activeElement);
let nextIndex: number;
if (currentIndex === -1) {
// No item focused, focus first or last based on direction
nextIndex = direction === 1 ? 0 : items.length - 1;
} else {
// Move to next/previous item, wrapping around
nextIndex = (currentIndex + direction + items.length) % items.length;
}
items[nextIndex]?.focus();
}
private focusFirstItem() {
// Use tasksSettled to ensure DOM is updated
queueTask(() => {
const firstItem = this.menuElement?.querySelector('[role="menuitem"]') as HTMLElement;
firstItem?.focus();
});
}
private setupClickOutsideHandler() {
this.clickOutsideHandler = (event: MouseEvent) => {
const target = event.target as Node;
if (this.element && !this.element.contains(target)) {
this.close();
}
};
}
private addClickOutsideListener() {
if (this.clickOutsideHandler) {
// Use timeout to avoid immediate close from the same click that opened it
setTimeout(() => {
document.addEventListener('click', this.clickOutsideHandler!, true);
}, 0);
}
}
private removeClickOutsideListener() {
if (this.clickOutsideHandler) {
document.removeEventListener('click', this.clickOutsideHandler, true);
}
}
/**
* Call this when an item is selected to close the menu
*/
handleItemClick() {
this.close();
}
}dropdown-menu.html
dropdown-menu.css
Usage Examples
Basic Dropdown
With Custom Trigger
Programmatic Control
Disabled State
Testing
Test your dropdown component:
Accessibility Features
This dropdown implements WCAG 2.1 guidelines:
✅ Keyboard Navigation: Full keyboard support with arrow keys
✅ ARIA Attributes: Proper
role,aria-haspopup,aria-expanded,aria-hidden✅ Focus Management: Focuses first item when opened, returns focus to trigger when closed
✅ Escape to Close: Standard Escape key behavior
✅ Screen Reader Support: Announces menu state and items
Enhancements
1. Add Icons to Menu Items
2. Add Submenus
Nest another dropdown-menu inside:
3. Add Search/Filter
4. Add Positioning Intelligence
Use a library like Floating UI to automatically position the menu to avoid viewport overflow:
Best Practices
Always Clean Up: Remove event listeners in
detaching()to prevent memory leaksFocus Management: Return focus to trigger when closing for better UX
Debounce: For search/filter, debounce input to avoid excessive filtering
Accessibility: Test with keyboard only and screen readers
Portal Rendering: For complex layouts, render menu in a portal to avoid z-index issues
Summary
You've built a fully-featured dropdown menu with:
✅ Click and keyboard interactions
✅ Accessibility built-in
✅ Click-outside detection
✅ Customizable trigger and content
✅ Comprehensive tests
This dropdown is production-ready and can be extended with search, submenus, and intelligent positioning!
Last updated
Was this helpful?