Angular CDK Keyboard List Navigation and Selection | Task

Ole Ersoy
May - 24  -  4 min

Scenario

We have a Product item list and we want users to be able to navigate through it using the keyboard, perform typeahead searches, and select an active item.

Approach

Create a new Angular Stackblitz and add @angular/cdk to the dependencies.

For styling we will use Materialize and so the CDN link needs to go in index.html.

<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.97.8/css/materialize.min.css"
integrity="sha512-17AHGe9uFHHt+QaRYieK7bTdMMHBMi8PeWG99Mf/xEcfBLDCn0Gze8Xcx1KoSZxDnv+KnCC+os/vuQ7jrF/nkw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>

Product Component

Create src/components/product.component.ts.

Each Product component instance will be an option for the ActiveDescendantKeyManager and therefore it needs to implement the ListKeyManagerOption interface.

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

Since we are using the ActiveDescendantKeyManager it also needs to implement the interface Highlightable.

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

This is our implementation.

import { Component, HostBinding, Input } from '@angular/core';
import { Highlightable, ListKeyManagerOption } from '@angular/cdk/a11y';

@Component({
  selector: 'app-product',
  templateUrl: './product.component.html',
  styleUrls: ['./product.component.css'],
})
export class ProductComponent implements Highlightable, ListKeyManagerOption {
  @Input() product: string;
  @Input() disabled: boolean = false;
  private _isActive: boolean = false;

  @HostBinding('class.active') get isActive() {
    return this._isActive;
  }

  setActiveStyles() {
    this._isActive = true;
  }

  setInactiveStyles() {
    this._isActive = false;
  }

  getLabel() {
    return this.product;
  }
}

The application component will render our search field and the keyboard navigable list of products.

In order to initialize the ActiveDescendantKeyManager instance we need to do three things.

  • Create a @ViewChildren query for selecting the ProductComponent instances being used as options.
  • Initialize the ActiveDescendantKeyManager with the options.
  • Forward keyboard events from the application component to the ActiveDescendantKeyManager.

This is our app.component.ts implementation.

import { ActiveDescendantKeyManager } from '@angular/cdk/a11y/';
import { ENTER } from '@angular/cdk/keycodes';
import { Component, QueryList, ViewChildren } from '@angular/core';
import { ProductComponent } from './components/product.component';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  @ViewChildren(ProductComponent)
  products: QueryList<ProductComponent>;

  productArray = ['rubicks cube', 'dino bot', 'barbie', 'lego bus'];

  private keyManager: ActiveDescendantKeyManager<ProductComponent>;

  selection = '';

  ngAfterViewInit() {
    this.keyManager = new ActiveDescendantKeyManager(this.products)
      .withWrap()
      .withTypeAhead();
  }

  onKeyUp(event) {
    if (event.keyCode === ENTER) {
      this.selection = this.keyManager.activeItem.product;
    } else {
      this.keyManager.onKeydown(event);
    }
  }
}

Within ngAfterViewInit we initialize the ActiveDescendantKeyManager calling withWrap, which causes the list to wrap when the keyboard navigation gets tot the end, and withTypeAhead which allows us to select specific items in the list via the search field.

Our application component template binds the onKeyUp method which performs navigation by forwarding non ENTER events to the keyManager. ENTER events cause the selection field to be set with the current selection.

<div class="input-field">
  <input placeholder="  Search" (keyup)="onKeyUp($event)" #input />
</div>

<section style="display:flex; flex-direction:column" class="collection">
  <app-product
    class="collection-item"
    *ngFor="let product of productArray | filter: input.value; index as index"
    [product]="product"
  >
    {{ product }}
  </app-product>
</section>

<p style="margin-top: 1em" *ngIf="selection">Selected: {{ selection }}</p>

The filter pipe implementation is used to narrow the selection when the user types, however it is not necessary to select an item.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'filter',
})
export class FilterPipe implements PipeTransform {
  transform(products: any[], query: string): any[] {
    if (!products) return [];
    if (!query || query.length == 0) return products;
    return products.filter(
      (p) => p.toLowerCase().indexOf(query.toLowerCase()) != -1
    );
  }
}

Demo

Selecting the search field and try typing in the name of a product. Navigate using the arrow keys.