angulartypescriptangular-materialangular-reactive-formsmat-autocomplete

Making mat-autocomplete function like a Bootstrap select


I am working on an application in Angular where I was primarily using <ng-select> for searchable dropdowns and it worked like a charm. Now I am migrating the entire application to Angular Material and I am facing an issue trying to make Angular Material mat-autocomplete work just like an <ng-select>.

Earlier, when using <ng-select> when editing existing data I just had to do a patchValue and set the option value into the controller for its corresponding label to appear in its corresponding <ng-select>. And this is the default behavior of select. When migrating to <mat-autocomplete> it is not working like this. I understand that I can use <mat-select> instead but the search functionality is the key for me.

I have gone through the Angular Material documentation and by using the displayWith property I can make <mat-autocomplete> work. Still, I have to patch the entire option object into the form control and it is not what I want as my backend is not developed to handle it that way.

To give you an idea of what I am talking about, below is a sample code of the scenario:

HTML:

<form [formGroup]="fruitFormBootstrap">
  <div class="row">
    <div class="col-4">
      <label for="fruits" class="form-label">Fruits</label>
      <select id="fruits" class="form-select" formControlName="fruit">
        @for (fruit of fruits; track $index) {
          <option [value]="fruit.value">{{ fruit.label }}</option>
        }
      </select>
    </div>
  </div>
  <div>
    <button class="btn btn-primary" (click)="submitBootstrapForm()">Submit</button>
  </div>
</form>

<hr>

<form [formGroup]="fruitFormMaterial">
  <mat-form-field class="example-full-width">
    <mat-label>Fruits</mat-label>
    <input type="text"
           placeholder="Pick one"
           aria-label="Fruits"
           matInput
           formControlName="fruit"
           [matAutocomplete]="auto">
    <mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
      @for (fruit of fruits; track fruit.value) {
        <mat-option [value]="fruit">{{fruit.label}}</mat-option>
      }
    </mat-autocomplete>
  </mat-form-field>
  <button mat-raised-button (click)="submitAngularMaterialForm()">Submit</button>
</form>

TypeScript:

import {Component, OnInit} from '@angular/core';
import {MatInputModule} from "@angular/material/input";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatButtonModule} from "@angular/material/button";
import {MatListModule} from "@angular/material/list";
import {MatSelectModule} from "@angular/material/select";

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [
    MatFormFieldModule,
    MatInputModule,
    MatAutocompleteModule,
    MatSelectModule,
    ReactiveFormsModule,
    MatButtonModule,
    MatListModule
  ],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent implements OnInit {
  fruits: Array<{ value: number, label: string }>;
  fruitFormBootstrap: FormGroup;
  fruitFormMaterial: FormGroup;

  constructor(private fb: FormBuilder) {
    this.fruits = [
      { value: 1, label: 'Watermelon' },
      { value: 2, label: 'Apple' },
      { value: 3, label: 'Banana' }
    ];

    this.fruitFormBootstrap = this.fb.group({
      fruit: new FormControl()
    });

    this.fruitFormMaterial = this.fb.group({
      fruit: new FormControl()
    });
  }

  // Patching a value in bootstrap automatically selects its corresponding label for display
  ngOnInit() {
    this.fruitFormBootstrap.patchValue({
      fruit: 2
    });

    //angular material autocompelte patching only updates the controller without updating the display to show its corresponding label

    // this.fruitFormMaterial.patchValue({
    //   fruit: 2
    // });

    const searchFruit = this.fruits.find(fruit => fruit.value === 2);
    //although this works but it sets the entire object as form control value which is not what I want
    this.fruitFormMaterial.patchValue({
      fruit: searchFruit
    })
  }

  //submitting a select in bootstrap set the form control with the value of the selected option
  submitBootstrapForm() {
    console.log(this.fruitFormBootstrap);
  }

  //Based on angular material documentation this is how to have a separate display value than the control
  displayFn(option: { value: number, label: string }) {
    return option ? option.label : '';
  }

  submitAngularMaterialForm() {
    console.log(this.fruitFormMaterial);
  }
}

In the above code, you can see that for the Bootstrap select. I just have to patch the value of the option and the select automatically selects its corresponding label and displays it. But that is not the case for <mat-autocompelete>. I have to set the entire object for it to work properly. The same thing happens if I select an option and click the submit button. Bootstrap select is setting the control as the value but the <mat-autocomplete> is setting it as the entire selected fruit object (which I guess is expected since that is what has been provided to the mat-option [value] property binding).

I have played around a bit with the mat-option [value] property binding and displayFn implementation but nothing is working. When I use fruit.value for the [value] property binding the form control is properly updated with the value that is selected instead of the entire selected object but then the label is not displayed.

NOTE: Just a note that I can have multiple <mat-autocomplete>(s) in the same form all referring to different data (but the options structure is the same for all)


EDIT:

Thanks to Yong Shun for the answer and it works! But I guess I should have provided more details regarding the challenge I am having with multiple <mat-autocomplete>(s) in the same form. Apologies. So, I have multiple <mat-autocomplete>(s) in a dynamically generated form from a JSON returned by the backend and the form looks like the below:

  <form [formGroup]="dataForm">
    <div formArrayName="forms">
      @for (form of getForms(); track $index) {
        <div [formGroupName]="$index">
          @for (field of fieldsArray; track $index) {
            @if (field.display) {
              @switch (field.type) {
                @case ('input') {
                  @switch (field.inputType) {
                    @case ('text') {
                      <mat-form-field appearance="outline" class="w-50 pb-2 pe-2">
                        <mat-label>{{ field.displayName }}</mat-label>
                        <input type="text" matInput [formControlName]="field.controllerName">
                      </mat-form-field>
                    }
                    @case ('number') {
                      <mat-form-field appearance="outline" class="w-50 pb-2">
                        <mat-label>{{ field.displayName }}</mat-label>
                        <input type="number" matInput [formControlName]="field.controllerName">
                      </mat-form-field>
                    }
                    @case ('date') {
                      <mat-form-field appearance="outline" class="w-50 pb-2 pe-2">
                        <mat-label>{{ field.displayName }}</mat-label>
                        <input matInput [matDatepicker]="picker" [formControlName]="field.controllerName">
                        <mat-hint>MM/DD/YYYY</mat-hint>
                        <mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
                        <mat-datepicker #picker></mat-datepicker>
                      </mat-form-field>
                    }
                    @case ('checkbox') {
                      <mat-checkbox [formControlName]="field.controllerName">{{ field.displayName }}</mat-checkbox>
                    }
                  }
                }
                @case ('textarea') {
                  <mat-form-field appearance="outline" class="w-100 pb-2 pe-2">
                    <mat-label>{{ field.displayName }}</mat-label>
                    <textarea matInput [formControlName]="field.controllerName"></textarea>
                  </mat-form-field>
                }
                @case ('select') {
                  <mat-form-field class="w-50 pb-2 pe-2" appearance="outline">
                    <mat-label>{{ field.displayName }}</mat-label>
                    <input type="text"
                           placeholder="Pick one"
                           matInput
                           [formControlName]="field.controllerName"
                           [matAutocomplete]="auto">
                    <mat-autocomplete requireSelection #auto="matAutocomplete" [displayWith]="displayFn">
                      @for (option of filteredOptions.get(field.controllerName)! | async; track option) {
                        <mat-option [value]="option">{{ option.label }}</mat-option>
                      }
                    </mat-autocomplete>
                  </mat-form-field>
                }
              }
            }
          }
        </div>
      }
    </div>
  </form>

So although the displayFn implementation provided in the answer by Yong Shun is correct but I was having trouble figuring out how I can pass the formControlName for which the option was changed so that I can perform the find operation after getting the list of options values value from the filteredOptions map with the formControlName:

filteredOptions: Map<string, Observable<Option[]>>;

Solution

  • You can modify the displayFn to display the selected option's label by searching based on the value from the fruits array. Note that, you need to change it to an arrow function to access the component variable.

    displayFn = (value: string) => {
      if (Number.isNaN(value)) return value;
    
      return (
        this.fruits?.find((fruit) => fruit.value === Number(value))?.label ||
        value
      );
    };
    

    And in your view, pass the fruit.value instead of the fruit object to the [value] attribute. So that you can patch the form control value with fruit.value instead of the fruit object.

    <mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
      @for (fruit of fruits; track fruit.value) {
        <mat-option [value]="fruit.value">{{ fruit.label }}</mat-option>
      }
    </mat-autocomplete>
    

    For passing the options from the view:

    displayFn(options: any[]): (value: any) => string {
      return (value: any) => {
        if (Number.isNaN(value)) return value;
    
        return (
          options.find((option) => option.value === Number(value))?.label || value
        );
      };
    }
    
    <mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn(fruits)">
      @for (fruit of fruits; track fruit.value) {
        <mat-option [value]="fruit.value">{{ fruit.label }}</mat-option>
      }
    </mat-autocomplete>
    

    Demo @ StackBlitz