src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.ts
Custom MatFormFieldControl
for any select / dropdown field.
OnChanges
OnInit
AfterViewInit
providers |
{ provide: MatFormFieldControl, useExisting: BasicAutocompleteComponent }
|
selector | app-basic-autocomplete |
imports |
BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS
|
styleUrls | ./basic-autocomplete.component.scss |
templateUrl | basic-autocomplete.component.html |
Properties |
|
Methods |
Inputs |
Outputs |
HostBindings |
Accessors |
constructor(elementRef: ElementRef
|
||||||||||||||||||
Parameters :
|
createOption | |
Type : function
|
|
display | |
Type : "text" | "chips" | "none"
|
|
Default value : "text"
|
|
Display the selected items as simple text, as chips or not at all (if used in combination with another component) |
hideOption | |
Type : function
|
|
Default value : () => false
|
|
multi | |
Type : boolean
|
|
Whether the user should be able to select multiple values. |
optionToString | |
Type : (option: O) => any
|
|
Default value : (option: O) =>
option?.["_label"] ?? option?.toString()
|
|
reorder | |
Type : boolean
|
|
Whether the user can manually drag & drop to reorder the selected items |
valueMapper | |
Type : (option: O) => any
|
|
Default value : (option: O) =>
option?.["_id"] ?? (option as unknown as V)
|
|
aria-describedby | |
Type : string
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:41
|
disabled | |
Type : boolean
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:73
|
placeholder | |
Type : string
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:42
|
required | |
Type : boolean
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:45
|
value | |
Type : T
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:84
|
autocompleteFilterChange | |
Type : EventEmitter
|
|
valueChange | |
Type : EventEmitter
|
|
Inherited from
CustomFormControlDirective
|
|
Defined in
CustomFormControlDirective:99
|
id |
Type : string
|
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:39
|
Async createNewOption | ||||||
createNewOption(option: string)
|
||||||
Parameters :
Returns :
any
|
drop | ||||||
drop(event: CdkDragDrop
|
||||||
Parameters :
Returns :
void
|
onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
Inherited from
CustomFormControlDirective
|
||||||
Defined in
CustomFormControlDirective:410
|
||||||
Parameters :
Returns :
void
|
onFocusOut | ||||||
onFocusOut(event: FocusEvent)
|
||||||
Parameters :
Returns :
void
|
select | ||||||
select(selected: string | SelectableOption<O | V>)
|
||||||
Parameters :
Returns :
void
|
showAutocomplete | ||||||
showAutocomplete(valueToRevertTo?: string)
|
||||||
Parameters :
Returns :
void
|
unselect | ||||||
unselect(option: SelectableOption<O | V>)
|
||||||
Parameters :
Returns :
void
|
writeValue | ||||||
writeValue(val: V[] | V)
|
||||||
Inherited from
CustomFormControlDirective
|
||||||
Defined in
CustomFormControlDirective:419
|
||||||
Parameters :
Returns :
void
|
blur |
blur()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:129
|
Returns :
void
|
focus |
focus()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:124
|
Returns :
void
|
registerOnChange | ||||||
registerOnChange(fn: any)
|
||||||
Inherited from
CustomFormControlDirective
|
||||||
Defined in
CustomFormControlDirective:150
|
||||||
Parameters :
Returns :
void
|
registerOnTouched | ||||||
registerOnTouched(fn: any)
|
||||||
Inherited from
CustomFormControlDirective
|
||||||
Defined in
CustomFormControlDirective:154
|
||||||
Parameters :
Returns :
void
|
setDescribedByIds | ||||||
setDescribedByIds(ids: string[])
|
||||||
Inherited from
CustomFormControlDirective
|
||||||
Defined in
CustomFormControlDirective:136
|
||||||
Parameters :
Returns :
void
|
setDisabledState | ||||||
setDisabledState(isDisabled: boolean)
|
||||||
Inherited from
CustomFormControlDirective
|
||||||
Defined in
CustomFormControlDirective:158
|
||||||
Parameters :
Returns :
void
|
_selectedOptions |
Type : SelectableOption<O, V>[]
|
Default value : []
|
autocomplete |
Type : MatAutocompleteTrigger
|
Decorators :
@ViewChild(MatAutocompleteTrigger)
|
autocompleteFilterFunction |
Type : function
|
autocompleteForm |
Default value : new FormControl("")
|
autocompleteOptions |
Type : SelectableOption<O, V>[]
|
Default value : []
|
inputElement |
Decorators :
@ViewChild(MatInput, {static: true})
|
isInSearchMode |
Type : WritableSignal<boolean>
|
Default value : signal(false)
|
display the search input rather than the selected elements only (when the form field gets focused). |
retainSearchValue |
Type : string
|
Keep the search value to help users quickly multi-select multiple related options without having to type filter text again |
showAddOption |
Default value : false
|
whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate) |
templateRef |
Type : TemplateRef<any>
|
Decorators :
@ContentChild(TemplateRef)
|
trackByOptionValueFn |
Type : TrackByFunction<SelectableOption<O, V>> | undefined
|
Default value : () => {...}
|
virtualScrollViewport |
Type : CdkVirtualScrollViewport
|
Decorators :
@ViewChild(CdkVirtualScrollViewport)
|
_disabled |
Default value : false
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:82
|
_value |
Type : T
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:97
|
controlType |
Type : string
|
Default value : "custom-control"
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:60
|
Public elementRef |
Type : ElementRef<HTMLElement>
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:102
|
errorState |
Default value : false
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:59
|
Public errorStateMatcher |
Type : ErrorStateMatcher
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:103
|
focused |
Default value : false
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:57
|
id |
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
Decorators :
@HostBinding()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:39
|
Static nextId |
Type : number
|
Default value : 0
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:37
|
Public ngControl |
Type : NgControl
|
Decorators :
@Optional()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:104
|
onChange |
Default value : () => {...}
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:61
|
onTouched |
Default value : () => {...}
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:62
|
Public parentForm |
Type : NgForm
|
Decorators :
@Optional()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:105
|
Public parentFormGroup |
Type : FormGroupDirective
|
Decorators :
@Optional()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:106
|
stateChanges |
Default value : new Subject<void>()
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:56
|
touched |
Default value : false
|
Inherited from
CustomFormControlDirective
|
Defined in
CustomFormControlDirective:58
|
displayText |
getdisplayText()
|
disabled | ||||||
getdisabled()
|
||||||
setdisabled(value: boolean)
|
||||||
Parameters :
Returns :
void
|
options | ||||||||
setoptions(options: O[])
|
||||||||
The options to display in the autocomplete dropdown.
If you pass complex objects here, you can customize what value is displayed and what value is output/stored
by overriding the
Parameters :
Returns :
void
|
import {
Component,
ContentChild,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Optional,
Output,
Self,
signal,
TemplateRef,
TrackByFunction,
ViewChild,
WritableSignal,
AfterViewInit,
} from "@angular/core";
import { NgForOf, NgIf, NgTemplateOutlet } from "@angular/common";
import { MatFormFieldControl } from "@angular/material/form-field";
import {
FormControl,
FormGroupDirective,
NgControl,
NgForm,
ReactiveFormsModule,
} from "@angular/forms";
import { MatInput, MatInputModule } from "@angular/material/input";
import {
MatAutocompleteModule,
MatAutocompleteTrigger,
} from "@angular/material/autocomplete";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { filter, map, startWith } from "rxjs/operators";
import { ErrorStateMatcher } from "@angular/material/core";
import { CustomFormControlDirective } from "./custom-form-control.directive";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import {
MatChipGrid,
MatChipInput,
MatChipRemove,
MatChipRow,
} from "@angular/material/chips";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { MatTooltip } from "@angular/material/tooltip";
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from "@angular/cdk/drag-drop";
import {
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
} from "@angular/cdk/scrolling";
interface SelectableOption<O, V> {
initial: O;
asString: string;
asValue: V;
selected: boolean;
}
export const BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS = [
ReactiveFormsModule,
MatInputModule,
MatAutocompleteModule,
NgForOf,
MatCheckboxModule,
NgIf,
NgTemplateOutlet,
MatChipInput,
MatChipGrid,
MatChipRow,
FaIconComponent,
MatTooltip,
MatChipRemove,
DragDropModule,
CdkVirtualScrollViewport,
CdkVirtualForOf,
CdkFixedSizeVirtualScroll,
];
/**
* Custom `MatFormFieldControl` for any select / dropdown field.
*/
@Component({
selector: "app-basic-autocomplete",
templateUrl: "basic-autocomplete.component.html",
styleUrls: ["./basic-autocomplete.component.scss"],
providers: [
{ provide: MatFormFieldControl, useExisting: BasicAutocompleteComponent },
],
imports: BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS,
})
export class BasicAutocompleteComponent<O, V = O>
extends CustomFormControlDirective<V | V[]>
implements OnChanges, OnInit, AfterViewInit
{
@ContentChild(TemplateRef) templateRef: TemplateRef<any>;
// `_elementRef` is protected in `MapInput`
@ViewChild(MatInput, { static: true }) inputElement: MatInput & {
_elementRef: ElementRef<HTMLElement>;
};
@ViewChild(MatAutocompleteTrigger) autocomplete: MatAutocompleteTrigger;
@ViewChild(CdkVirtualScrollViewport)
virtualScrollViewport: CdkVirtualScrollViewport;
@Input() valueMapper = (option: O) =>
option?.["_id"] ?? (option as unknown as V);
@Input() optionToString = (option: O) =>
option?.["_label"] ?? option?.toString();
@Input() createOption: (input: string) => Promise<O>;
@Input() hideOption: (option: O) => boolean = () => false;
/**
* Whether the user should be able to select multiple values.
*/
@Input() multi?: boolean;
/**
* Whether the user can manually drag & drop to reorder the selected items
*/
@Input() reorder?: boolean;
autocompleteOptions: SelectableOption<O, V>[] = [];
autocompleteForm = new FormControl("");
autocompleteSuggestedOptions = this.autocompleteForm.valueChanges.pipe(
filter((val) => typeof val === "string"),
map((val) => this.updateAutocomplete(val)),
startWith([] as SelectableOption<O, V>[]),
);
autocompleteFilterFunction: (option: O) => boolean;
@Output() autocompleteFilterChange = new EventEmitter<(o: O) => boolean>();
/** whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate) */
showAddOption = false;
get displayText() {
const values: V[] = Array.isArray(this.value) ? this.value : [this.value];
return values
.map((v) => this._options.find((o) => o.asValue === v)?.asString)
.join(", ");
}
override get disabled(): boolean {
return this._disabled;
}
override set disabled(value: boolean) {
this._disabled = coerceBooleanProperty(value);
this._disabled
? this.autocompleteForm.disable()
: this.autocompleteForm.enable();
this.stateChanges.next();
}
/**
* The options to display in the autocomplete dropdown.
* If you pass complex objects here, you can customize what value is displayed and what value is output/stored
* by overriding the `valueMapper` and `optionToString` methods via inputs.
* By default, the "_id" property is used as the value and the "_label" property or `toString()` method as the display value.
*
* @param options Array of available options (can be filtered further by the `hideOption` function)
*/
@Input() set options(options: O[]) {
this._options = options.map((o) => this.toSelectableOption(o));
}
private _options: SelectableOption<O, V>[] = [];
_selectedOptions: SelectableOption<O, V>[] = [];
/**
* Keep the search value to help users quickly multi-select multiple related options without having to type filter text again
*/
retainSearchValue: string;
/**
* Display the selected items as simple text, as chips or not at all (if used in combination with another component)
*/
@Input() display: "text" | "chips" | "none" = "text";
/**
* display the search input rather than the selected elements only
* (when the form field gets focused).
*/
isInSearchMode: WritableSignal<boolean> = signal(false);
trackByOptionValueFn: TrackByFunction<SelectableOption<O, V>> | undefined = (
i,
o,
) => o?.asValue;
constructor(
elementRef: ElementRef<HTMLElement>,
errorStateMatcher: ErrorStateMatcher,
@Optional() @Self() ngControl: NgControl,
@Optional() parentForm: NgForm,
@Optional() parentFormGroup: FormGroupDirective,
) {
super(
elementRef,
errorStateMatcher,
ngControl,
parentForm,
parentFormGroup,
);
}
ngOnInit() {
this.autocompleteSuggestedOptions.subscribe((options) => {
this.autocompleteOptions = options;
});
// Subscribe to the valueChanges observable to print the input value
this.autocompleteForm.valueChanges.subscribe((value) => {
if (typeof value === "string") {
this.retainSearchValue = value;
}
});
}
ngOnChanges(changes: { [key in keyof this]?: any }) {
if (changes.valueMapper) {
this._options.forEach(
(opt) => (opt.asValue = this.valueMapper(opt.initial)),
);
}
if (changes.optionToString) {
this._options.forEach(
(opt) => (opt.asString = this.optionToString(opt.initial)),
);
}
if (changes.value || changes.options) {
this.setInitialInputValue();
if (this.autocomplete?.panelOpen) {
// if new options have been added, make sure to update the visible autocomplete options
this.showAutocomplete(this.autocompleteForm.value);
}
}
}
ngAfterViewInit() {
window.addEventListener("focus", () => {
if (this.autocomplete?.panelOpen) {
this.showAutocomplete();
}
});
}
drop(event: CdkDragDrop<any[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(
this.autocompleteOptions,
event.previousIndex,
event.currentIndex,
);
}
this._selectedOptions = this.autocompleteOptions.filter((o) => o.selected);
if (this.multi) {
this.value = this._selectedOptions.map((o) => o.asValue);
} else {
this.value = undefined;
}
this.setInitialInputValue();
this.onChange(this.value);
this.showAutocomplete(this.autocompleteForm.value);
}
showAutocomplete(valueToRevertTo?: string) {
if (this.multi && this.retainSearchValue) {
// reset the search value to previously entered text to help user selecting multiple similar options without retyping filter text
this.autocompleteForm.setValue(this.retainSearchValue);
} else {
// reset the search value to show all available options again
this.autocompleteForm.setValue("");
}
if (!this.multi) {
// cannot setValue to "" here because the current selection would be lost
this.autocompleteForm.setValue(this.displayText, { emitEvent: false });
}
setTimeout(() => {
this.inputElement.focus();
// select all text for easy overwriting when typing to search for options
(
this.inputElement._elementRef.nativeElement as HTMLInputElement
).select();
if (valueToRevertTo) {
this.autocompleteForm.setValue(valueToRevertTo);
}
});
this.isInSearchMode.set(true);
// update virtual scroll as the container remains empty until the user scrolls initially
setTimeout(() => this.virtualScrollViewport.checkViewportSize());
}
private updateAutocomplete(inputText: string): SelectableOption<O, V>[] {
let filteredOptions = this._options.filter(
(o) => !this.hideOption(o.initial),
);
if (inputText) {
this.autocompleteFilterFunction = (option) =>
this.optionToString(option)
.toLowerCase()
.includes(inputText.toLowerCase());
this.autocompleteFilterChange.emit(this.autocompleteFilterFunction);
filteredOptions = filteredOptions.filter((o) =>
this.autocompleteFilterFunction(o.initial),
);
// do not allow users to create a new entry "identical" to an existing one:
this.showAddOption = !this._options.some(
(o) => o.asString.toLowerCase() === inputText.toLowerCase(),
);
}
return filteredOptions;
}
private setInitialInputValue() {
this._options.forEach(
(o) =>
(o.selected = Array.isArray(this.value)
? this.value?.includes(o.asValue)
: this.value === o.asValue),
);
this._selectedOptions = this._options.filter((o) => o.selected);
}
select(selected: string | SelectableOption<O, V>) {
if (typeof selected === "string") {
this.createNewOption(selected);
return;
}
if (selected) {
this.selectOption(selected);
} else {
this.autocompleteForm.setValue("");
this._selectedOptions = [];
this.value = undefined;
}
this.onChange(this.value);
}
unselect(option: SelectableOption<O, V>) {
option.selected = false;
this._selectedOptions = this._options.filter((o) => o.selected);
if (this.multi) {
this.value = this._selectedOptions.map((o) => o.asValue);
} else {
this.value = undefined;
}
this.onChange(this.value);
}
async createNewOption(option: string) {
const createdOption = await this.createOption(option);
if (createdOption) {
const newOption = this.toSelectableOption(createdOption);
this._options.push(newOption);
this.select(newOption);
} else {
// continue editing
this.showAutocomplete();
this.autocompleteForm.setValue(option);
}
}
private selectOption(option: SelectableOption<O, V>) {
if (this.multi) {
option.selected = !option.selected;
this._selectedOptions = this._options.filter((o) => o.selected);
this.value = this._selectedOptions.map((o) => o.asValue);
// re-open autocomplete to select next option
setTimeout(() => this.showAutocomplete());
} else {
this._selectedOptions = [option];
this.value = option.asValue;
this.isInSearchMode.set(false);
}
}
private toSelectableOption(opt: O): SelectableOption<O, V> {
return {
initial: opt,
asValue: this.valueMapper(opt),
asString: this.optionToString(opt),
selected: false,
};
}
onFocusOut(event: FocusEvent) {
if (
!this.elementRef.nativeElement.contains(event.relatedTarget as Element)
) {
if (!this.multi && this.autocompleteForm.value === "") {
this.select(undefined);
}
this.isInSearchMode.set(false);
}
}
override onContainerClick(event: MouseEvent) {
if (
!this._disabled &&
(event.target as Element).tagName.toLowerCase() != "input"
) {
this.showAutocomplete();
}
}
override writeValue(val: V[] | V) {
super.writeValue(val);
this.setInitialInputValue();
}
}
<!--Display-->
<input
*ngIf="display === 'text' || display === 'none'; else chipsDisplay"
[hidden]="isInSearchMode() || display === 'none'"
[disabled]="_disabled"
matInput
style="text-overflow: ellipsis; width: calc(100% - 50px)"
(focusin)="showAutocomplete()"
(focusout)="showAutocomplete()"
[value]="displayText"
[placeholder]="placeholder"
/>
<!--Search-->
<input
[hidden]="!isInSearchMode()"
#inputElement
[formControl]="autocompleteForm"
matInput
style="text-overflow: ellipsis"
[matAutocomplete]="autoSuggestions"
(focusout)="onFocusOut($event)"
[placeholder]="placeholder"
/>
<!--
Autocomplete
-->
<mat-autocomplete
[disableRipple]="true"
#autoSuggestions="matAutocomplete"
(optionSelected)="select($event.option.value)"
autoActiveFirstOption
[hideSingleSelectionIndicator]="true"
>
<div
cdkDropList
(cdkDropListDropped)="drop($event)"
cdkDropListGroup
[cdkDropListDisabled]="!reorder"
>
<cdk-virtual-scroll-viewport
[style.height]="(autocompleteOptions?.length ?? 0) * 48 + 'px'"
[style.max-height]="3 * 48 + 'px'"
itemSize="48"
minBufferPx="200"
>
<mat-option
[value]="item"
cdkDrag
*cdkVirtualFor="
let item of autocompleteOptions;
trackBy: trackByOptionValueFn
"
>
<div class="flex-row disable-autocomplete-active-color align-center">
<div *ngIf="reorder">
<fa-icon
icon="grip-vertical"
size="sm"
class="drag-handle"
></fa-icon>
</div>
<mat-checkbox *ngIf="multi" [checked]="item.selected"></mat-checkbox>
<ng-container *ngIf="!templateRef; else itemTemplate">
{{ item.asString }}
</ng-container>
<ng-template
class="item-option"
#itemTemplate
[ngTemplateOutlet]="templateRef"
[ngTemplateOutletContext]="{ $implicit: item.initial }"
></ng-template>
</div>
</mat-option>
</cdk-virtual-scroll-viewport>
</div>
<!-- Create new option -->
<mat-option
*ngIf="createOption && showAddOption && inputElement.value"
[value]="inputElement.value"
>
<em
i18n="Label for adding an option in a dropdown|e.g. Add new My new Option"
>Add new</em
>
{{ inputElement.value }}
</mat-option>
<mat-option style="display: none">
<!-- This mat-option is never displayed ("display: none") but has to be there,
because the footer below will only be displayed with at least one mat-option -->
</mat-option>
<div class="autocomplete-footer">
<ng-content select="[autocompleteFooter]"></ng-content>
</div>
</mat-autocomplete>
<!--
Optional displaying as chips
-->
<ng-template #chipsDisplay>
<input
[hidden]="true"
[disabled]="_disabled"
matInput
(focusin)="showAutocomplete()"
(focusout)="showAutocomplete()"
[matChipInputFor]="chipList"
/>
<mat-chip-grid #chipList>
<ng-container>
<mat-chip-row
*ngFor="let item of _selectedOptions"
[editable]="!_disabled"
class="chip"
>
<ng-container *ngIf="!templateRef; else itemTemplate">
{{ item.asString }}
</ng-container>
<ng-template
#itemTemplate
[ngTemplateOutlet]="templateRef"
[ngTemplateOutletContext]="{ $implicit: item.initial }"
></ng-template>
<button matChipRemove *ngIf="!_disabled" (click)="unselect(item)">
<fa-icon
i18n-matTooltip="tooltip for remove icon on chips of dropdown item"
matTooltip="remove"
icon="xmark"
></fa-icon>
</button>
</mat-chip-row>
</ng-container>
</mat-chip-grid>
</ng-template>
./basic-autocomplete.component.scss
@use "variables/colors";
@use "variables/sizes";
em {
color: colors.$primary;
}
.disable-autocomplete-active-color {
color: black;
}
.autocomplete-footer {
margin: sizes.$small sizes.$regular;
}