Create a custom select component with typeahead built on @angular/cdk in Angular

Native select element is used to create a drop-down list of options for users to choose from but does not support searching and typeahead. Because of that, there are situations where developers are forced to create their own select component. See how you can implement custom select with searching with typehead.

You can view the source code for this project by following this link: GitHub

Demo

Integration with forms

Select implements ControlValueAccessor what means you can bind formControlName or ngModel directive.

import { ControlValueAccessor } from "@angular/forms";

@Component({
  selector: "app-select",
  template: `
    <form [formGroup]="form">
      <app-select formControlName="githubProfile">
        <!-- ... -->
      </app-select>
    </form>
  `,
  /* */
})
export class SelectComponent implements ControlValueAccessor {
  /* */
}

Creating overlay

To open an overlay is used @angular/cdk/overlay package. Thanks to this library all logic to create and position is hidden under nice classes and methods.

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

@Component({
  selector: "app-select",
  /* */
})
export class SelectComponent /* */ {
  private overlay = inject(Overlay);
  private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);

  private createOverlay(): OverlayRef {
    const positionStrategy = this.overlay
      .position()
      .flexibleConnectedTo(this.elementRef)
      .withPositions([{ originX: "start", originY: "top", overlayX: "start", overlayY: "top" }]);

    return this.overlay.create({
      hasBackdrop: true,
      backdropClass: "",
      positionStrategy,
      minWidth: this.elementRef.nativeElement.clientWidth,
    });
  }
}

Adding a11y

Built Select has support for keyboard shortcuts. You can open an overlay with Enter, Arrow up or Arrow down. It has been done thanks to @angular/cdk/a11y library and ActiveDescendantKeyManager.

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

@Component({
  selector: "app-select",
  /* */
})
export class SelectComponent /* */ {
  private keyManager!: ActiveDescendantKeyManager<HightlightableOptionDirective>;

  ngAfterViewInit(): void {
    this.keyManager = new ActiveDescendantKeyManager(this.optionElsQuery).withHomeAndEnd().withWrap().withPageUpDown();
  }
}

To close an overlay you can press Escape.

Defining data source

Select require to implement data source. It is the only way to pass data to select. In addition to defining the data retrieval source is required to define a property to bind to control – a way to retrieve a representation of the selected value.

export abstract class SelectDataSource<T> {
  abstract getOptions(searchValue$: Observable<string>): Observable<T[]>;
  abstract getBindedValue(value: T): unknown;
}
@Component({
  selector: "app-select",
  /* ... */
})
export class SelectComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {
  private selectDataSource = inject(SelectDataSource);
  protected options$ = this.selectDataSource.getOptions(this.searchedValue$);

  /* ... */

  selectOption(option: unknown): void {
    const bindedValue = this.selectDataSource.getBindedValue(option);
    this.emitValue(bindedValue);
    /* ... */
  }

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

  /* ... */
}

Example data sources

Country data source

The example country data source as directive can be used anywhere in a application

@Directive({
  selector: "[countryDataSource]",
  standalone: true,
  providers: [
    {
      provide: SelectDataSource,
      useExisting: CountryDataSourceDirective,
    },
  ],
})
export class CountryDataSourceDirective implements SelectDataSource<Country> {
  private service = inject(CountryService);

  getOptions(searchValue$: Observable<string>) {
    return searchValue$.pipe(
      switchMap((searchValue) =>
        this.service.countries$.pipe(
          map((countries) => countries.filter((country) => country.name.includes(searchValue)).slice(0, 15)),
        ),
      ),
    );
  }

  getBindedValue(value: Country): unknown {
    return value.code;
  }
}

You just need to provide directive to the app-select component

<app-select countryDataSource formControlName="country">
  <!-- ... -->
</app-select>

Github repository data source

The example github repository data source as directive can be used anywhere in a application

@Directive({
  selector: "[repositoryDataSource]",
  standalone: true,
  providers: [
    {
      provide: SelectDataSource,
      useExisting: RepositoryDataSourceDirective,
    },
  ],
})
export class RepositoryDataSourceDirective implements SelectDataSource<RepositoryItem> {
  private service = inject(RepositoryService);

  getOptions(searchValue$: Observable<string>) {
    return searchValue$.pipe(
      debounceTime(500),
      switchMap((searchValue) => this.service.repositories({ searchValue })),
    );
  }

  getBindedValue(value: RepositoryItem): string {
    return value.name;
  }
}

You just need to provide directive to the app-select component

<app-select repositoryDataSource formControlName="githubProfile">
  <!-- ... -->
</app-select>

Option template

To define a view of an option you have to define a template inside app-select.

@Component({
  selector: "app-select",
  template: `
    <!-- Fragment of app-select template to display available options -->

    <li *ngFor="let option of options$ | async">
      <ng-template
        [ngTemplateOutlet]="optionTemplateDirective.template"
        [ngTemplateOutletContext]="{ $implicit: option }"
      ></ng-template>
    </li>

    <!-- ... -->
  `,
  /* ... */
})
export class SelectComponent /* */ {
  // Fragment of app-select logic to get provided template
  @ContentChild(OptionTemplateDirective, { static: true }) optionTemplateDirective!: OptionTemplateDirective;
  private selectDataSource = inject(SelectDataSource);
  protected options$ = this.selectDataSource.getOptions(this.searchedValue$);

  /* ... */
}
<!-- Example of option template -->

<app-select someDataSource>
  <ng-content *appOptionTemplate="let item">{{ item | json }}</ng-content>
</app-select>

To have types (ng-template does not provide types) you should create a component with appropriate input.

@Component({
  selector: "app-country-select-option",
  template: ` <!-- ... --> `,
  styles: [
    /* ... */
  ],
  standalone: true,
})
export class CountrySelectOptionComponent {
  @Input({ required: true }) data!: Country;
}
<app-select countryDataSource>
  <app-country-select-option *appOptionTemplate="let item" [data]="item" />
</app-select>

Separate template for option

In a scenario where you need to have a different template per option, you should put ngIf or ngSwitch into template.

<app-select someDataSource>
  <ng-container *appOptionTemplate="let item">
    <ng-container *ngIf="item.code === 'CITY'">
      <!-- Some city template -->
    </ng-container>
    <ng-container *ngIf="item.code === 'COUNTRY'">
      <!-- Some country template -->
    </ng-container>
  </ng-container>
</app-select>

Do you like the content?

Your support helps me continue my work. Please consider making a donation.

Donations are accepted through PayPal or Stripe. You do not need a account to donate. All major credit cards are accepted.

Leave a comment