File

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

Description

Custom MatFormFieldControl for any select / dropdown field.

Extends

CustomFormControlDirective

Implements

OnInit AfterViewInit

Metadata

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor()

Inputs

createOption
Type : (input: string) => Promise<O>
createOptions
Type : CreateOptionConfig<O>[]
Default value : []
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)

displayFullLengthOptionLabel
Default value : false

Whether dropdown option labels should be shown in full length. Set to false to truncate labels with ellipsis.

hideOption
Type : (option: O) => boolean
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[]
Default value : []

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) => string
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) => V
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 : (o: O) => boolean
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 createFromConfig
createFromConfig(marker: CreateOptionMarker<O>)
Parameters :
Name Type Optional
marker CreateOptionMarker<O> No
Returns : any
Protected createOptionAriaLabel
createOptionAriaLabel(option: CreateOptionConfig<O>, input: string)
Parameters :
Name Type Optional
option CreateOptionConfig<O> No
input string No
Returns : string
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
Protected createOptionLabel
createOptionLabel(option: CreateOptionConfig<O>, input: string)
Parameters :
Name Type Optional
option CreateOptionConfig<O> No
input string No
Returns : string
dropChips
dropChips(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 | CreateOptionMarker)
Parameters :
Name Type Optional
selected string | SelectableOption<O | V> | CreateOptionMarker<O> No
Returns : void
showAutocomplete
showAutocomplete(valueToRevertTo?: string)
Parameters :
Name Type Optional
valueToRevertTo string Yes
Returns : void
Protected toCreateOptionValue
toCreateOptionValue(option: CreateOptionConfig<O>, input: string)
Parameters :
Name Type Optional
option CreateOptionConfig<O> No
input string No
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 : unknown
Default value : signal<SelectableOption<O, V>[]>([])
autocomplete
Type : unknown
Default value : viewChild(MatAutocompleteTrigger)
autocompleteFilterFunction
Type : function
autocompleteForm
Type : unknown
Default value : new FormControl("")
autocompleteOptions
Type : Signal<SelectableOption[]>
Default value : toSignal( this.autocompleteSuggestedOptions, { initialValue: [] as SelectableOption<O, V>[] }, )
autocompleteSuggestedOptions
Type : unknown
Default value : this.autocompleteForm.valueChanges.pipe( filter((val) => typeof val === "string"), map((val) => this.updateAutocomplete(val)), startWith([] as SelectableOption<O, V>[]), )
Protected availableCreateOptions
Type : unknown
Default value : computed<CreateOptionConfig<O>[]>(() => { if (this.createOptions().length > 0) { return this.createOptions(); } if (!this.createOption()) { return []; } return [{ label: "", create: this.createOption() }]; })
effectiveDisplay
Type : unknown
Default value : computed<"text" | "chips" | "none">(() => this.multi() && this.reorder() && this.display() === "text" ? "chips" : this.display(), )

Derived display mode. Reorderable multi-select uses chips without mutating the public input.

hasMoreOptions
Type : unknown
Default value : signal(false)
inputElement
Type : unknown
Default value : viewChild("inputElement", { read: MatInput }) as Signal< (MatInput & { _elementRef: ElementRef<HTMLElement> }) | undefined >
isInSearchMode
Type : WritableSignal<boolean>
Default value : signal(false)

display the search input rather than the selected elements only (when the form field gets focused).

Protected optionsSource
Type : unknown
Default value : computed<O[]>(() => this.options())

The source of options the dropdown displays. Defaults to the options input; subclasses can override this to derive options from an internal computed instead (replacing the former pattern of imperatively assigning this.options).

panelWidth
Type : unknown
Default value : signal("200px")

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 : signal(false)

whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate)

templateRef
Type : unknown
Default value : 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 : unknown
Default value : viewChild(CdkVirtualScrollViewport)
controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
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>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

Accessors

displayText
getdisplayText()
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  computed,
  contentChild,
  DestroyRef,
  effect,
  ElementRef,
  input,
  inject,
  OnInit,
  output,
  signal,
  Signal,
  TemplateRef,
  TrackByFunction,
  untracked,
  viewChild,
  WritableSignal,
} from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { NgTemplateOutlet } from "@angular/common";
import {
  MAT_FORM_FIELD,
  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 { auditTime, filter, map, startWith } from "rxjs/operators";
import { CustomFormControlDirective } from "./custom-form-control.directive";
import {
  MatChipGrid,
  MatChipInput,
  MatChipRemove,
  MatChipRow,
} from "@angular/material/chips";
import { MatTooltip } from "@angular/material/tooltip";
import {
  CdkDragDrop,
  DragDropModule,
  moveItemInArray,
} from "@angular/cdk/drag-drop";
import {
  CdkFixedSizeVirtualScroll,
  CdkVirtualForOf,
  CdkVirtualScrollViewport,
  ViewportRuler,
} from "@angular/cdk/scrolling";
import { EMPTY, fromEvent, merge } from "rxjs";
import { FaDynamicIconComponent } from "../fa-dynamic-icon/fa-dynamic-icon.component";

// re-export FaDynamicIconComponent to avoid forcing users of BasicAutocompleteComponent to import separately
export { FaDynamicIconComponent } from "../fa-dynamic-icon/fa-dynamic-icon.component";

/**
 * Configuration for a single "Add new [label]" entry in the autocomplete dropdown.
 * Pass an array of these via `[createOptions]` to show one create option per entity type.
 */
export interface CreateOptionConfig<O> {
  /** Label shown in the dropdown, e.g. the entity type's human-readable name */
  label: string;
  /** Called when the user selects this option; should open a creation form and return the new entity */
  create: (input: string) => Promise<O>;
}

interface CreateOptionMarker<O> {
  __createOptionConfig: CreateOptionConfig<O>;
  __input: string;
}

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,
  MatCheckboxModule,
  NgTemplateOutlet,
  MatChipInput,
  MatChipGrid,
  MatChipRow,
  FaDynamicIconComponent,
  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"],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    { provide: MatFormFieldControl, useExisting: BasicAutocompleteComponent },
    {
      provide: MAT_FORM_FIELD,
      useFactory: () =>
        inject(MAT_FORM_FIELD, { optional: true, skipSelf: true }),
    },
  ],
  imports: BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS,
})
export class BasicAutocompleteComponent<O, V = O>
  extends CustomFormControlDirective<V | V[]>
  implements OnInit, AfterViewInit
{
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly destroyRef = inject(DestroyRef);
  private readonly viewportRuler = inject(ViewportRuler);

  templateRef = contentChild(TemplateRef);
  // Query the search `<input #inputElement>` by its template-ref name (read as MatInput)
  // rather than by type: a bare `viewChild(MatInput)` would match the first MatInput in
  // the view (the display input inside the `@if`), whereas the old `@ViewChild(MatInput,
  // { static: true })` skipped structural content and resolved the search input.
  // `_elementRef` is protected in `MatInput`, so widen the read type to expose it.
  inputElement = viewChild("inputElement", { read: MatInput }) as Signal<
    (MatInput & { _elementRef: ElementRef<HTMLElement> }) | undefined
  >;
  autocomplete = viewChild(MatAutocompleteTrigger);
  virtualScrollViewport = viewChild(CdkVirtualScrollViewport);

  valueMapper = input<(option: O) => V>(
    (option: O) => option?.["_id"] ?? (option as unknown as V),
  );
  optionToString = input<(option: O) => string>(
    (option: O) => option?.["_label"] ?? option?.toString(),
  );
  /** @deprecated Prefer `createOptions` to support one unified create flow. */
  createOption = input<(input: string) => Promise<O>>();
  createOptions = input<CreateOptionConfig<O>[]>([]);
  hideOption = input<(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;
    }
  }

  protected availableCreateOptions = computed<CreateOptionConfig<O>[]>(() => {
    if (this.createOptions().length > 0) {
      return this.createOptions();
    }
    if (!this.createOption()) {
      return [];
    }

    return [{ label: "", create: this.createOption() }];
  });

  protected createOptionLabel(
    option: CreateOptionConfig<O>,
    input: string,
  ): string {
    return option.label || this.createOptionDisplay(input);
  }

  protected createOptionAriaLabel(
    option: CreateOptionConfig<O>,
    input: string,
  ): string {
    return $localize`:ARIA label for adding an option in a dropdown:Add new ${this.createOptionLabel(
      option,
      input,
    )}`;
  }

  /**
   * Whether the user should be able to select multiple values.
   */
  multi = input<boolean>();

  /**
   * Whether the user can manually drag & drop to reorder the selected items
   */
  reorder = input<boolean>();

  autocompleteForm = new FormControl("");

  /**
   * Keep the inner autocomplete input's enabled state in sync with the control,
   * reacting to the base `enabled` signal instead of overriding the `disabled` setter.
   */
  private readonly _syncAutocompleteFormEnabled = effect(() => {
    this.enabled()
      ? this.autocompleteForm.enable()
      : this.autocompleteForm.disable();
  });
  autocompleteSuggestedOptions = this.autocompleteForm.valueChanges.pipe(
    filter((val) => typeof val === "string"),
    map((val) => this.updateAutocomplete(val)),
    startWith([] as SelectableOption<O, V>[]),
  );
  autocompleteOptions: Signal<SelectableOption<O, V>[]> = toSignal(
    this.autocompleteSuggestedOptions,
    { initialValue: [] as SelectableOption<O, V>[] },
  );
  autocompleteFilterFunction: (option: O) => boolean;
  autocompleteFilterChange = output<(o: O) => boolean>();

  /** whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate) */
  showAddOption = signal(false);

  /**
   * Dynamic width of the autocomplete dropdown panel.
   * Set to match the full width of the Material form field container (including icons/padding).
   */
  panelWidth = signal("200px");

  /**
   * 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.
   */
  maxOptionsToDisplay = input<number>(100);
  hasMoreOptions = signal(false);

  /**
   * Whether dropdown option labels should be shown in full length.
   * Set to false to truncate labels with ellipsis.
   */
  displayFullLengthOptionLabel = input(false);

  // Kept as a getter (not a `computed`): it reads `_options`, a plain mutable array
  // (rebuilt/reordered/pushed imperatively), so a `computed` would cache stale results
  // unless `_options` were made a signal — a larger refactor not worth it here.
  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(", ");
  }

  /**
   * 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)
   */
  options = input<O[]>([]);

  /**
   * The source of options the dropdown displays. Defaults to the `options` input;
   * subclasses can override this to derive options from an internal computed instead
   * (replacing the former pattern of imperatively assigning `this.options`).
   */
  protected optionsSource = computed<O[]>(() => this.options());

  private _options: SelectableOption<O, V>[] = [];

  _selectedOptions = signal<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)
   */
  display = input<"text" | "chips" | "none">("text");

  /**
   * Derived display mode. Reorderable multi-select uses chips without mutating the public input.
   */
  effectiveDisplay = computed<"text" | "chips" | "none">(() =>
    this.multi() && this.reorder() && this.display() === "text"
      ? "chips"
      : this.display(),
  );

  /**
   * 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);
  });

  constructor() {
    super();
    // Rebuild the derived options whenever the `options` input (or the
    // value/label mappers) change. Replaces the former `set options` setter
    // and the `valueMapper`/`optionToString`/`options` `ngOnChanges` branches.
    effect(() => {
      const options = this.optionsSource();
      // read the mapper inputs so the rebuild also reacts to their changes
      this.valueMapper();
      this.optionToString();
      // The block below is `untracked` so the effect only re-runs for the
      // dependencies read above (options + mappers). Without it the effect
      // would also track the signals read inside `setInitialInputValue`
      // (e.g. the control `value`) and re-run — and rebuild the derived
      // options — on every value change, which is both wasteful and would
      // discard runtime-created options.
      untracked(() => {
        this._options = options.map((o) => this.toSelectableOption(o));
        this.setInitialInputValue();
        if (this.autocomplete()?.panelOpen) {
          // if new options have been added, update the visible autocomplete options
          this.showAutocomplete(this.autocompleteForm.value);
        }
      });
    });

    // Re-check the virtual scroll viewport whenever the visible options change
    // (replaces the setTimeout that ran inside the former options subscription).
    effect(() => {
      this.autocompleteOptions();
      setTimeout(() => this.virtualScrollViewport()?.checkViewportSize());
    });
  }

  ngOnInit() {
    // 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;
      }
    });
  }

  ngAfterViewInit() {
    merge(
      fromEvent(window, "focus"),
      fromEvent(window, "resize"),
      this.viewportRuler.change(),
      this.getVisualViewportChangeEvents(),
    )
      .pipe(auditTime(16), takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.updateOpenPanelPosition());
  }

  /**
   * 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.set(`${fieldWidth}px`);
  }

  /**
   * Returns viewport-change events that are not always covered by window resize
   * (e.g. DevTools docking/undocking and some browser UI changes).
   */
  private getVisualViewportChangeEvents() {
    const visualViewport = window.visualViewport;
    if (!visualViewport) {
      return EMPTY;
    }

    return merge(
      fromEvent(visualViewport, "resize"),
      fromEvent(visualViewport, "scroll"),
    );
  }

  /**
   * Recomputes width and position of an open autocomplete panel.
   * Runs twice (immediately and on next animation frame) to handle late layout updates.
   */
  private updateOpenPanelPosition(): void {
    if (!this.autocomplete()?.panelOpen) {
      return;
    }

    this.updatePanelWidth();
    this.autocomplete()?.updatePosition();

    requestAnimationFrame(() => {
      if (!this.autocomplete()?.panelOpen) {
        return;
      }

      this.updatePanelWidth();
      this.autocomplete()?.updatePosition();
    });
  }

  dropChips(event: CdkDragDrop<any[]>) {
    if (event.previousContainer !== event.container) {
      return;
    }

    const selectedOptions = [...this._selectedOptions()];
    moveItemInArray(selectedOptions, event.previousIndex, event.currentIndex);

    this._options = this.orderSelectedFirst(this._options, selectedOptions);

    if (this.multi()) {
      this.value = selectedOptions.map((option) => option.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.effectiveDisplay() === "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(() => {
      const inputEl = this.inputElement();
      inputEl?.focus();

      // select all text for easy overwriting when typing to search for options
      (inputEl?._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.effectiveDisplay() === "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.set(
        !this._options.some(
          (o) => o?.asString?.toLowerCase() === filterText?.toLowerCase(),
        ),
      );
    }

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

    if (this.multi() && this.reorder()) {
      filteredOptions = this.orderSelectedFirst(
        filteredOptions,
        this._selectedOptions(),
      );
    }

    return filteredOptions;
  }

  private orderSelectedFirst(
    options: SelectableOption<O, V>[],
    selected: SelectableOption<O, V>[],
  ): SelectableOption<O, V>[] {
    const selectedSet = new Set(selected);
    return [
      ...selected.filter((option) => options.includes(option)),
      ...options.filter((option) => !selectedSet.has(option)),
    ];
  }

  /**
   * 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.set(
      this._options.filter((o) => o.selected && !o.isHidden),
    );
  }

  select(selected: string | SelectableOption<O, V> | CreateOptionMarker<O>) {
    if (
      selected != null &&
      typeof selected === "object" &&
      "__createOptionConfig" in selected
    ) {
      this.createFromConfig(selected as CreateOptionMarker<O>);
      return;
    }

    if (typeof selected === "string") {
      const defaultCreateOption = this.availableCreateOptions()[0];
      if (defaultCreateOption) {
        this.createFromConfig(
          this.toCreateOptionValue(defaultCreateOption, selected),
        );
      }
      return;
    }

    if (selected) {
      this.selectOption(selected as SelectableOption<O, V>);
    } else {
      this.autocompleteForm.setValue("");
      this._selectedOptions.set([]);
      this.value = undefined;
    }
    this.onChange(this.value);
  }

  unselect(option: SelectableOption<O, V>) {
    option.selected = false;
    this._selectedOptions.set(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);
  }

  /** @internal used in template to build a marker value for typed create options */
  protected toCreateOptionValue(
    option: CreateOptionConfig<O>,
    input: string,
  ): CreateOptionMarker<O> {
    return { __createOptionConfig: option, __input: input };
  }

  async createFromConfig(marker: CreateOptionMarker<O>) {
    const createdOption = await marker.__createOptionConfig.create(
      marker.__input,
    );
    if (createdOption) {
      const newOption = this.toSelectableOption(createdOption);
      this._options.push(newOption);
      this.select(newOption);
    } else {
      this.showAutocomplete();
      this.autocompleteForm.setValue(marker.__input);
    }
  }

  private selectOption(option: SelectableOption<O, V>) {
    if (this.multi()) {
      option.selected = !option.selected;
      this._selectedOptions.set(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.set([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();
    this.cdr.markForCheck();
  }
}
<div matAutocompleteOrigin #autocompleteOrigin="matAutocompleteOrigin">
  <!--Display-->
  @if (effectiveDisplay() === "text" || effectiveDisplay() === "none") {
    <input
      [id]="id"
      [hidden]="isInSearchMode() || effectiveDisplay() === 'none'"
      [disabled]="!enabled()"
      matInput
      style="text-overflow: ellipsis; width: calc(100% - 50px)"
      (focusin)="showAutocomplete()"
      (focusout)="showAutocomplete()"
      [value]="displayText"
      [placeholder]="placeholder"
    />
  } @else {
    <input
      [hidden]="true"
      [disabled]="!enabled()"
      matInput
      (focusin)="showAutocomplete()"
      (focusout)="showAutocomplete()"
      [matChipInputFor]="chipList"
    />
    <mat-chip-grid
      #chipList
      cdkDropList
      cdkDropListOrientation="mixed"
      [cdkDropListDisabled]="!reorder()"
      (cdkDropListDropped)="dropChips($event)"
      class="chip-drop-list"
    >
      @for (item of _selectedOptions(); track item.asValue) {
        <mat-chip-row
          cdkDrag
          [cdkDragDisabled]="!reorder()"
          [editable]="enabled()"
          class="chip"
          [style.background-color]="item.asValue?.['color']"
        >
          <ng-template
            [ngTemplateOutlet]="chipContent"
            [ngTemplateOutletContext]="{ $implicit: item }"
          ></ng-template>

          @if (enabled()) {
            <button matChipRemove (click)="unselect(item)">
              <app-fa-dynamic-icon
                i18n-matTooltip="
                  tooltip for remove icon on chips of dropdown item
                "
                matTooltip="remove"
                icon="xmark"
              ></app-fa-dynamic-icon>
            </button>
          }

          <ng-template cdkDragPreview>
            <mat-chip-row
              [editable]="false"
              class="chip chip-preview"
              [style.background-color]="item.asValue?.['color']"
            >
              <ng-template
                [ngTemplateOutlet]="chipContent"
                [ngTemplateOutletContext]="{ $implicit: item }"
              ></ng-template>
            </mat-chip-row>
          </ng-template>
        </mat-chip-row>
      }
    </mat-chip-grid>
  }

  <!--Search-->
  <input
    [hidden]="!isInSearchMode()"
    #inputElement
    [formControl]="autocompleteForm"
    matInput
    style="text-overflow: ellipsis"
    [matAutocomplete]="autoSuggestions"
    [matAutocompleteConnectedTo]="autocompleteOrigin"
    (focusout)="onFocusOut($event)"
    [placeholder]="placeholder"
  />
</div>

<!--
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>
    @if (!displayFullLengthOptionLabel()) {
      <cdk-virtual-scroll-viewport
        [style.height.px]="viewportHeight()"
        [itemSize]="48"
        minBufferPx="200"
        maxBufferPx="500"
      >
        <mat-option
          [value]="item"
          *cdkVirtualFor="
            let item of autocompleteOptions();
            trackBy: trackByOptionValueFn
          "
        >
          <ng-container
            [ngTemplateOutlet]="optionContent"
            [ngTemplateOutletContext]="{ $implicit: item, truncate: true }"
          ></ng-container>
        </mat-option>
      </cdk-virtual-scroll-viewport>
    } @else {
      @for (item of autocompleteOptions(); track item.asValue) {
        <mat-option [value]="item" class="option-with-wrap">
          <ng-container
            [ngTemplateOutlet]="optionContent"
            [ngTemplateOutletContext]="{ $implicit: item, truncate: false }"
          ></ng-container>
        </mat-option>
      }
    }
  </div>

  <ng-template #chipContent let-item>
    @if (reorder()) {
      <span class="drag-handle color-accent" cdkDragHandle>
        <app-fa-dynamic-icon icon="grip-vertical"></app-fa-dynamic-icon>
      </span>
    }

    @if (!templateRef()) {
      {{ item.asString }}
    } @else {
      <ng-template
        [ngTemplateOutlet]="templateRef()"
        [ngTemplateOutletContext]="{ $implicit: item.initial }"
      ></ng-template>
    }
  </ng-template>

  <ng-template #optionContent let-item let-truncate="truncate">
    <div
      class="option-content flex-row disable-autocomplete-active-color align-center"
    >
      @if (multi()) {
        <mat-checkbox [checked]="item.selected"></mat-checkbox>
      }
      @if (!templateRef()) {
        <span
          class="option-label"
          [class.text-truncate]="truncate"
          [class.text-wrap]="!truncate"
          [matTooltip]="item.asString"
          matTooltipPosition="above"
          [class.not-defined-label]="item.isEmpty"
          [class.invalid-label]="item.isInvalid"
        >
          {{ item.asString }}
        </span>
      } @else {
        <div class="option-label item-option">
          <ng-template
            [ngTemplateOutlet]="templateRef()"
            [ngTemplateOutletContext]="{ $implicit: item.initial }"
          ></ng-template>
        </div>
      }
    </div>
  </ng-template>

  <!-- Create new option -->
  @if (showAddOption() && inputElement.value) {
    @for (option of availableCreateOptions(); track $index) {
      <mat-option
        [value]="toCreateOptionValue(option, inputElement.value)"
        [attr.aria-label]="createOptionAriaLabel(option, inputElement.value)"
      >
        <em
          i18n="
            Label for adding an option in a dropdown|e.g. Add new My new Option
          "
          >Add new</em
        >
        {{ createOptionLabel(option, 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%;
  }

  .cdk-drag-preview {
    box-sizing: border-box;
    border-radius: 4px;
  }

  .cdk-drag-placeholder {
    opacity: 0;
  }

  .cdk-drag-animating {
    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
  }
}

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

.option-content {
  width: 100%;
  min-width: 0;
  justify-content: flex-start;
}

.option-label {
  display: block;
  flex: 1 1 auto;
  min-width: 0;
}

.text-wrap {
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
  line-height: 1.3;
}

.chip {
  border-radius: 4px;
}

.chip-drop-list.cdk-drop-list-dragging .chip:not(.cdk-drag-placeholder) {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

.chip-preview {
  box-shadow:
    0 5px 5px -3px rgba(0, 0, 0, 0.2),
    0 8px 10px 1px rgba(0, 0, 0, 0.14),
    0 3px 14px 2px rgba(0, 0, 0, 0.12);
  cursor: grabbing;
}

.drag-handle {
  display: inline-flex;
  align-items: center;
  margin-right: 8px;
  cursor: move;
  opacity: 0.5;
}

.drag-handle:hover {
  opacity: 1;
}

// 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 {
  .mdc-list-item__primary-text {
    text-align: left;
  }

  app-display-configurable-enum {
    display: block;
    flex: 1 1 auto;
    min-width: 0;
  }

  app-display-configurable-enum .bubble-list {
    width: 100%;
    min-width: 0;
  }

  app-display-configurable-enum .colored-bubble {
    display: block;
    max-width: 100%;
  }

  &.option-with-wrap app-display-configurable-enum .colored-bubble {
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    overflow-wrap: break-word;
  }
}

::ng-deep .mat-mdc-autocomplete-panel .mat-mdc-option:not(.option-with-wrap) {
  max-height: 48px;

  .mdc-list-item__primary-text {
    display: block;
    width: 100%;
    overflow: visible;
  }
}

::ng-deep .mat-mdc-autocomplete-panel .mat-mdc-option.option-with-wrap {
  height: auto;
  min-height: 48px;
  max-height: none;

  .mdc-list-item__primary-text {
    white-space: normal;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""