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.