Mastering keyboard navigation in Angular using ListKeyManager

By: Ahsan Ayaz, from Modus Create

Programming is fun, especially when you love the technology you’re working on. We at Modus Create love the web and web technologies. One of the frameworks that we work with is Angular.

When you work with Angular on large scale apps, there comes a set of different challenges and problems that require diving deep into Angular. In this article, we’ll go through one such challenge: implementing keyboard navigation to a list component. An example use-case can be an autocomplete dropdown list which may have keyboard navigation.

To implement keyboard navigation entirely from scratch, we could develop a custom implementation, but that would take a bit of time and perhaps would be re-inventing the wheel – if something is already out there that does the job.

Angular Material has a CDK (Component Dev Kit) which provides a lot of cool components and services for creating custom components & services that we can ship as libraries or use in our applications. Angular Material itself is built on top of the Angular CDK. Angular Material’s `a11y` package provides us a number of services to improve accessibility. One of those services is the `ListKeyManager` service. Which we will be using to implement keyboard navigation into our app.

Let’s dive into the code:

dive in

First, if you don’t have `@angular/cdk` package installed in your app, do a quick `npm install @angular/cdk –save`.

We have a repo created here which you can clone/fork and work locally on our example app as we go along. We’ll keep things simple for now but there can be more complex use cases when using `ListKeyManager`. We’ll show you how we’ve implemented keyboard navigation in our demo app and you can implement this in a similar way.

Let’s go through what our demo app looks like. First off, we’re loading some random users from randomuser.me api. We load them in our `app.component.ts` and then we use our `ListItemComponent` to display each user individually. We also have a search input which will filter the users based on their names.

See the code below for `AppComponent`:

import { Component, OnInit } from "@angular/core";
import { UsersService } from "./core/services/users.service";
import { first } from "rxjs/operators";

@Component({
   selector: "app-root",
   templateUrl: "./app.component.html",
   styleUrls: ["./app.component.scss"]
})
export class AppComponent implements OnInit {
   users: any;
   isLoadingUsers: boolean;
   searchQuery: string;
   constructor(private usersService: UsersService) {}
   
   ngOnInit() {
      this.isLoadingUsers = true;
      this.usersService
      .getUsers()
      .pipe(first())
      .subscribe(users => {
         this.users = users;
         this.isLoadingUsers = false;
      });
   }
}

In our view (`app.component.html`), we have:

<div class="users-page">
   <div class="users-page__loading" *ngIf="isLoadingUsers">
      Loading Users
   </div>
   <div class="users-age__main" *ngIf="!isLoadingUsers">
      <h3 class="users-page__main__heading">
         Users List
      </h3>
      <div class="users-page__main__search">
         <input type="text" [(ngModel)]="searchQuery" placeholder="search users by name" (keyup)="handleKeyUp($event)">
      </div>
      <div class="users-page__main__list">
         <app-list-item
         *ngFor="let user of users | filterByName: searchQuery"
         class="users-page__main__list__item"
         [item]="user"
         (itemSelected)="showUserInfo($event)">
      </app-list-item>
   </div>
</div>
</div>

Notice that we’re looping over `users` using `*ngFor` and passing each user as an `item` in the `app-list-item` component. We’re also filtering the list using the `filterByName` pipe which uses the value from the input above.

The `app-list-item` component just displays the image, name and email of each user. Here is what the view code looks like:

<div class="item" [class.item--active]="isActive">
   <div class="item__img">
      <img [src]="item.picture.thumbnail">
   </div>
   <div class="item__content">
      <div class="item__content__name">{{item.name.first}} {{item.name.last}}</div>
      <div class="item__content__email">{{item.email}}</div>
   </div>
</div>

Notice that the `div` with the class `item` has a conditional class being applied i.e. `item–active`. This would make sure that the active item looks different from the rest since we’re applying different styles on this class. The class `item–active` would be applied when the `isActive` property of the item is `true`. We will use this later.

Moving forward, we’ll now include `ListKeyManager` from the `@angular/cdk/a11y` package in our `app.component.ts` as:

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

Then, we have to create a `KeyEventsManager` instance in our `app.component.ts` that we would use to subscribe to keyboard events. We will do it by creating a property in the AppComponent class as:

export class AppComponent implements OnInit {
   users: any;
   isLoadingUsers: boolean;
   keyboardEventsManager: ListKeyManager; // <- add this
   constructor(private usersService: UsersService) {
   }
   ...
}

We have declared the property `keyboardEventsManager` but haven’t initialized it with anything. To do that, we would have to pass a `QueryList` to `ListKeyManager` constructor as it expects a `QueryList` as an argument. The question is, what would this `QueryList` be? The `QueryList` should comprise of the elements on which the navigation would be applied. I.e. the `ListItem` components. So we will first use `@ViewChildren` to create a `QueryList` and access the `ListItem` components which are in `AppComponent`‘s view. Then we will pass that `QueryList` to the `ListKeyManager`. Our AppComponent should look like this now:

import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
import { UsersService } from "./core/services/users.service";
import { first } from "rxjs/operators";
import { ListKeyManager } from "@angular/cdk/a11y";
import { ListItemComponent } from "./core/components/list-item/list-item.component"; // importing so we can use with `@ViewChildren and QueryList
@Component({
   selector: "app-root",
   templateUrl: "./app.component.html",
   styleUrls: ["./app.component.scss"]
})
export class AppComponent implements OnInit {
   users: any;
   isLoadingUsers: boolean;
   keyboardEventsManager: ListKeyManager;
   @ViewChildren(ListItemComponent) listItems: QueryList; // accessing the ListItemComponent(s) here
   constructor(private usersService: UsersService) {}
   
   ngOnInit() {
      this.isLoadingUsers = true;
      this.usersService
      .getUsers()
      .pipe(first())
      .subscribe(users => {
         this.users = users;
         this.isLoadingUsers = false;
         this.keyboardEventsManager = new ListKeyManager(this.listItems); // initializing the event manager here
      });
   }

Now that we have created `keyboardEventsManager`, we can initiate the keyboard events handler using a method named `handleKeyUp` on the search input as we would press `Up` and `Down` arrow keys and navigate through the list observing the active item.

...
import { ListKeyManager } from '@angular/cdk/a11y';
import { ListItemComponent } from './core/components/list-item/list-item.component';
import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes';

...
export class AppComponent implements OnInit {
   ...
   keyboardEventsManager: ListKeyManager;
   searchQuery: string;
   @ViewChildren(ListItemComponent) listItems: QueryList;
   constructor(private usersService: UsersService) {
   }
   
   ...
   /**
   * @author Ahsan Ayaz
   * @desc Triggered when a key is pressed while the input is focused
   */
   handleKeyUp(event: KeyboardEvent) {
      event.stopImmediatePropagation();
      if (this.keyboardEventsManager) {
         if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) {
            // passing the event to key manager so we get a change fired
            this.keyboardEventsManager.onKeydown(event);
            return false;
         } else if (event.keyCode === ENTER) {
            // when we hit enter, the keyboardManager should call the selectItem method of the `ListItemComponent`
            this.keyboardEventsManager.activeItem.selectItem();
            return false;
         }
      }
   }
}

We will connect the `handleKeyUp` method to the search input in `app.component.html` on the `(keyup)` event as:

<input type="text" [(ngModel)]="searchQuery" placeholder="search users by name" (keyup)="handleKeyUp($event)">

If you debug the functions now, they would be triggered when up/down or enter key is pressed. But this doesn’t do anything right now. The reason is that as we discussed, the active item is distinguished when the `isActive` property inside the `ListItemComponent` is `true` and the `item–active` class is therefore applied. To do that, we will keep track of the active item in the `KeyboardEventsManager` by subscribing to `keyboardEventsManager.change`. We will get the active index of the current item in navigation each time the active item changes. We just have to set the `isActive` of our `ListItemComponent` to reflect those changes in view. To do that, we will create a method `initKeyManagerHandlers` and will call it right after we initialize the `keyboardEventsManager`.

Let’s see how our app looks like now:

Keyboard Nav using UP & DOWN arrow keys
Keyboard Nav using UP & DOWN arrow keys

BOOM💥! Our list now has keyboard navigation enabled and works with the `UP` and `DOWN` arrow keys. The only thing remaining is to show the selected item on `ENTER` key press.

Notice that in our `app.component.html`, the `app-list-item` has an `@Output` emitter as:

(itemSelected)="showUserInfo($event)"

This is how the ListItemComponent looks like:

import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core';

@Component({
   selector: 'app-list-item',
   templateUrl: './list-item.component.html',
   styleUrls: ['./list-item.component.scss']
})

export class ListItemComponent implements OnInit {
   @Input() item;
   @Output() itemSelected = new EventEmitter();
   isActive: boolean;
   constructor() { }
   
   ngOnInit() {
      this.isActive = false;
   }
   
   setActive(val) {
      this.isActive = val;
   }
   
   selectItem() {
      this.itemSelected.emit(this.item);
   }
}

If you recall, in our `handleKeyUp` method inside `AppComponent`, we execute the below statement on `ENTER` key press:

this.keyboardEventsManager.activeItem.selectItem();

The above statement is calling `ListItemComponent`‘s `selectItem` method which emits `itemSelected` to the parent component. The emitted event in the parent calls the `showUserInfo($event)` which finally alerts the message with the user name.

Let’s see how the completed app looks now:

Selecting active item using ENTER key
Selecting active item using ENTER key

Conclusion

Angular CDK provides a lot of tools and as we’re working on complex projects, we’re continuously finding out great ways to create intuitive experiences that are easy to write and maintain. If you’re interested in building your own component libraries like Angular Material, do dive into Angular CDK and paste in the comments whatever cool stuff you come up with.

Happy coding – check out out our Github repo for more.

Leave a Reply

Your email address will not be published. Required fields are marked *