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

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();
  }
}

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

  1. Always Clean Up: Remove event listeners in detaching() to prevent memory leaks

  2. Focus Management: Return focus to trigger when closing for better UX

  3. Debounce: For search/filter, debounce input to avoid excessive filtering

  4. Accessibility: Test with keyboard only and screen readers

  5. 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?