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
Accessors

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
maxOptionsToDisplay
Type : number
Default value : 100

Maximum number of options to display in the dropdown. If more options match the current filter, a hint is shown to type to narrow results. Set to 0 for no limit. Defaults to 100.

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
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

autocompleteFilterChange
Type : EventEmitter
valueChange
Type : EventEmitter

Methods

compareEnumValues
compareEnumValues(a: any, b: any)

Compare two enum values by id if present, otherwise by reference.

Parameters :
Name Type Optional
a any No
b any No
Returns : boolean
Async createNewOption
createNewOption(option: string)
Parameters :
Name Type Optional
option string No
Returns : any
Protected createOptionDisplay
createOptionDisplay(input: string)

Used in template to display the "Add new" option label. Delegates to optionToString so callers with a custom optionToString (e.g. showing an example date) get a preview in the "Add new" option too. Falls back to the raw input when optionToString throws or returns null/undefined (e.g. when O is an object type whose properties don't exist on a plain string).

Parameters :
Name Type Optional
input string No
Returns : string
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
Public updatePanelWidth
updatePanelWidth()

Set the width of the dropdown panel programmatically to match the parent form field. (this is not possible with pure CSS)

Note: If the field is close to the viewport edge, Angular Material's overlay system may shift the dropdown horizontally to keep it visible, causing minor misalignment. This is expected and ensures accessibility.

Returns : void
writeValue
writeValue(val: V[] | V, notifyFormControl: unknown)
Parameters :
Name Type Optional Default value
val V[] | V No
notifyFormControl unknown No false
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
Type : unknown
Default value : new FormControl("")
autocompleteOptions
Type : WritableSignal<SelectableOption[]>
Default value : signal([])
autocompleteSuggestedOptions
Type : unknown
Default value : this.autocompleteForm.valueChanges.pipe( filter((val) => typeof val === "string"), map((val) => this.updateAutocomplete(val)), startWith([] as SelectableOption<O, V>[]), )
hasMoreOptions
Type : unknown
Default value : false
inputElement
Type : unknown
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).

panelWidth
Type : string

Dynamic width of the autocomplete dropdown panel. Set to match the full width of the Material form field container (including icons/padding).

retainSearchValue
Type : string

Keep the search value to help users quickly multi-select multiple related options without having to type filter text again

showAddOption
Type : unknown
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 : () => {...}
viewportHeight
Type : unknown
Default value : computed(() => { const contentHeight = (this.autocompleteOptions()?.length ?? 0) * 48; const availableHeight = BasicAutocompleteComponent.PANEL_MAX_HEIGHT - BasicAutocompleteComponent.FOOTER_RESERVE; return Math.min(contentHeight, availableHeight); })
virtualScrollViewport
Type : CdkVirtualScrollViewport
Decorators :
@ViewChild(CdkVirtualScrollViewport)
_disabled
Type : unknown
Default value : false
_value
Type : T
controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
errorState
Type : unknown
Default value : false
errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
focused
Type : unknown
Default value : false
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
touched
Type : unknown
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 {
  AfterViewInit,
  Component,
  computed,
  ContentChild,
  ElementRef,
  EventEmitter,
  Input,
  OnChanges,
  OnInit,
  Output,
  signal,
  TemplateRef,
  TrackByFunction,
  ViewChild,
  WritableSignal,
} from "@angular/core";
import { NgForOf, NgIf, NgTemplateOutlet } from "@angular/common";
import { MatFormFieldControl } from "@angular/material/form-field";
import { FormControl, 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 { 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;
  isHidden: boolean;
  isInvalid?: boolean;
  isEmpty?: 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;

  /**
   * Used in template to display the "Add new" option label.
   * Delegates to optionToString so callers with a custom optionToString (e.g. showing
   * an example date) get a preview in the "Add new" option too.
   * Falls back to the raw input when optionToString throws or returns null/undefined
   * (e.g. when O is an object type whose properties don't exist on a plain string).
   */
  protected createOptionDisplay(input: string): string {
    try {
      return this.optionToString(input as unknown as O) ?? input;
    } catch {
      return input;
    }
  }

  /**
   * 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: WritableSignal<SelectableOption<O, V>[]> = signal([]);
  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;

  /**
   * Dynamic width of the autocomplete dropdown panel.
   * Set to match the full width of the Material form field container (including icons/padding).
   */
  panelWidth: string;

  /**
   * Maximum number of options to display in the dropdown.
   * If more options match the current filter, a hint is shown to type to narrow results.
   * Set to 0 for no limit. Defaults to 100.
   */
  @Input() maxOptionsToDisplay: number = 100;
  hasMoreOptions = false;

  get displayText() {
    const values: V[] = Array.isArray(this.value) ? this.value : [this.value];

    return values
      .map(
        (v) =>
          this._options.find((o) => this.compareEnumValues(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;

  /**
   * Height of the virtual scroll viewport, capped to fit within the panel.
   * Material's default panel max-height is 256px. We reserve space for footer elements
   * ("type to filter" hint, "show inactive" toggle, padding) and cap the viewport accordingly
   * so that virtual scrolling actually virtualizes (instead of rendering all items).
   */
  private static readonly PANEL_MAX_HEIGHT = 256;
  private static readonly FOOTER_RESERVE = 56;
  viewportHeight = computed(() => {
    const contentHeight = (this.autocompleteOptions()?.length ?? 0) * 48;
    const availableHeight =
      BasicAutocompleteComponent.PANEL_MAX_HEIGHT -
      BasicAutocompleteComponent.FOOTER_RESERVE;
    return Math.min(contentHeight, availableHeight);
  });

  ngOnInit() {
    this.autocompleteSuggestedOptions.subscribe((options) => {
      this.autocompleteOptions.set(options);
      setTimeout(() => {
        this.virtualScrollViewport.checkViewportSize();
      });
    });
    // Subscribe to the valueChanges observable to print the input value
    this.autocompleteForm.valueChanges.subscribe((value) => {
      if (
        typeof value === "string" &&
        (this.display !== "text" || value !== this.displayText)
      ) {
        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();
      }
    });
  }

  /**
   * Set the width of the dropdown panel programmatically to match the parent form field.
   * (this is not possible with pure CSS)
   *
   * Note: If the field is close to the viewport edge, Angular Material's overlay system may shift the dropdown horizontally
   * to keep it visible, causing minor misalignment. This is expected and ensures accessibility.
   */
  public updatePanelWidth() {
    // Use closest .mat-mdc-form-field or .mat-form-field from input element
    const fieldEl = this.inputElement?._elementRef?.nativeElement.closest(
      ".mat-mdc-form-field, .mat-form-field",
    ) as HTMLElement;
    const fieldWidth = fieldEl ? fieldEl.getBoundingClientRect().width : 200;
    this.panelWidth = `${fieldWidth}px`;
  }

  drop(event: CdkDragDrop<any[]>) {
    if (event.previousContainer === event.container) {
      const reordered = [...this.autocompleteOptions()];
      moveItemInArray(reordered, event.previousIndex, event.currentIndex);
      this.autocompleteOptions.set(reordered);
    }
    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 if (this.multi && this.display === "text" && this.displayText) {
      // keep selected items visible when the multi-select input is focused/opened
      this.autocompleteForm.setValue(this.displayText);
    } 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 });
    }

    // Update panel width when autocomplete is actually shown (when form field is rendered)
    this.updatePanelWidth();

    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>[] {
    const filterText =
      this.multi && this.display === "text" && inputText === this.displayText
        ? ""
        : inputText;

    let filteredOptions = this._options.filter(
      (o) => !this.hideOption(o.initial) && !o.isHidden,
    );
    if (filterText) {
      this.autocompleteFilterFunction = (option) =>
        this.optionToString(option)
          ?.toLowerCase()
          ?.includes(filterText.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() === filterText?.toLowerCase(),
      );
    }

    if (
      this.maxOptionsToDisplay > 0 &&
      filteredOptions.length > this.maxOptionsToDisplay
    ) {
      this.hasMoreOptions = true;
      filteredOptions = filteredOptions.slice(0, this.maxOptionsToDisplay);
    } else {
      this.hasMoreOptions = false;
    }

    return filteredOptions;
  }

  /**
   * Compare two enum values by id if present, otherwise by reference.
   */
  compareEnumValues(a: any, b: any): boolean {
    if (a === b) return true;
    if (!a || !b) return false;
    if (a.id !== undefined && b.id !== undefined) {
      return a.id === b.id;
    }
    if (a.value !== undefined && b.value !== undefined) {
      return a.value === b.value;
    }
    return false;
  }

  private setInitialInputValue() {
    this._options.forEach(
      (o) =>
        (o.selected = Array.isArray(this.value)
          ? this.value?.some((v) => this.compareEnumValues(v, o.asValue))
          : this.compareEnumValues(this.value, o.asValue)),
    );
    this._selectedOptions = this._options.filter(
      (o) => o.selected && !o.isHidden,
    );
  }

  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,
      isHidden: (opt as SelectableOption<O, V>)?.isHidden ?? false,
      isInvalid: (opt as SelectableOption<O, V>)?.isInvalid ?? false,
      isEmpty: (opt as SelectableOption<O, V>)?.isEmpty ?? 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);
      this.retainSearchValue = "";
    }
  }
  override onContainerClick(event: MouseEvent) {
    const target = event.target;
    const clickedOption =
      target instanceof Element
        ? target.closest(".mat-mdc-option, .mat-option")
        : null;
    if (!this._disabled && !clickedOption) {
      this.showAutocomplete();
    }
  }

  override writeValue(val: V[] | V, notifyFormControl = false): void {
    super.writeValue(val, notifyFormControl);
    this.setInitialInputValue();
  }
}
<!--Display-->
@if (display === "text" || display === "none") {
  <input
    [id]="id"
    [hidden]="isInSearchMode() || display === 'none'"
    [disabled]="_disabled"
    matInput
    style="text-overflow: ellipsis; width: calc(100% - 50px)"
    (focusin)="showAutocomplete()"
    (focusout)="showAutocomplete()"
    [value]="displayText"
    [placeholder]="placeholder"
  />
} @else {
  <input
    [hidden]="true"
    [disabled]="_disabled"
    matInput
    (focusin)="showAutocomplete()"
    (focusout)="showAutocomplete()"
    [matChipInputFor]="chipList"
  />
  <mat-chip-grid #chipList>
    <ng-container>
      @for (item of _selectedOptions; track item) {
        <mat-chip-row
          [editable]="!_disabled"
          class="chip"
          [style.background-color]="item.asValue?.['color']"
        >
          @if (!templateRef) {
            {{ item.asString }}
          } @else {
            <ng-template
              [ngTemplateOutlet]="templateRef"
              [ngTemplateOutletContext]="{ $implicit: item.initial }"
            ></ng-template>
          }
          @if (!_disabled) {
            <button matChipRemove (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>
}

<!--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"
  [panelWidth]="panelWidth"
>
  <!-- Select All and Clear Buttons on top of options via content projection-->
  <div>
    <ng-content select="[autocompleteHeader]"></ng-content>
  </div>
  <div
    cdkDropList
    (cdkDropListDropped)="drop($event)"
    cdkDropListGroup
    [cdkDropListDisabled]="!reorder"
  >
    <cdk-virtual-scroll-viewport
      [style.height.px]="viewportHeight()"
      [itemSize]="48"
      minBufferPx="200"
      maxBufferPx="500"
    >
      <mat-option
        [value]="item"
        cdkDrag
        *cdkVirtualFor="
          let item of autocompleteOptions();
          trackBy: trackByOptionValueFn
        "
      >
        <div class="flex-row disable-autocomplete-active-color align-center">
          @if (reorder) {
            <div>
              <fa-icon
                icon="grip-vertical"
                size="sm"
                class="drag-handle"
              ></fa-icon>
            </div>
          }
          @if (multi) {
            <mat-checkbox [checked]="item.selected"></mat-checkbox>
          }
          @if (!templateRef) {
            <span
              class="text-truncate"
              [matTooltip]="item.asString"
              matTooltipPosition="above"
              [class.not-defined-label]="item.isEmpty"
              [class.invalid-label]="item.isInvalid"
            >
              {{ item.asString }}
            </span>
          } @else {
            <ng-template
              class="item-option"
              [ngTemplateOutlet]="templateRef"
              [ngTemplateOutletContext]="{ $implicit: item.initial }"
            ></ng-template>
          }
        </div>
      </mat-option>
    </cdk-virtual-scroll-viewport>
  </div>

  <!-- Create new option -->
  @if (createOption && showAddOption && inputElement.value) {
    <mat-option [value]="inputElement.value">
      <em
        i18n="
          Label for adding an option in a dropdown|e.g. Add new My new Option
        "
        >Add new</em
      >
      {{ createOptionDisplay(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">
    @if (hasMoreOptions) {
      <em
        class="more-options-hint"
        i18n="Hint shown when dropdown has more options than displayed"
        >Type to filter more results ...</em
      >
    }
    <ng-content select="[autocompleteFooter]"></ng-content>
  </div>
</mat-autocomplete>

./basic-autocomplete.component.scss

@use "variables/colors";
@use "variables/sizes";

@use "../special-option-labels.scss";

:host {
  overflow: hidden;
}

em {
  color: colors.$primary;
}

.disable-autocomplete-active-color {
  color: black;
}

.autocomplete-footer {
  margin: sizes.$small sizes.$regular;
}

.more-options-hint {
  display: block;
  font-size: 0.85em;
  color: colors.$muted;
  padding: sizes.$small 0;
}

::ng-deep {
  .cdk-virtual-scroll-orientation-vertical .cdk-virtual-scroll-content-wrapper {
    width: 100%;
  }
}

.text-truncate {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.chip {
  border-radius: 4px;
}

// Fix for double scrollbars when field/option labels are long
// ensure all options have consistent 48px height for virtual scroll calculations
::ng-deep .mat-mdc-autocomplete-panel .mat-mdc-option {
  max-height: 48px;

  span {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""