File

src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.ts

Description

Custom MatFormFieldControl for any select / dropdown field.

Extends

CustomFormControlDirective

Implements

OnChanges OnInit AfterViewInit

Metadata

Index

Properties
Methods
Inputs
Outputs
HostBindings
Accessors

Constructor

constructor(elementRef: ElementRef, errorStateMatcher: ErrorStateMatcher, ngControl: NgControl, parentForm: NgForm, parentFormGroup: FormGroupDirective)
Parameters :
Name Type Optional
elementRef ElementRef<HTMLElement> No
errorStateMatcher ErrorStateMatcher No
ngControl NgControl No
parentForm NgForm No
parentFormGroup FormGroupDirective No

Inputs

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.

options
Type : 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 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.

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
disabled
Type : boolean
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

autocompleteFilterChange
Type : EventEmitter
valueChange
Type : EventEmitter

HostBindings

id
Type : string
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`

Methods

Async createNewOption
createNewOption(option: string)
Parameters :
Name Type Optional
option string No
Returns : any
drop
drop(event: CdkDragDrop)
Parameters :
Name Type Optional
event CdkDragDrop<any[]> No
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
onFocusOut
onFocusOut(event: FocusEvent)
Parameters :
Name Type Optional
event FocusEvent No
Returns : void
select
select(selected: string | SelectableOption<O | V>)
Parameters :
Name Type Optional
selected string | SelectableOption<O | V> No
Returns : void
showAutocomplete
showAutocomplete(valueToRevertTo?: string)
Parameters :
Name Type Optional
valueToRevertTo string Yes
Returns : void
unselect
unselect(option: SelectableOption<O | V>)
Parameters :
Name Type Optional
option SelectableOption<O | V> No
Returns : void
writeValue
writeValue(val: V[] | V)
Parameters :
Name Type Optional
val V[] | V No
Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void

Properties

_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 : []
autocompleteSuggestedOptions
Default value : this.autocompleteForm.valueChanges.pipe( filter((val) => typeof val === "string"), map((val) => this.updateAutocomplete(val)), startWith([] as SelectableOption<O, V>[]), )
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
_value
Type : T
controlType
Type : string
Default value : "custom-control"
Public elementRef
Type : ElementRef<HTMLElement>
errorState
Default value : false
Public errorStateMatcher
Type : ErrorStateMatcher
focused
Default value : false
id
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Decorators :
@HostBinding()
Static nextId
Type : number
Default value : 0
Public ngControl
Type : NgControl
Decorators :
@Optional()
@Self()
onChange
Default value : () => {...}
onTouched
Default value : () => {...}
Public parentForm
Type : NgForm
Decorators :
@Optional()
Public parentFormGroup
Type : FormGroupDirective
Decorators :
@Optional()
stateChanges
Default value : new Subject<void>()
touched
Default value : false

Accessors

displayText
getdisplayText()
disabled
getdisabled()
setdisabled(value: boolean)
Parameters :
Name Type Optional
value boolean No
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 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.

Parameters :
Name Type Optional Description
options O[] No

Array of available options (can be filtered further by the hideOption function)

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;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""