How to create a custom dropdown using Angular CDK

Introduction

Angular CDK is a library from Google that provides a set of tools which helps to simplify the component building process in Angular. It provides utilities for creating floating panels, virtual scrolling, drag-n-drop, accessibility and more. If you are creating a reusable library in Angular then CDK will be of great help.

Creating a custom dropdown with the same behaviors of a native one is a little complex job. Though in outside dropdowns looks simple there is a lot of engineering goes with it, like positioning the dropdown, adjusting it's position and dimensions based on screen real-estate, keyboard accessibility etc. The Angular CDK library has dedicated modules to deal with creating overlays and accessibility. In this article I'm gonna show you how we can build a custom dropdown step by step with the help of CDK.

Setup

I'm gonna use Angular CLI to create the new project. Run the below command from the terminal.

ng new angular-dropdown --minimal=true

Listing 1. Creating new angular project

Please type "No" for routing and choose "Sass" for styles. Open the project is created run npm start and launch http://localhost:4200 in browser to make sure the app is running. Create a new folder called "custom-dropdown" under "src/app". This is the place where we gonna drop all our files related to custom dropdown. I'm not so familiar with CLI so I'll be manually creating the component files and others over the CLI commands. Before creating the files let's plan what components we need to build.

Planning

Below is how our custom dropdown looks like,

Fig 1. Custom Dropdown

Like the native dropdown, it has got an input control and on clicking that it opens a floating panel that is tied with the control. The floating panel contains an array of options and you can select them by mouse or keyboard.

We can decompose the UI into basically three parts: custom-select (wrapper), custom-dropdown (floating panel) and custom-select-option (option).

This is how someone can use our custom dropdown.

<custom-select label="Destination" [(ngModel)]="selectedDestination">
  <custom-select-option key="1" value="Paris"></custom-select-option>
  <custom-select-option key="2" value="Mauritius"></custom-select-option>
  <custom-select-option key="3" value="Singapore"></custom-select-option>
</custom-select>

Listing 2. Custom dropdown HTML

Creating the wrapper component

Let's create the wrapper component. Create a new file with name "custom-select.component.ts" under the "custom-dropdown" folder. Paste the below code.

import { Component } from '@angular/core';

@Component({
	selector: 'custom-select',
  templateUrl: './custom-select.html',
  styleUrls: ['./_custom-select.scss']
})
export class CustomSelectComponent {
}

Listing 3. custom-select.component.ts

Create the template file "custom-select.html" and the style "_custom-select.scss" in the same folder.

Open the "app.module.ts" file and add the CustomSelectComponent to the declarations array.

import { CustomSelectComponent } from './custom-dropdown/custom-select.component';

@NgModule({
   declarations:[CustomSelectComponent]
})
export class AppModule { }

Listing 4. app.module.ts

Our select component needs some inputs. It needs label, placeholder, the selected option key, required (boolean) and disabled (boolean).

export class CustomSelectComponent {

  @Input()
  public label: string;

  @Input()
  public placeholder: string;

  @Input()
  public selected: string;

  @Input()
  public required = false;

  @Input()
  public disabled = false;
} 

Listing 5. custom-select.component.ts

We are gonna use the input native element to build our wrapper. Go to the template file and drop the below HTML snippet. It contains a label, input element, icon and wrapper divs.

<div class="input-wrapper">
  <div class="input" [class.required]="required" #dropreference>
    <label class="label-text">{{label}}</label>
    <input #input
           placeholder={{placeholder}}
           [disabled]="disabled"
           readonly
           autocomplete="off">
    <span class="dropdown-arrow">🔽</span>
  </div>
</div>

Listing 6. custom-select.html

Let's add some styles. Open the "_custom-select.scss" file and paste the below styles.

.input-wrapper {
  position: relative;
  min-width: 5.625rem;
  padding: .5rem 0 1.5rem;

  .input {
    position: relative;
    margin: 0;
    outline: 0;

    input {
      width: 100%;
      height: 1.5rem;
      line-height: 1.3125rem;
      border: none;
      border-bottom: 1px solid #a8a8a8;
      text-overflow: ellipsis;
      white-space: normal;
      overflow: hidden;
      color: #323232;
      margin-top: 4px;
      padding-bottom: 2px;
      outline: 0;
      cursor: pointer;
      font-size: inherit;

      &:focus {
        border-bottom: 2px solid #0079cc;
      }

      &:disabled {
        cursor: default;
        color: #93a1aa;
        border-bottom: solid 1px #93a1aa;
      }

      &:disabled + .dropdown-arrow {
        color: #93a1aa;
      }
    }

    .dropdown-arrow {
      position: absolute;
      color: inherit;
      right: 0;
      bottom: 0;
      cursor: pointer;
    }

    &.required label:after {
      color: red;
      content: "*";
      position: relative;
      left: 5px;
      bottom: 5px;
    }
  }
}

.dropdown-options-container {
  width: 100%;
  border-radius: 3px;
  box-shadow: 0 1px 4px 0 rgba(0,0,0,.24);
  height: 100%;
  display: block;
  overflow: auto;
}

Listing 7. _custom-select.scss

Let's quickly see how our component looks. Create two files "app.component.html" and "_app.scss" under the "app" folder. Open the "app.component.ts" and replace the template with templateUrl and style with styleUrls as shown below.

import { Component } from '@angular/core';

@Component({
  selector: 'custom-root',
  templateUrl: './app.component.html',
  styleUrls: ['./_app.scss']
})
export class AppComponent {
}

Listing 8. app.component.ts

Drop the below code to the "app.component.html" file. We've used the custom-select component and passed some inputs to it.

<custom-select [required]="true"
  label="Destination"
  placeholder="Pick Your Holiday Destination">
</custom-select>

Listing 9. app.component.html

Add the below code to "_app.scss" file.

custom-select {
  display: block;
  width: 500px;
}

Listing 10. _app.scss

After saving all these changes you should see the below screen.

Fig 2. Custom Dropdown Wrapper

Creating the dropdown

This is our critical component that shows up on clicking the input field and displays the options. We are gonna use Angular CDK's overlay module to build this. Please run the below command to install Angular CDK.

npm install @angular/cdk --save

Listing 11. Installing CDK

Import both the PortalModule and OverlayModule from CDK in "app.module.ts" and import the CDK stylesheet in "styles.scss" file.

@import '~@angular/cdk/overlay-prebuilt.css';

Listing 12. styles.scss

import { OverlayModule } from '@angular/cdk/overlay';
import { PortalModule } from '@angular/cdk/portal';

@NgModule({
  imports: [
    PortalModule,
    OverlayModule,
    ...
  ]
})
class AppModule {
}

Listing 13. app.module.ts

Create a new file with name "dropdown.component.ts" under the "custom-dropdown" folder. Drop the below code, explanation follows.

import { TemplatePortalDirective } from '@angular/cdk/portal';
import { Component, Input } from '@angular/core';

@Component({
	selector: 'custom-dropdown',
	 template: `


      `
})
export class DropdownComponent {

  @Input()
  public reference: HTMLElement;

  @ViewChild(TemplatePortalDirective)
  public contentTemplate: TemplatePortalDirective;
}

Listing 14. dropdown.component.ts

Angular CDK provides different ways to create overlays (floating panels). We can create an overlay from a component or from a template. We've used the later approach. Our dropdown component accepts any content and put that inside a template. The template is rendered as a floating panel using CDK. We defined couple of properties in our component. An input property that takes the reference of a HTML element that our dropdown should be connected with and a view child to get the reference of our template.

CDK provides a singleton service called Overlay that helps to create floating panels from template or component easier. We can acquire the service using constructor dependency injection. Add the below constructor to the dropdown component.

import { Overlay } from '@angular/cdk/overlay';

...

constructor(protected overlay: Overlay) {
}

Listing 15. dropdown.component.ts

Next, create methods to show and hide the overlay.

protected overlayRef: OverlayRef;

public showing = false;

public show() {
    this.overlayRef = this.overlay.create(this.getOverlayConfig());
    this.overlayRef.attach(this.contentTemplate);
    this.syncWidth();
    this.overlayRef.backdropClick().subscribe(() => this.hide());
    this.showing = true;
 }

public hide() {
   this.overlayRef.detach();
   this.showing = false;
}

@HostListener('window:resize')
public onWinResize() {
  this.syncWidth();
}

private syncWidth() {
  if (!this.overlayRef) {
    return;
  }

  const refRect = this.reference.getBoundingClientRect();
  this.overlayRef.updateSize({ width: refRect.width });
}

Listing 16. dropdown.component.ts

If you see the code we needed only two lines to create overlay. It's that simple with CDK.

this.overlayRef = this.overlay.create(this.getOverlayConfig());
this.overlayRef.attach(this.contentTemplate);

Listing 17. Creating Overlay with CDK

The Overlay service returns an instance of type OverlayRef which is a reference to the created overlay. We need this reference to detach the template on closing and also to subscribe to backdrop click event to close the overlay.

On creating an overlay we need to pass the configuration. In the configuration we pass the position strategy, scrolling strategy and other information. Let's see how the getOverlayConfig method looks,

protected getOverlayConfig(): OverlayConfig {
    const positionStrategy = this.overlay.position()
      .flexibleConnectedTo(this.reference)
      .withPush(false)
      .withPositions([{
        originX: 'start',
        originY: 'bottom',
        overlayX: 'start',
        overlayY: 'top'
      }, {
        originX: 'start',
        originY: 'top',
        overlayX: 'start',
        overlayY: 'bottom'
      }]);

    return new OverlayConfig({
      positionStrategy: positionStrategy,
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-transparent-backdrop'
    });
  }

Listing 18. dropdown.component.ts

We've used the connected position strategy which means the floating panel should always be connected the the passed HTML reference element. In case of dialogs we need to use a different strategy called Global Position Strategy. To keep the dropdown width same as the input field we've listened to the window resize event and updating the overlay width in the handler. Finally, don't forget to add the component to the declarations array of the app module.

Let's use our dropdown in the select component and see how it works. Open the "custom-select.html" file and add the custom-dropdown element with some dummy text. Also, add click handlers to the input and the icon elements to show the dropdown.

<div class="input-wrapper">
  <div class="input" [class.required]="required" #dropreference>
    <label class="label-text">{{label}}</label>
    <input #input
           placeholder={{placeholder}}
           [disabled]="disabled"
           readonly
           (click)="showDropdown()"
           autocomplete="off">
    <span class="dropdown-arrow" (click)="onDropMenuIconClick($event)">🔽</span>
    <custom-dropdown [reference]="dropreference" #dropdownComp>
      Dummy Text
    </custom-dropdown>
  </div>
</div>

Listing 19. custom-select.html

Open the "custom-select.component.ts" and implement the click handlers.

@ViewChild('input')
public input: ElementRef;

@ViewChild(DropdownComponent)
public dropdown: DropdownComponent;

public showDropdown() {
  this.dropdown.show();
}

public onDropMenuIconClick(event: UIEvent) {
  event.stopPropagation();
  setTimeout(() => {
    this.input.nativeElement.focus();
    this.input.nativeElement.click();
  }, 10);
}

Listing 20. custom-select.component.ts

Let's see how things looks in browser. You should see the below image on clicking the input field. On clicking outside, the dropdown should close. Well, our dropdown not looks good yet but it works! Let's go on and implement the option component.

Fig 3. Dropdown Overlay With Dummy Text

Creating Option Component

Create a new file with name "custom-select-option.component.ts".

@Component({
    selector: 'custom-select-option',
    template: '{{value}}',
    styleUrls: ['./_custom-select-option.scss']
})
export class CustomSelectOptionComponent {

  @Input()
  public key: string;

  @Input()
  public value: string;
}

Listing 21. custom-select-option.component.ts

The option component takes two inputs: key and value. The value is displayed in the template.

Create the style file "_custom-select-option.scss" and add the below styles.

:host {
  display: block;
  padding: 0 0.875rem;
  height: 2.5rem;
  line-height: 2.5rem;
  color: #333;
  background-color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  user-select: none;
  cursor: pointer;

  &.selected {
    color: #0079cc;
    background-color: #eee;
    font-weight: bold;

    &:hover, &.active {
      background-color: #eee;

      @media screen and (-ms-high-contrast: active) {
        background-color: #eee;
      }
    }
  }

  &:hover, &.active {
    outline: none;
    background-color: #eee;

    @media screen and (-ms-high-contrast: active) {
      background-color: #eee;
    }
  }

  &.disabled {
    color: #93a1aa;
    cursor: auto;

    &:hover, &:focus {
      outline: none;
      background-color: #fff;

      @media screen and (-ms-high-contrast: active) {
        background-color: #fff;
      }
    }
  }
}

Listing 22. _custom-select-option.scss

Add our CustomSelectOptionComponent to the declarations array in the app module.

Open the "custom-select.html" file to pass the projected options to the dropdown.

<custom-dropdown [reference]="dropreference" #dropdownComp>
  <div class="dropdown-options-container">
    <ng-content select="custom-select-option"></ng-content>
  </div>
</custom-dropdown>

Listing 23. custom-select.html

Update the "app.component.html" to pass some options.

<custom-select [required]="true"
  label="Destination"
  placeholder="Pick Your Holiday Destination">
  <custom-select-option key="1" value="Paris"></custom-select-option>
  <custom-select-option key="2" value="Mauritius"></custom-select-option>
  <custom-select-option key="3" value="Singapore"></custom-select-option>
  <custom-select-option key="4" value="Malaysia"></custom-select-option>
  <custom-select-option key="5" value="Goa"></custom-select-option>
  <custom-select-option key="6" value="Thailand"></custom-select-option>
</custom-select>

Listing 24. app.component.html

Let's quickly see how our dropdown looks. Not bad, huh?

Fig 4. Dropdown with Overlay

Establish communication between select and option components

There is no communication between the wrapper and the option components yet. When the user pass the selected option (key while defining the component we need to get the corresponding option label and display it. Next, whenever an option is clicked we need to update the selected property and display the selected option's text in the input field. Let's see how we can establish the communication between both the select and option components.

First make the necessary code changes to display the selected option label.

Open the "custom-select.component.ts" and add an options property to retrieve the projected options.

@ContentChildren(CustomSelectOptionComponent)
public options: QueryList<CustomSelectOptionComponent>

Listing 25. custom-select.component.ts

Implement the ngAfterViewInit hook to retrieve the selected option from the passed key and update the display text in the input field.

export class CustomSelectComponent implements AfterViewInit {

  ...

  @ContentChildren(CustomSelectOptionComponent)
  public options: QueryList<CustomSelectOptionComponent>

  public selectedOption: CustomSelectOptionComponent;

  public displayText: string;

  public ngAfterViewInit() {
   setTimeout(() => {
     this.selectedOption = this.options.toArray().find(option => option.key === this.selected);
     this.displayText = this.selectedOption ? this.selectedOption.value : '';
   });
  }
}

Listing 26. custom-select.component.ts

Create a new method called selectOption which will be called from the option component on clicking them. In selectOption method we update the selected property, store the reference of selected option component, update the display text etc etc.

public selectOption(option: CustomSelectOptionComponent) {
  this.selected = option.key;
  this.selectedOption = option;
  this.displayText = this.selectedOption ? this.selectedOption.value : '';
  this.hideDropdown();
  this.input.nativeElement.focus();
}

public hideDropdown() {
  this.dropdown.hide();
}

Listing 27. custom-select.component.ts

Last not least update the template to show the displayText.

<input #input
  placeholder={{placeholder}}
  [disabled]="disabled"
  readonly
  [value]="displayText"
  (click)="showDropdown()"
  autocomplete="off">

Listing 28. custom-select.html

Alright, we've completed the changes required for communication in the select component. Let's do the necessary plumbing work in the option component. In the option component we need to listen to the click handler and invoke the selectOption method. It's not easy to retrieve the wrapped select component in the option. When I tried to retrieve through DI I ended up facing circular dependency errors. One way to overcome it is through a service. If you guys know alternate ways please let me know.

Create a new service with name "custom-dropdown.service.ts". All it does is stores the select component which will be eventually retrieved by the option component for invoking the selectOption method.

import { Injectable } from '@angular/core';
import { CustomSelectComponent } from './custom-select.component';

@Injectable()
export class CustomDropdownService {

  private select: CustomSelectComponent;

  public register(select: CustomSelectComponent) {
    this.select = select;
  }

  public getSelect(): CustomSelectComponent {
    return this.select;
  }
}

Listing 29. custom-dropdown.service.ts

We want the service's life to be tied with the select component. To achieve that define it as a provider in the component.

@Component({
  selector: 'custom-select',
  templateUrl: './custom-select.html',
  styleUrls: ['./_custom-select.scss'],
  providers: [CustomDropdownService]
})
export class CustomSelectComponent implements AfterViewInit {
}

Listing 30. custom-select.component.ts

Next, retrieve the created service in the constructor to store itself so the option components can acquire it.

constructor(private dropdownService: CustomDropdownService) {
  this.dropdownService.register(this);
}

Listing 31. custom-select.component.ts

Now acquire the same service in the option component. Open the "custom-select-option.component.ts" file, retrieve the select component from the service and add handler to the host click event to call the selectOption method.

@HostBinding('class.selected')
public get selected(): boolean {
 return this.select.selectedOption === this;
}

private select: CustomSelectComponent;

constructor(private dropdownService: CustomDropdownService) {
 this.select = this.dropdownService.getSelect();
}

@HostListener('click', ['$event'])
public onClick(event: UIEvent) {
 event.preventDefault();
 event.stopPropagation();

 this.select.selectOption(this);
}

Listing 32. custom-select-option.component.ts

Well, we've done establishing the communication between select and option components. Let's see how it works.

Modify the "app.component.html" file to pass the selected destination to the custom-select component. Ideally we'll be setting the data using ngModel or formControl/formControlName when using within a form. We've to do a little more work to achieve that and we'll see later about that.

<custom-select [required]="true"
  label="Destination"
  placeholder="Pick Your Holiday Destination"
  selected="4">
  <custom-select-option key="1" value="Paris"></custom-select-option>
  <custom-select-option key="2" value="Mauritius"></custom-select-option>
  <custom-select-option key="3" value="Singapore"></custom-select-option>
  <custom-select-option key="4" value="Malaysia"></custom-select-option>
  <custom-select-option key="5" value="Goa"></custom-select-option>
  <custom-select-option key="6" value="Thailand"></custom-select-option>
</custom-select>

Listing 33. app.component.html

When you run the application you should see the input field displaying the destination name "Malaysia". Open the dropdown and select any option you should notice the input field is updated accordingly.

Cool! We've finished the major engineering work needed for a custom dropdown. To make it super useful there is still little more work to do. Like the native we need our dropdown to be keyboard accessible. For example, we should able to navigate the options by arrow keys and select the active option by pressing enter. In the following section we are gonna make that happen!

Making the dropdown accessible

Angular CDK provides a dedicated module called "a11y" for accessibility. They provide key managers that helps us to navigate a collection of items through keyboard. There are two types of key managers: FocusKeyManager and ActiveDescendantKeyManager. We are going to use the later one for the job. First, let's instantiate the key manager in the ngAfterViewInit method.

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

private keyManager: ActiveDescendantKeyManager<CustomSelectOptionComponent>

public ngAfterViewInit() {
  setTimeout(() => {
    ...
    this.keyManager = new ActiveDescendantKeyManager(this.options)
      .withHorizontalOrientation('ltr')
      .withVerticalOrientation()
      .withWrap();
  });
}

Listing 34. custom-select-option.component.ts

We've passed the list of option components whose navigation should be handled to the key manager. We've also invoked a chain of methods to allow navigation by both up/down and left/right arrow keys. The withWrap method tells the key manager to circulate the navigation.

Before processing further list out the things we need to do to improve our dropdown's accessibility.

  • The user should be able to open the dropdown (floating panel) by pressing enter or arrow keys.
  • When the dropdown opens either the selected item or the first item should be active.
  • User should be able to navigate the options by up, down, right and left arrow keys.
  • User should able to select an option by pressing enter key.
  • On pressing enter the dropdown should hide.
  • User should not able to tab out when the dropdown is opened.

To achieve most of the above items first we need to listen to the keydown event of the input field.

Update the template to add keydown handler.

<input #input
  placeholder={{placeholder}}
  [disabled]="disabled"
  readonly
  [value]="displayText"
  (click)="showDropdown()"
  (keydown)="onKeyDown($event)"
  autocomplete="off">

Listing 35. custom-select.html

Below is the implementation of the onKeyDown handler. The code is pretty self explanatory.

public onKeyDown(event: KeyboardEvent) {
 if (['Enter', ' ', 'ArrowDown', 'Down', 'ArrowUp', 'Up'].indexOf(event.key) > -1) {
   if (!this.dropdown.showing) {
     this.showDropdown();
     return;
   }

   if (!this.options.length) {
     event.preventDefault();
     return;
   }
 }

 if (event.key === 'Enter' || event.key === ' ') {
   this.selectedOption = this.keyManager.activeItem;
   this.displayText = this.selectedOption ? this.selectedOption.value : '';
   this.hideDropdown();
 } else if (event.key === 'Escape' || event.key === 'Esc') {
   this.dropdown.showing && this.hideDropdown();
 } else if (['ArrowUp', 'Up', 'ArrowDown', 'Down', 'ArrowRight', 'Right', 'ArrowLeft', 'Left']
   .indexOf(event.key) > -1) {
   this.keyManager.onKeydown(event);
 } else if (event.key === 'PageUp' || event.key === 'PageDown' || event.key === 'Tab') {
   this.dropdown.showing && event.preventDefault();
 }
}

Listing 36. custom-select.component.ts

Update the showDropdown method to activate the selected or the first option whenever the panel shows up.

public showDropdown() {
 this.dropdown.show();

 if (!this.options.length) {
   return;
 }

 this.selected ? this.keyManager.setActiveItem(this.selectedOption) : this.keyManager.setFirstItemActive();
}

Listing 37. custom-select.component.ts

Next update the selectOption method to set the selected option active by calling the key manager's setActiveItem method.

public selectOption(option: CustomSelectOptionComponent) {
  this.keyManager.setActiveItem(option);
  this.selected = option.key;
  this.selectedOption = option;
  this.displayText = this.selectedOption ? this.selectedOption.value : '';
  this.hideDropdown();
  this.input.nativeElement.focus();
}

Listing 38. custom-select.component.ts

We need to do a little work in the option component as well. We should implement an interface from CDK called Highlightable. This interface provides three methods: getLabel, setActiveStyles and setInactiveStyles In the getLabel method we should return the display text. The other two methods helps to set/remove custom style when the option is in active/inactive state.

export class CustomSelectOptionComponent implements Highlightable {

  ...

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

  public getLabel(): string {
    return this.value;
  }

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

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

Listing 39. custom-select-option.component.ts

That's all the work needed for accessibility. You could now able to select the option by navigating through keys and pressing enter key. Cool!

The last important piece that's pending is make our custom component work with the Angular Forms (template and reactive). By doing that we can easily use ngModel or reactive directives directly over our form component. Luckily Angular provides straight support to make custom form components work with Angular forms.

Making Custom Component Work with Angular Forms

The first thing you should do is set a provider to provide the component for NG_VALUE_ACCESSOR.

import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
 selector: 'custom-select',
 templateUrl: './custom-select.html',
 styleUrls: ['./_custom-select.scss'],
 providers: [
   {
     provide: NG_VALUE_ACCESSOR,
     useExisting: forwardRef(() => CustomSelectComponent),
     multi: true
   },
   CustomDropdownService
 ]
})

Listing 40. custom-select.component.ts

Second you should implement the ControlValueAccessor interface. This interface helps to connect our custom component with Angular by providing methods to read and write data to our component. The implementations of these methods are quite simple as you see below.

export class CustomSelectComponent implements AfterViewInit, ControlValueAccessor {

  public onChangeFn = (_: any) => {};

  public onTouchedFn = () => {};

  public registerOnChange(fn: any): void {
    this.onChangeFn = fn;
  }

  public registerOnTouched(fn: any): void {
    this.onTouchedFn = fn;
  }

  public setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  public writeValue(obj: any): void {
    this.selected = obj;
  }

  public onTouched() {
    this.onTouchedFn();
  }

  public onChange() {
    this.onChangeFn(this.selected);
  }
}

Listing 41. custom-select.component.ts

Whenever the selected option changes we need to call the OnChange method and whenever the user moves the focus out of input field we need to call the onTouched method. The selected option changes in two places: in clicking the enter key in the keydown event and in the selectOption method that's invoked from the option components. Let's update those places to call the onChange method to notify angular.

if (event.key === 'Enter' || event.key === ' ') {
  this.selectedOption = this.keyManager.activeItem;
  this.displayText = this.selectedOption ? this.selectedOption.value : '';
  this.hideDropdown();
  this.onChange();
}

public selectOption(option: CustomSelectOptionComponent) {
  ...
  this.onChange();
}

Listing 42. custom-select.component.ts

Finally update the template to call the onTouched method on blur event. This helps angular to mark the field dirty when the user moves the focus out.

<input #input
  placeholder={{placeholder}}
  [disabled]="disabled"
  readonly
  [value]="displayText"
  (click)="showDropdown()"
  (keydown)="onKeyDown($event)"
  (blur)="onTouched()"
  autocomplete="off">

Listing 43. custom-select.html

Yep, that's it folks! we've successfully built a custom dropdown using CDK. Now we can directly bind data to using ngModel or through reactive directives. Let's quickly see how we can use the ngModel over it.

Open the "app.component.ts" and add a property called selectedDestination to bind the data to the dropdown. Also, import the FormsModule to "app.module.ts".

export class AppComponent {

  public selectedDestination: string;
}

Listing 44. app.component.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { OverlayModule } from '@angular/cdk/overlay';
import { PortalModule } from '@angular/cdk/portal';

import { CustomSelectComponent } from './custom-dropdown/custom-select.component';
import { CustomSelectOptionComponent } from './custom-dropdown/custom-select-option.component';
import { DropdownComponent } from './custom-dropdown/dropdown.component';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent,
    CustomSelectComponent,
    CustomSelectOptionComponent,
    DropdownComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    OverlayModule,
    PortalModule
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Listing 45. app.module.ts

Update the "app.component.html" as below,

<custom-select [required]="true"
               label="Destination"
               placeholder="Pick Your Holiday Destination"
               [(ngModel)]="selectedDestination"
               #model="ngModel">
  <custom-select-option key="1" value="Paris"></custom-select-option>
  <custom-select-option key="2" value="Mauritius"></custom-select-option>
  <custom-select-option key="3" value="Singapore"></custom-select-option>
  <custom-select-option key="4" value="Malaysia"></custom-select-option>
  <custom-select-option key="5" value="Goa"></custom-select-option>
  <custom-select-option key="6" value="Thailand"></custom-select-option>
</custom-select>
<div *ngIf="model.invalid && (model.dirty || model.touched)" style="color: red">
  <div *ngIf="model.errors.required">
    Destintation is required.
  </div>
</div>

Listing 46. app.component.html

Don't miss to include the FormsModule in app.module.ts. If you quickly test in browser you should see how nicely the built-in angular validation works with our custom component and that proves our component is well integrated with angular forms.

Fig 5. Dropdown with Validation

I hope you learnt something valuable from this article. Please ask any questions or share your feedback in the below comments form. Thanks for reading!

Source Code   Demo

blog comments powered by Disqus