Angular Material Multi-Select Autocomplete

bob.ts - Aug 5 '21 - - Dev Community

For a recent client, I needed a "searchable" select. They wanted to match functionality used in other applications. The original searchable selects were a legacy jQuery object that would have been an odd fit in a modern Angular application.

What I needed was a select-type dropdown that allowed for multi-row selection, as well as the ability to filter the list down on a string entered as a search by the user.

Here is what I came up with ... a multiselect autocomplete.

Code

Working Example
GitHub Repo

HTML

Starting with the HTML ... these are displayed out of order to make the logic behind them more understandable.

Input

This is the form field with a Material Input tied to selectControl.

<mat-form-field class="full-width">
  <input matInput type="text"
  [placeholder]="placeholder"
  [matAutocomplete]="auto"
  [formControl]="selectControl">
</mat-form-field>
Enter fullscreen mode Exit fullscreen mode

Chip List

I added a Material Chip List to display the selections. This code is generally above the other code so that they are not hidden under the Autocomplete dropdown. This list also allows for Chips to be removed on click.

<div class="chip-list-wrapper">
  <mat-chip-list #chipList>
    <ng-container *ngFor="let select of selectData">
      <mat-chip class="cardinal-colors" (click)="removeChip(select)">
        {{ select.item }}
        <mat-icon class="mat-chip-remove">cancel</mat-icon>
      </mat-chip>
    </ng-container>
  </mat-chip-list>
</div>
Enter fullscreen mode Exit fullscreen mode

Autocomplete

And, here is the Material Autocomplete tied to filterdata.

<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
  <mat-option *ngFor="let data of filteredData | async">
    <div (click)="optionClicked($event, data)">
      <mat-checkbox [checked]="data.selected" 
        (change)="toggleSelection(data)" 
        (click)="$event.stopPropagation()">
        {{ data.item }}
      </mat-checkbox>
    </div>
  </mat-option>
</mat-autocomplete>
Enter fullscreen mode Exit fullscreen mode

CSS

The CSS is pretty straight forward ... some sizing and color.

.full-width {
  width: 100%;
}

.chip-list-wrapper {
  min-height: 3em;
}

.msac-colors {
  background-color: var(--primary-color);
  color: white;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript

Again, I want to try to break this code up for readability.

Imports

import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';

import { ItemData } from '@core/interfaces/multi-select-item-data';
Enter fullscreen mode Exit fullscreen mode

Most of these are pretty straight forward ... ItemData needs definition ... looking at the interfaces ...

export interface ItemData {
  item: string;
  selected: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Component Wrapper

@Component({
  selector: 'multiselect-autocomplete',
  templateUrl: './multiselect-autocomplete.component.html',
  styleUrls: ['./multiselect-autocomplete.component.scss']
})
export class MultiselectAutocompleteComponent implements OnInit {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Data Setup

Here are the data points, inputs, and outputs.

@Output() result = new EventEmitter<{ key: string, data: Array<string> }>();

@Input() placeholder: string = 'Select Data';
@Input() data: Array<string> = [];
@Input() key: string = '';

selectControl = new FormControl();

rawData: Array<ItemData> = [];
selectData: Array<ItemData> = [];

filteredData: Observable<Array<ItemData>>;
filterString: string = '';
Enter fullscreen mode Exit fullscreen mode

The placeholder and data structures are fairly clear. The key is passed in, then emitted back out without change. This allows the outside (calling) code to know which object to attach to.

Initialization

constructor() {
  this.filteredData = this.selectControl.valueChanges.pipe(
    startWith<string>(''),
    map(value => typeof value === 'string' ? value : this.filterString),
    map(filter => this.filter(filter))
  );
}

ngOnInit(): void {
  this.data.forEach((item: string) => {
    this.rawData.push({ item, selected: false });
  });
}
Enter fullscreen mode Exit fullscreen mode

Now, I am taking the data input and generating matching rawData with selected as a boolean.

Additionally, I am binding the filteredData to the selectControl value changes. This is why we need the async in the HTML above.

Filter and Display Functions

These two functions are used directly on the HTML objects above.

filter = (filter: string): Array<ItemData> => {
  this.filterString = filter;
  if (filter.length > 0) {
    return this.rawData.filter(option => {
      return option.item.toLowerCase().indexOf(filter.toLowerCase()) >= 0;
    });
  } else {
    return this.rawData.slice();
  }
};

displayFn = (): string => '';
Enter fullscreen mode Exit fullscreen mode

Option Clicked

optionClicked = (event: Event, data: ItemData): void => {
  event.stopPropagation();
  this.toggleSelection(data);
};
Enter fullscreen mode Exit fullscreen mode

optionClicked is named and configured this way for readability.

Toggle Selection

toggleSelection = (data: ItemData): void => {
  data.selected = !data.selected;

  if (data.selected === true) {
    this.selectData.push(data);
  } else {
    const i = this.selectData.findIndex(value => value.item === data.item);
    this.selectData.splice(i, 1);
  }

  this.selectControl.setValue(this.selectData);
  this.emitAdjustedData();
};
Enter fullscreen mode Exit fullscreen mode

toggleSelection toggles, adds / removes the value from selectData, and emits the changed data.

Emitting Adjusted Data

emitAdjustedData = (): void => {
  const results: Array<string> = []
  this.selectData.forEach((data: ItemData) => {
    results.push(data.item);
  });
  this.result.emit({ key: this.key, data: results });
};
Enter fullscreen mode Exit fullscreen mode

Here, I needed to rebuild a simply array of string containing the selected items only.

Removing a chip

This code seems redundant, but in my mind it was better to describe the functionality clearly.

removeChip = (data: ItemData): void => {
  this.toggleSelection(data);
};
Enter fullscreen mode Exit fullscreen mode

Using the Multiselect Autocomplete

HTML

Here, I passed in the inputs and set a function to capture the emitted result.

<multiselect-autocomplete
  [placeholder]="structure[index].subtitle"
  [data]="cardSelects[card.key]"
  [key]="card.key"
  (result)="selectChange($event)">
</multiselect-autocomplete>
Enter fullscreen mode Exit fullscreen mode

TypeScript

Event key and data are emitted out and used here.

selectChange = (event: any) => {
  const key: string = event.key;
  this.cardValue[key] = [ ...event.data ];
};
Enter fullscreen mode Exit fullscreen mode

Code

Working Example
GitHub Repo

Summary

This was a cool component to create and good challenge. I am pleased with the result, both look-and-feel as well as functionality.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .