Dropdown Menu
Build a fully-featured dropdown menu component with keyboard navigation and accessibility
What We're Building
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
Accessibility Features
Enhancements
1. Add Icons to Menu Items
2. Add Submenus
3. Add Search/Filter
4. Add Positioning Intelligence
Best Practices
Summary
Last updated
Was this helpful?