ForestG
ForestG

Reputation: 18123

Angular Autocomplete Input selected and blur event race condition

If you have an Angular Material MatAutoInput, and you listen to the option selection event and also listen to the blur event, you can see that if you select an item in the select list, the input blur event will get called as well.

This is quite problematic, as I need to listen to the blur event in the input field for validation, and also watch the select. If the user clicks on an element, the blur causes to call my valuation logic on the field, which can mess things up.

<form class="example-form">
  <mat-form-field class="example-full-width">
    <input type="text"
           placeholder="Pick one"
           aria-label="Number"
           matInput
           (blur)="onBlur()"
           [formControl]="myControl"
           [matAutocomplete]="auto">
    <mat-autocomplete #auto="matAutocomplete" (optionSelected)="optionSelected()">
      <mat-option *ngFor="let option of options" [value]="option">
        {{option}}
      </mat-option>
    </mat-autocomplete>
  </mat-form-field>
</form>

@Component({
  selector: 'autocomplete-simple-example',
  templateUrl: 'autocomplete-simple-example.html',
  styleUrls: ['autocomplete-simple-example.css'],
})
export class AutocompleteSimpleExample {
  myControl = new FormControl();
  options: string[] = ['One', 'Two', 'Three'];

  optionSelected(){
    console.log("option selected event, value: ", this.myControl.value);

  }

  onBlur(){
    console.log("Blur event, value:",  this.myControl.value);
  }
}

Live demo: https://stackblitz.com/edit/angular-pp3ztn?file=src%2Fapp%2Fautocomplete-simple-example.ts

Is there any way to go around this issue? Or can I be absolute sure about this order of events happening? If for any reason the event would happen in a different order (first the select, then the blur) it can mess things up in my deeper input logic.

My closes solution was to try to disable the blur event checking upon the click on the field, and only enabling the blur detection upon selecting an option OR blur event on the option list itself... which seemed quite hacky. Also, found a same-ish thread: https://github.com/angular/material/issues/3976 which sadly did not give any real solutions.

Upvotes: 13

Views: 18596

Answers (5)

fixAR496
fixAR496

Reputation: 31

Sorry for my english right away ;) I faced a similar problem and used the following solution to get the desired functionality

HTML template:

<mat-form-field class="mat-input-styles-white-theme-settings disabled-hint">
   <mat-label>
       <div class="label-content">
           <span>TempLabel</span>
       </div>
   </mat-label>
   <input matInput type="name" [formControlName]="'PersonSearch'" [autocomplete]="'off'" [matAutocomplete]="personSearchAutocomplete">
   <mat-autocomplete #personSearchAutocomplete 
       [displayWith]="displayedSearchFn.bind(this)"
       (optionSelected)="onSelectSearchValue($event)">
       @for (elem of searchUserDataTrigger$ | async; track elem) {
       <mat-option [value]="elem.Id">
            <span>{{elem.FirstName +
                  ' ' + elem.LastName}}
            </span>
       </mat-option>
       }
   </mat-autocomplete>
</mat-form-field>

Next, we create an observable that will interact with the search field:

private searchUserData$!: Observable<ISearchUserResponse | string>
 public searchUserDataTrigger$: Subject<Array<ISearchUserItem>> = new Subject<Array<ISearchUserItem>>()
public allowedUsersValues: Array<ISearchUserItem> = []
public fg: FormGroup = new FormGroup({
PersonSearch: new FormControl("", [EqArrValidator(this.allowedUsersValues.map(el => el.Id)), Validators.required])})

ngOnInit(): void {
    this.searchUserData$ = this.fg.get("PersonSearch")!.valueChanges
.pipe(
    map(el => {
      //We find logins by unique user ID
      let userId = Number(el)
      let value = this.allowedUsersValues.filter(el => el.Id == userId)

      if (value.length > 0)
        return value
      return el
    }),
distinctUntilChanged(),
    debounceTime(200),
    exhaustMap(el => {
      let passContent$ = from(["isUserSelectedValue"])
      let getterData$ = this._usersService.searchUserByStr(el).pipe(catchError((err) => of()))

      return iif(() => typeof (el) == "string", getterData$, passContent$.pipe(map(el => {
        let selectedEl = this.allowedUsersValues.filter(el2 => el2.Id == this.selectedSearchUserId)

        if (selectedEl.length > 0) {
          /**
           * We sift out the event generated by mat-option and carry out further processing of the arrays.
           * 
           * ...
           * Your code
           * ...
           */
           return
        }
        return el
      })))
    }))

this.searchUserData$.pipe(takeUntil(this.unsubscribe$)).subscribe({
    next: res => {
      // We find out what type is emitted ("isUserSelectedValue" | object). If necessary, you can add a strict comparison, which you specify in the pipeline .pipe()
      if (typeof (res) == "string") {
        return
      }

      this.allowedUsersValues = [...res.data]
      this.fg.get("PersonSearch")?.setValidators([
        EqArrValidator(this.allowedUsersValues.map(el => el.Id)), Validators.required
      ])
      this.searchUserDataTrigger$.next(this.allowedUsersValues)
      this.fg.updateValueAndValidity()
    },

    complete: () => { }
  })
    }
  1. In this case, you listen to all values ​​from the input field using valueChanges.

  2. Next, select the value you need from among those sent by the Backend. If the value is found - return an array containing the value selected by the user.

  3. The key logic is the use of the iif() operator. You form two observables:

    1 - The one that will interact directly with the entered value and on the basis of this, all further processing will be performed;

    2 - The one that will signal that the user has selected a value from the list.

The displayedSearchFn function is responsible for the value that will be displayed to the user (ex. FirstName) The onSelectSearchValue function stores the last element selected by the user (selectedSearchUserId)

It is also possible to refine the pipeline using the filter() operator, but I chose the above path for my needs

This is my first time commenting on stackoverflow, so don't judge harshly ;)

Upvotes: 1

Tomek
Tomek

Reputation: 568

You could also leverage the MatAutocompleteTrigger and its panelClosingActions (docs)

@ViewChild('autocompleteInput', { read: MatAutocompleteTrigger }) triggerAutocompleteInput: MatAutocompleteTrigger;

ngAfterViewInit() {
    this.triggerAutocompleteInput.panelClosingActions
      .pipe(tap(console.log))
      .subscribe()
  }

<mat-form-field>
  <input
    #autocompleteInput
    //...
  >
</mat-form-field>

Upvotes: 0

Tombalabomba
Tombalabomba

Reputation: 480

You can use mousedown and event.preventDefault() on the item that should not trigger a blur-event when clicked.

<mat-autocomplete #auto="matAutocomplete" (optionSelected)="optionSelected()">
   <mat-option *ngFor="let option of options" (mousedown)=$event.preventDefault()   
         [value]="option"> 
     {{option}}
   </mat-option>
</mat-autocomplete>

Upvotes: 10

knsheely
knsheely

Reputation: 623

In my case, I needed to clear the input on blur and set a separate value when an option was selected. Since the blur always seems to happen before the optionSelected event, any actions placed inside the (blur) listener will stop the optionSelected event from happening at all with no way to discern a click outside of the input box vs selecting an option. The only way I've seen to get around it is to place your (blur) actions inside of a setTimeout with a time > 0.

<input matInput (blur)="onBlur()"...
<mat-autocomplete (optionSelected)="onSelect($event)"...

and in your component:

onBlur() {
    setTimeout(() => {
        //doStuffOnBlur()
    }, 200);
}

onSelect(event: MatAutocompleteSelectedEvent) {
    //doStuffWithSelectedOption()
}

Upvotes: 5

ForestG
ForestG

Reputation: 18123

I have found a not-so-hacky soltuion:

Simply put this check in the (blur) event handling:

  @ViewChild('autocomplete') autocomplete: MatAutocomplete;

  ...
  onBlur(){  if(autocomplete.isOpen){....}   }

This way we can discriminate the blur event handling while the user is clicking on the option list, and ensure we run/do not run validation logic on blur.

Upvotes: 7

Related Questions