Use Angular CDK Overlay to Build a Custom, Accessible Select Dropdown Component, Part 3

This is part three of a series on using Angular CDK Overlay to build a custom, accessible select dropdown component.
In part one of this series, we discussed form components and created the structure of the select element. Then, in part two, we added mouse interaction to our select component and set it up to emit the value of the selected item. That’s basically everything you need for a functional select component, right? Except we’re missing one feature that people will expect from a standard select element: keyboard input.

Keyboard Input

For our select component to function as people expect it to we will need to handle keyboard interaction. This will allow users to speed through a form using their keyboard and improve accessibility.

Capture Input

First we need to capture the keyboard input by adding angular (keydown) event handlers to both the main bar and the list of options. Insert them like so:


<div
  #select
  role="listbox"
  tabindex="{{ this.disabled ? -1 : 0 }}"
  [ngClass]="mainSelectClasses()"
  (click)="showDropdown()"

  (keydown)="onKeyDown($event)"

  (blur)="onTouched()"
  [attr.id]="inputId"
  [attr.aria-label]="ariaLabel || null"
  [attr.aria-labelledby]="ariaLabelledby || null"
  [attr.aria-multiselectable]="false"
  [innerHTML]="displayText"
></div>

and


<div class="dropdown-options-container" (keydown)="onKeyDown($event)">
  <ng-content></ng-content>
</div>

Next, we need to write the onKeyDown() function that will take the key events and manipulate the select menu. We can use the same function to handle both an open or closed options menu without caring if the user is focused in the main bar or the select menu. The onKeyDown() function itself just differentiates between the open and closed states to try and reduce the size of the function:


public onKeyDown(event: KeyboardEvent): void {
    if (this.showing) {
        this.handleVisibleDropdown(event);
    } else {
        this.handleHiddenDropdown(event);
    }
}

Handling Key Events

Now we need to handle every key event. The clearest way is to use a switch statement that has a block for each option. The nice thing about switch statements is that it will match a case and then execute until it hits a break; statement. This lets us list every case in an easily readable way without using long if conditions.

Here are the cases for when the options menu is visible and the user hits a key. Note that we’re using an ActiveDescentantKeyManager from the @angular/cdk/a11y package to manage the active option in the list. This gives us a lot of nice accessibility functions. That means we won’t need to worry about things like how a screen reader or other assistive aid will indicate which option is selected. We’ll just implement the Highlightable interface in our option class and get that functionality.


import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
...
private keyManager!: ActiveDescendantKeyManager;
...
public ngAfterContentInit(): void {
  // Initialize the keyManager with your desired settings in the ngAfterContentInit
  // lifecycle hook, passing in the list of options
  this.keyManager = new ActiveDescendantKeyManager(this.options || [])
    .withHorizontalOrientation('ltr')
    .withVerticalOrientation()
    .withWrap();
}
...
private handleVisibleDropdown(event: KeyboardEvent): void {
    switch (event.key) {
        // Enter and space cause the currently-highlighted 
        // item to become the active item
        case 'Enter':
        case ' ':
            if (this.keyManager.activeItem) {
                this.selectOption(this.keyManager.activeItem);
            }
            break;
        case 'Escape':
            this.hide();
            break;
        case 'ArrowUp':
        case 'ArrowDown':
        case 'ArrowRight':
        case 'ArrowLeft':
            this.keyManager.onKeydown(event);
            this.keyManager.activeItem?.scrollIntoView();
            // This prevents the arrow keys from scrolling the
            // page while the drop-down is focused
            event.preventDefault(); 
            break;
        case 'Tab':
            this.keyManager.onKeydown(event);
            this.keyManager.activeItem?.scrollIntoView();
            break;
        case 'PageUp':
        case 'PageDown':
            // For page up/down we are just eating the default 
            // event to prevent the user from being in the select
            // menu but jump around the page 
            event.preventDefault();
            break;
        default:
            // For all keys besides the ones enumerated 
            // above we'll use a search function to
            // select the item that begins with the letters 
            // that the user is entering
            event.stopPropagation();
            const firstFound = this.getOptionStartingWith(event.key);
            if (firstFound) {
                firstFound.scrollIntoView();
                this.keyManager.setActiveItem(firstFound);
            }
    }
}

And here is the code for handling when the user focuses on the select bar and hits a key without the menu being open. We’re using the same general structure as before but with fewer possible outcomes:


private handleHiddenDropdown(event: KeyboardEvent): void {
    switch (event.key) {
        case 'Enter':
        case ' ':
        case 'ArrowDown':
        case 'ArrowUp':
            this.showDropdown();
            if (this.selectedOption) {
                this.selectedOption.scrollIntoView();
            }
            break;
        default:
            event.stopPropagation();
            const firstFound = this.getOptionStartingWith(event.key);
            if (firstFound) {
                this.selectOption(firstFound);
            }
    }
}

Implementation

Now that we’ve added a couple of new functions, we need to implement. We need a way to scroll the active option into view when the user is keying through options. We also need a way to find which option the user is specifying when they are hitting a letter or number. And, we need to make sure our option implements the Highlightable interface. First, let’s create our function for selecting an item that the user is selecting by hitting keys on the keyboard. We’ll have the select menu go to the first option starting with the key that has been hit (case insensitive) and then cycle through the options that start with that letter if the user keeps hitting the same key.

To facilitate this, we’ll need to keep track of the last key they hit and the number of times they’ve hit it in a row.


private lastKeyPressed: string = '';
private keyPressIndex: number = 0;
...
private getOptionStartingWith(key: string): CustomSelectOptionComponent {
  if (this.lastKeyPressed === key) {
    this.keyPressIndex++;
  } else {
    this.keyPressIndex = 0;
  }
  this.lastKeyPressed = key;
  let optionsStartingWithKey = this.options.filter((option) => {
    return (
      !option.disabled &&
      option
        .getOptionElement()
        .textContent.trim()
        .toLocaleLowerCase()
        .startsWith(key.toLocaleLowerCase())
    );
  });
  return optionsStartingWithKey[
    this.keyPressIndex % optionsStartingWithKey.length
  ];
}

As you can see, this code goes through the options and creates a list of all the non-disabled options that begin with the key that was hit. Then it uses that list to iterate over if the same key is hit in succession. It loops back to the top of that subset if it reaches the end.

Now let’s go to the custom option component code and add the functions we need there. First, we need to change our class signature to implement Highlightable:


import { Highlightable } from '@angular/cdk/a11y';
...
export class CustomSelectOptionComponent implements Highlightable {

We’ll need to implement the two functions setActiveStyles and setInactiveStyles. This is pretty simple, and we’ll just use them to switch on and off an active class on our option:


@HostBinding('class.active')
public active = false;

public setActiveStyles(): void {
  this.active = true;
}

public setInactiveStyles(): void {
  this.active = false;
}

Make sure in your option CSS that you have the .active and :focus styles set to the same properties so that a mouse moving over the options and keystrokes moving through them look the same.

Now we’ll make it so the element the user selects with the keyboard is visible even if the menu is long and has a scroll bar. This sounds difficult but is actually quite simple. It’s just one line of code:


public scrollIntoView(): void {
    this.option.nativeElement.scrollIntoView({ block: 'center' });
  }
}

Now you should be able to navigate your select element with the keyboard! Try hitting different keys to cycle through options and then selecting an option with space or enter.

Write Value

One last thing! To make this component work in angular forms we need to implement a writeValue function. This lets a form set the value of the select element and is useful when someone comes back to a form they have already filled out. This is pretty simple but there is a small bug in angular forms we need to work around where it will try to set the value too early and we will end up overwriting it. Beyond handling the bug all we need to do is update the selected value to the given input and emit a change.


private rewriteValue: any = undefined;
...
public ngAfterContentInit(): void {
  // Reselect the written value if we need to after the form is loaded
  if (this.rewriteValue !== undefined) {
    this.writeValue(this.rewriteValue);
  }
  // Note: this is just what we added above
  this.keyManager = new ActiveDescendantKeyManager(this.options || [])
    .withHorizontalOrientation('ltr')
    .withVerticalOrientation()
    .withWrap();
}

public writeValue(obj: any): void {
  // writeValue is called before the content is fully initialized
  // Store that value and try again after content init
  // See bug: https://github.com/angular/angular/issues/29218
  if (this.options === undefined) {
    this.rewriteValue = obj;
    return;
  }
  this.selectedOption = this.options.find(x => x.value === obj)
  this.updateDisplayText();
  this.change.emit({
    source: this,
    selected: this.selectedOption,
  });
}

And there you have it, you can now have the value of the select elements preset in an angular form.


Again, here is a demo of everything so far on stackblitz.

In a future series, we’ll take a look at extending this element to add multi-select functionality and the ability to search in the list.

Conversation
  • Paul says:

    This is very nice, thanks for sharing!

    Do you still plan on implementing search and multi selection?

    Thanks again!

  • Join the conversation

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