Making Lists Keyboard-Navigable with Angular CDK

Accessible web applications are easier to use for everyone, especially those relying on assistive technologies. Yet, accessibility is often overlooked, partly because implementing it from scratch can feel time-consuming.

Fortunately, Angular developers have access to a robust toolkit. The @angular/cdk package includes several utilities that streamline accessibility work. Among these tools is a solution for a common interaction pattern: navigating lists with the keyboard.

This guide walks through the process of adding keyboard navigation to lists using Angular CDK. We'll cover the following ground:

  1. Standard keyboard navigation patterns for lists
  2. The ListKeyManager service from Angular CDK
  3. A practical example using FocusKeyManager

Keyboard Navigation Techniques

Components like menus, tables, and trees often share a hidden trait: at their core, they behave like lists. By mastering keyboard navigation for lists, you can apply the same logic to these more complex widgets.

Two main techniques dominate this space. Both allow users to move through items with the arrow keys and both work with screen readers to announce the current selection.

Roving Tabindex

The roving tabindex approach is arguably the most widespread. Its principle is simple: only the currently active item in the list is reachable via the tab key. Once the focus is on the list, the arrow keys take over to shift the focus between items.

This technique is highly compatible with assistive technology because it literally moves the browser focus to the new item. As a result, screen readers automatically announce the element that just received focus.

Here's the implementation logic:

  • Give every item in the list tabindex="-1" so they are skipped during tab navigation.
  • Assign tabindex="0" to the currently selected item so it is the entry point for keyboard navigation.
  • Listen for arrow key events. When an arrow key is pressed, set tabindex="-1" on the currently active item, set tabindex="0" on the next item in the chosen direction, and call its focus method.

Consider this example:

<ul>
  // selected item
  <li tabindex="0">Apples</li>
  <li tabindex="-1">Bananas</li>
  <li tabindex="-1">Cherries</li>
  <li tabindex="-1">Pineapple</li>
</ul>

The initial list has the first item focused. When the user presses the down arrow, the state changes like this:

<ul>
  <li tabindex="-1">Apples</li>
  // selected item
  <li tabindex="0">Bananas</li>
  <li tabindex="-1">Cherries</li>
  <li tabindex="-1">Pineapple</li>
</ul>

The tabindex="0" attribute moves to the next element, and the focus follows. With the focus now on Bananas, a screen reader will announce that item to the user.

aria-activedescendant

The second technique relies on the aria-activedescendant attribute. In this model, the list container itself holds the focus. This attribute points to the id of the active item, and the assistive technology reads the content of that referenced element.

You still need to handle arrow key events to update the selection, but instead of moving the browser focus, you only update this single attribute on the container.

The following example demonstrates this:

<ul aria-activedescendant="cherries" tabindex="0">
  <li id="apples">Apples</li>
  <li id="bananas">Bananas</li>
  // selected item
  <li id="cherries">Cherries</li>
  <li id="pineapple">Pineapple</li>
</ul>

After the down arrow is pressed, the list's state looks like this:

<ul aria-activedescendant="pineapple" tabindex="0">
  <li id="apples">Apples</li>
  <li id="bananas">Bananas</li>
  <li id="cherries">Cherries</li>
  // selected item
  <li id="pineapple">Pineapple</li>
</ul>

Notice that the aria-activedescendant on the ul element has been changed to the id of the new item, pineapple. Because the container has focus, the screen reader announces this new value.

Both of these patterns are effective. Implementing them from scratch repeatedly, however, introduces unnecessary complexity. This is precisely where the Angular CDK's ListKeyManager proves its value.

Understanding ListKeyManager

The ListKeyManager class acts as a specialized event handler. It takes a list of items and delegates the appropriate methods to them based on the key events it receives, making keyboard navigation a straightforward process.

Typical integration involves three steps:

  • Use a @ViewChildren query to get all the managed option components.
  • Create a ListKeyManager instance by passing that list of options.
  • Forward keyboard events from your main component to the ListKeyManager.

At its core, each list item must conform to an interface:

interface ListKeyManagerOption { 
  disabled?: boolean;
  getLabel?(): string;
 }

The two navigation techniques we discussed correspond to two specialized types of ListKeyManager: ActiveDescendantKeyManager and FocusKeyManager.

ActiveDescendantKeyManager

This manager is tailored for the aria-activedescendant pattern. Items managed by it need to implement a specific interface to let the manager update the attribute:

interface Highlightable extends ListKeyManagerOption {
  setActiveStyles(): void;
  setInactiveStyles(): void;
}

FocusKeyManager

Use this manager when the items themselves should receive browser focus. In this scenario, each item must implement the FocusableOption interface.

interface FocusableOption extends ListKeyManagerOption {
  focus(): void;
}

Building a List with FocusKeyManager

Doing A11y easily with Angular CDK. Keyboard-Navigable Lists — figure 1

Now that we understand the theory, let's put it into practice. While these techniques apply to menus and trees, we'll create a simple list component to illustrate the implementation, specifically using FocusKeyManager.

Our component will be split into two parts: the list container and the individual list item. The public API is defined below.

<my-list>
  <my-list-item>Apples</my-list-item>
  <my-list-item>Bananas</my-list-item>
  <my-list-item>Cherries</my-list-item>
</my-list>

Let's start building the child component.

import { FocusableOption } from '@angular/cdk/a11y';

@Component({
  selector: 'my-list-item',
  host: {
    tabindex: '-1',
    role: 'list-item',
  },
  template: '{{ fruit }}',
})
export class ListItemComponent implements FocusableOption {
  @Input() fruit: string;
  disabled: boolean;

  constructor(private element: ElementRef) {
  }

  getLabel(): string {
    return this.fruit;
  }

  focus() {
    this.element.nativeElement.focus();
  }
}

This is our ListItemComponent. It implements FocusableOption, which is the contract required by FocusKeyManager. The manager will invoke the focus method on this component to set the focus to the correct item.

The tabindex="-1" attribute is applied directly to the element. This ensures that the list items are bypassed when the user is tabbing through the page, a key part of the roving tabindex technique.

With the item ready, we can now build the main list component.

@Component({
  selector: 'my-list',
  host: { role: 'list' },
  template: '<ng-content></ng-content>',
})
export class ListComponent implements AfterContentInit {

  // 1. Query all child elements
  @ContentChildren(ListItem) items: QueryList<ListItem>;
  
  // FocusKeyManager instance
  private keyManager: FocusKeyManager<ListItem>;
  
  ngAfterContentInit() {
  
    // 2. Instantiate FocusKeyManager
    this.keyManager = new FocusKeyManager(this.items)
    
      // 3. Enabling wrapping
      .withWrap();
  }
}
  1. The ContentChildren decorator fetches all the projected ListItemComponent instances that are placed inside the list.
  2. We instantiate the FocusKeyManager, passing it the collection of ListItemComponent references.
  3. Wrapping is enabled here. This means navigating past the last item in the list will cycle the focus back to the first item, and vice versa.

There is one final step. We need to connect the component's keyboard events to the manager.

export class ListComponent implements AfterContentInit {
  @HostListener('keydown', ['$event'])
  onKeydown(event) {
    this.keyManager.onKeydown(event);
  }
}

With that, the implementation is complete. The result is a fully functional, keyboard-navigable list.

Keyboard-navigable Fruits list

Wrapping Up

We have successfully implemented accessible keyboard navigation for a list component using ListKeyManager. This approach not only handles the event logic cleanly but also ensures compatibility with assistive technologies out of the box.

Armed with these techniques, you are well-equipped to create accessible menus, trees, and other rich widgets with a fraction of the effort.

Follow along for more updates, and feel free to share any CDK topics you'd like covered next.

References & Further Reading

Explore these guides to deepen your understanding of keyboard navigation:

Get the full details on the ListKeyManager API: