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.
Top comments (4)
I enjoyed reading through your progress. I think this is a great modern solution to the multi-select issue. Thanks for posting. I am currently working on simply pre-filling a form based on values (username) from another component. And struggling with that, but I will figure it out. Also, I lived in Columbus for 1998-2012! Go Bucks!
This pattern was copied at my workplace but I'm running into the issue where selecting checkbox-options from the autocomplete with the keyboard doesn't really work. Have you found any ways around this?
Same, Have you found any ways around this?
I had a request on this article to provide a working copy of the code. I need about a week and I'll provide a GitHub and working GitHub Page ... links in the article.