Home » Posts » How to create a multiselect dropdown in Angular?

How to create a multiselect dropdown in Angular?

Often times, we get a requirement of creating select dropdowns with specific needs which are not available in components public libraries. Therefore, we will create a basic multiselect dropdown which can be a base to build upon for your requirements.

What are we going to support?

In this multiselect dropdown, we are going to support the following features.

  1. Placeholder text.
  2. Single and multiselect toggle.
  3. Search in options.
  4. Select/clear all options.
Single Select Dropdown
Multi Select Dropdown

Implementation

Let’s call our component as “mySelectComponent” and begin with its HTML.

HTML

<div class="my-select-container position-relative">
    <div class="my-select-top w-100 d-flex border rounded cursor-pointer shadow-sm" (click)="toggleDrawer($event)">
        <div class="my-select-placeholder flex-grow-1 p-3 text-truncate">
            {{selectionText}}
        </div>

        <div class="my-select-open-close-icon ms-2 p-3">
            <span *ngIf="!isDrawerOpened"><i class="bi bi-chevron-compact-down"></i></span>
            <span *ngIf="isDrawerOpened"><i class="bi bi-chevron-compact-up"></i></span>
        </div>
    </div>

    <div class="my-select-drawer w-100 border rounded position-absolute d-flex flex-column bg-white shadow"
        *ngIf="isDrawerOpened" (click)="$event.stopPropagation()">
        <div class="my-select-item-search p-2">
            <input type="text" placeholder="Search" class="form-control form-control-sm" [(ngModel)]="searchText"
                (keyup)="handleSearch($event)">
        </div>

        <div class="my-select-items p-2">
            <div class="my-select-single-select-items" *ngIf="!multiselect">
                <div *ngFor="let option of filteredOptions">
                    <label><input type="radio" name="my-select-single-select" [(ngModel)]="singleSelectValue"
                            [value]="option.value" (change)="handleRadioInputChange()" />
                        {{option.label}}</label>
                </div>
            </div>

            <div class="my-select-multi-select-items" *ngIf="multiselect">
                <div class="border-bottom text-secondary mb-2 d-flex align-items-center">
                    <input type="checkbox" id="select-all" [(ngModel)]="allOptionsSelected"
                        (change)="handleSelectAll()">
                    <label for="select-all"> Select All</label>
                </div>

                <div *ngFor="let option of filteredOptions">
                    <label><input type="checkbox" [(ngModel)]="selectedOptionsValueMap[option.value]"
                            (change)="handleCheckBoxChange()" />
                        {{option.label}}</label>
                </div>
            </div>
        </div>

        <div class="my-select-drawer-footer d-flex justify-content-end p-2 border-top" *ngIf="multiselect">
            <button class="btn btn-primary" (click)="handleApply()">Apply</button>
        </div>
    </div>
</div>

We start with a container div which has the top, where user will see the placeholder text and selection, and click on it to open the drawer, which will show the options below it. We can see that we have bound a function toggleDrawer on click event of top to handle its display and hiding.

We are showing the drawer only if the drawer is open i.e., when the isDrawerOpened variable is set to true. Also, we are stopping the propagation of the click event from the drawer because we are closing the drawer if a click happens outside the drawer, anywhere on the document. If we don’t do this, every time a user clicks inside the drawer div, it will disappear.

In the drawer, we first start with the search box whose keyup event is handled by the handleSearch function. Then comes the list of options as radio buttons or checkboxes depending on the multiselect property passed as input in the component. Finally, we have the apply button which is shown only in the case of multiselect.

TS

import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { ISelectOption } from '../interfaces/iselect-option';

@Component({
  selector: 'app-my-select',
  templateUrl: './my-select.component.html',
  styleUrls: ['./my-select.component.scss']
})
export class MySelectComponent implements OnInit, OnDestroy {

  private searchDebounceTimer: any = null;

  isDrawerOpened: boolean = false;
  singleSelectValue: string | number = '';
  filteredOptions: ISelectOption[] = [];
  selectedOptionsValueMap: any = {};
  allOptionsSelected: boolean = false;
  selectionText: string = '';
  searchText: string = '';


  @Input() placeholder: string = '';
  @Input() multiselect: boolean = false;
  @Input() options: ISelectOption[] = [];

  @Output() onSelect: EventEmitter<ISelectOption[]> = new EventEmitter<ISelectOption[]>();

  constructor() { }

  ngOnInit(): void {
    this.selectionText = this.placeholder;
    this.filteredOptions = [...this.options];
    document.addEventListener('click', this.handleDocumentClick);
  }

  ngOnDestroy(): void {
    document.removeEventListener('click', this.handleDocumentClick);
  }

  handleDocumentClick = (() => {
    this.closeDrawer();
  }).bind(this);

  toggleDrawer(event: any) {
    event.stopPropagation();
    this.isDrawerOpened = !this.isDrawerOpened;
  }

  closeDrawer() {
    this.isDrawerOpened = false;
  }

  handleRadioInputChange() {
    this.closeDrawer();

    const selectedOptions = [{ ...this.options.find(x => x.value === this.singleSelectValue) }];

    this.selectionText = selectedOptions[0].label || '';
    this.onSelect.emit(selectedOptions as ISelectOption[]);
  }

  handleSearch(event: any) {
    if (this.searchDebounceTimer !== null) {
      clearTimeout(this.searchDebounceTimer);
    }

    const searchText = event.target.value.trim();

    this.searchDebounceTimer = setTimeout(() => {
      if (searchText === '') {
        this.filteredOptions = [...this.options];
        return;
      }

      this.filteredOptions = this.options.filter(x => x.label.toLowerCase().includes(searchText.toLowerCase()));
    }, 500);
  }

  handleApply() {
    this.closeDrawer();

    const selectedOptions = this.options.filter(x => this.selectedOptionsValueMap[x.value]).map(x => { return { ...x } });

    if (selectedOptions.length > 0) {
      this.selectionText = `${selectedOptions.length} option(s) selected`;
    } else {
      this.selectionText = this.placeholder;
    }

    this.onSelect.emit(selectedOptions);
  }

  handleCheckBoxChange() {
    let areAllSelected = true;

    for (let option of this.filteredOptions) {
      if (!this.selectedOptionsValueMap[option.value]) {
        areAllSelected = false;
        break;
      }
    }

    this.allOptionsSelected = areAllSelected;
  }

  handleSelectAll() {
    for (let option of this.filteredOptions) {
      this.selectedOptionsValueMap[option.value] = this.allOptionsSelected;
    }
  }

}

Here, we are taking placeholder text, whether the options are to be shown as single or multiselect and the options as input property. We will be emitting the selected options from the onSelect event emitter.

When the component is initialized, we set the selection text to placeholder, create a shallow copy of original options and assign them to filtered options and add an event listener to document to close the drawer if clicked outside of it.

handleRadioInputChange raises the onSelect event when an option is selected. Generally, it is expected from a single select dropdown to close the drawer as soon as the selection is made, so, we are doing the same in this function. Note that we are returning a copy of the option selected using spread operator because we don’t want our input option object to be modified outside the component.

For searching, we want to provide the experience of searching on the fly i.e., relevant options are shown as soon as a user starts typing in the search box. He need not to click on a “search button” to actually begin searching. Now, searching (or filtering) on each keystroke might make the screen unresponsive for a moment in case of large option list, or, if the search is happening on the backend then it would be very inefficient to do an API call with every keystroke. Therefore, we are using a technique called debouncing. It basically prevents the actual search operation or API call to be made until the user pauses typing for a specific amount of time. handleSearch function does exactly this.

handleApply goes over the options and checks which of them are selected and creates a list of them. It then, depending on the number of options selected, sets the selection text. At last, it emits the onSelect event with the selected options list.

handleCheckBoxChange function checks whether all the options which are displayed are selected or not. If they are then the allOptionsSelected property is set to true to check the select all checkbox and vice versa.

handleSelectAll function marks all the options being displayed as checked or unchecked depending on the allOptionsSelected property.

Search in dropdown

The interface

You might have noticed that we are expecting input and emitting output as an array of ISelectOption. So, what is it? It is an interface or a way to tell the TS transpiler and the user of our component that this is the format expected as input and output.

export interface ISelectOption {
    label: string;
    value: string | number;
}

App component

In the app component, let’s have two sections, first, which will show the dropdown as single select dropdown and second, which will demonstrate as multi select dropdown.

app component HTML

<div class="container-fluid h-100">
  <div class="row h-100">
    <div class="col h-100">
      <div class="h-100 w-100 d-flex align-items-center justify-content-center">
        <div class="d-flex flex-column me-3">
          <span class="fw-bold">Choose a hero</span>

          <app-my-select [multiselect]="false" placeholder="Select a hero" [options]="options"
            (onSelect)="handleSingleOptionSelect($event)">
          </app-my-select>
        </div>

        <div class="d-flex flex-column">
          <div class="fw-bold">Selected Option</div>

          <div class="mt-2">
            <span *ngIf="selectedOption">Label = {{selectedOption.label}}</span> <br>
            <span *ngIf="selectedOption">Value = {{selectedOption.value}}</span>
          </div>
        </div>
      </div>
    </div>

    <div class="col h-100">
      <div class="h-100 w-100 d-flex align-items-center justify-content-center">
        <div class="d-flex flex-column me-3">
          <span class="fw-bold">Choose heroes</span>

          <app-my-select [multiselect]="true" placeholder="Select multiple heroes" [options]="options"
            (onSelect)="handleMultiOptionSelect($event)">
          </app-my-select>
        </div>

        <div class="d-flex flex-column">
          <div class="fw-bold">Selected Options</div>

          <div *ngFor="let option of selectedOptions" class="mt-2">
            <span>Label = {{option.label}}</span> <br>
            <span>Value = {{option.value}}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

app component TS

import { Component } from '@angular/core';
import { ISelectOption } from './interfaces/iselect-option';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'multiselect-dropdown';
  options: ISelectOption[] = [
    {
      label: 'Tony Stark',
      value: 'iron-man'
    },
    {
      label: 'Thor',
      value: 'thor'
    },
    {
      label: 'Steve Rogers',
      value: 'captain-america'
    },
    {
      label: 'Bruce Banner',
      value: 'hulk'
    }
  ];
  selectedOptions: ISelectOption[] = [];
  selectedOption: ISelectOption | undefined = undefined

  handleSingleOptionSelect(selectedOptions: ISelectOption[]) {
    this.selectedOption = selectedOptions[0];
  }

  handleMultiOptionSelect(selectedOptions: ISelectOption[]) {
    this.selectedOptions = selectedOptions;
  }
}

App component TS code is short and easy to understand. We are defining some options and handling output events from our dropdown component.

Selected options in dropdowns

This is how we create a multiselect dropdown from scratch. This is a very basic implementation and you might want to add more features according to your requirements to use it in your project.