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
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>
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>
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>
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;
}
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';
Most of these are pretty straight forward ... ItemData
needs definition ... looking at the interfaces ...
export interface ItemData {
item: string;
selected: boolean;
}
Component Wrapper
@Component({
selector: 'multiselect-autocomplete',
templateUrl: './multiselect-autocomplete.component.html',
styleUrls: ['./multiselect-autocomplete.component.scss']
})
export class MultiselectAutocompleteComponent implements OnInit {
...
}
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 = '';
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 });
});
}
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 => '';
Option Clicked
optionClicked = (event: Event, data: ItemData): void => {
event.stopPropagation();
this.toggleSelection(data);
};
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();
};
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 });
};
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);
};
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>
TypeScript
Event key
and data
are emitted out and used here.
selectChange = (event: any) => {
const key: string = event.key;
this.cardValue[key] = [ ...event.data ];
};
Code
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.