Monday, December 23, 2024

Implement Keyboard Navigation on Custom Angular Controls

One of the advantages to using Angular Material controls is that they come with accessibility baked in. As such, they include role and aria-* attributes, as well as support for standard keyboard navigation. Should you decide to build your own widgets, you will also have to consider accessibility, or else risk losing some users. Case in point, we recently transformed some HTML Elements into a custom Angular 11 component, which we then refactored into a multi-level drop-down. In both cases, our control did not include any accessibility features. Hence, there was no way for screen readers to identify the component as a menu or to navigate its items on laptops and desktop computers without a mouse.

In this tutorial, we’ll remedy those limitations by making our original single level drop-down fully accessible.

Toggling the Menu in Angular

Opening and closing the menu is the domain of the component that houses the dropdown-menu component, a.k.a., the parent. Meanwhile, the dropdown-menu component will handle keyboard navigation. As such, we can examine both separately.

In the app.component.html template, we’ll add:

  1. the #menuTrigger template variable
  2. a tabindex attribute, to make the menu trigger a tabstop
  3. a keydown event handler
<div class="menu-container"

  (click)="menuOpened = true" (mouseenter)="menuOpened = true" 

  (mouseleave)="menuOpened = false" (keydown)="onKeyDown($event)">

  <span #menuTrigger tabindex="0">Menu</span>

  <dropdown-menu [ngClass]="{ 'menu-opened': menuOpened }" class="dropdown-menu" 

    [menuItems]="investmentClasses" (itemSelected)="onItemSelected($event)">

  </dropdown-menu>

</div>

 

Why keydown for Angular Menu Events?

You may be wondering why I’m binding our key handler to keydown, as opposed to keypress or keyup? Of the three key events, I prefer keydown because, unlike the keypress event, the keydown event is fired for all keys, regardless of whether they produce a character value. Both the keydown and keyup events provide a code indicating which key is pressed, while keypress only indicates which character was entered. Since we’re not processing characters at all, keypress is not the right event for us. Keyup is considered by many developers, including myself, to be keydown’s simple-minded sibling. It always gets the message too late to do anything useful with it.

The onKeyDown() Event Handler

The three keys that we’ll listen for are Escape, Enter, and Tab. Here’s what we’ll do for each:

  • Escape: Close the menu and set the focus on the menu trigger.
  • Enter: Open the menu and set the focus on the first menu item.
  • Tab: Close the menu (if opened).

You can see the key handling in the onKeyDown() method of the app.component.ts file:

public onKeyDown(event: KeyboardEvent) {

  switch (event.key) {

    case 'Escape':

      this.menuOpened = false;

      this.menuTrigger.nativeElement.focus();

      break;

    case 'Enter':

        this.menuOpened = true;

        // make sure that the menu is open before setting focus

        setTimeout(() => this.multiLevelDropDown.setFocusOnFirstMenuItem(), 1);

        break;

    case 'Tab':

      if (this.menuOpened) {

        this.menuOpened = false;

      }

      break;

  }

}

 

Referencing the Menu Trigger and Drop-down Menu Component

Being a highly opinionated framework, Angular prescribes its own way to reference DOM elements using the @ViewChild property decorator. I’ve seen many instances of document.getElementById(), document.getElementsByClassName(), etc., in Angular applications. This is an anti-pattern because it breaks Angular’s component-based architecture. Here’s the correct way to reference the #menuTrigger and DropdownMenuComponent:

@ViewChild('menuTrigger', { read: ElementRef, static: true }) 

private menuTrigger : ElementRef;

  

@ViewChild(DropdownMenuComponent)

private dropDownMenu : DropdownMenuComponent;

 

The first @ViewChild refers to the SPAN element to which we added the #menuTrigger template variable. Since it’s just a regular HTML element, and not a proper Angular Component, we’ll read it as an ElementRef. The static: true Metadata Property tell Angular that it’s OK to resolve query results before change detection runs, as the element is not dynamic.

The second @ViewChild references our drop-down menu as a DropdownMenuComponent class so that we can access its public members. Whenever you only have one component in a template, you can omit the template variable and reference the component directly.

Setting the Focus On the First Menu Item

The DropdownMenuComponent is well positioned to place the focus on the first menu item since it has the menuItemsRef reference to all its menu item elements. The QueryList type provides the first and last properties to make accessing those elements easier:

public setFocusOnFirstMenuItem(): void {

  if (this.menuItemsRef != null && this.menuItemsRef.first != null) {

    this.menuItemsRef.first.nativeElement.focus();

  } 

}

 

We should also update the CSS so that the active menu item is highlighted:

a:hover,

a:focus {

  background-color: #a12f42;

}

 

Traversing Menu Items

We’ll use the up and down arrow keys to navigate between menu items. In the dropdown-menu.component.html template, we’ll add a tabindex of -1 in order to disable the Tab key as well as a keydown handler, similar to the one in the AppComponent:

<a tabindex="-1" #menuItems *ngFor="let mi of menuItems;let i = index" 

(click)="onClick($event, i)" (keydown)="onKeyDown($event, i)">

  <mat-icon mat-list-icon>{{ mi.icon }}</mat-icon>

  <span class="dropdown-menu-item">{{ mi.value }}</span>

</a>

 

In the onKeyDown() handler, we can access menu elements by index and either add or subtract 1 to the current index to move the focus to the next item. With a little extra effort, we can also allow menu navigation to loop back to the first or last item when there are no more items:

public onKeyDown(event: KeyboardEvent, index: number) {

  switch (event.key) {

    case "ArrowUp":

      event.stopPropagation();

      (index === 0

         ? this.menuItemsRef.last 

         : this.menuItemsRef.get(index - 1)

      ).nativeElement.focus();

      break;

  

    case "ArrowDown":

      event.stopPropagation();

      (index === this.menuItemsRef.length - 1

        ? this.menuItemsRef.first 

        : this.menuItemsRef.get(index + 1)

      ).nativeElement.focus();

      break;

  }

}

The Demo

On codesandbox.io, you’ll find the demo with all of the code that was presented in this article.

Conclusion

Making your own Angular controls is a perfectly valid alternative to Material controls when you don’t want all of the bells and whistles that they provide or are looking for different behavior and/or appearance. The key is to provide the accessibility features that users have come to expect in modern Web apps.

Rob Gravelle
Rob Gravelle
Rob Gravelle resides in Ottawa, Canada, and has been an IT guru for over 20 years. In that time, Rob has built systems for intelligence-related organizations such as Canada Border Services and various commercial businesses. In his spare time, Rob has become an accomplished music artist with several CDs and digital releases to his credit.

Get the Free Newsletter!

Subscribe to Developer Insider for top news, trends & analysis

Popular Articles

Featured