How do I reference an Angular component from typescript when it may be removed by ngIf?

12,637

Solution 1

I reproduced your case in this stackblitz. After setting this.selected = true, Angular has to perform change detection to display the mat-form-field element, and that would normally happen after the current execution cycle. One way to get immediate access to the input element is to trigger change detection in your code, for example with ChangeDetector.detectChanges (see this answer for other techniques):

import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
import { MatInput } from '@angular/material';

@Component({
  ...
})
export class FormFieldPrefixSuffixExample {

  @ViewChild(MatInput) input: MatInput;

  text = "Hello world!"
  selected = false;

  constructor(private changeDetector: ChangeDetectorRef) {
  }

  select(): void {
    this.selected = true;
    this.changeDetector.detectChanges();
    this.input.focus();
  }  
}

Another workaround, suggested by kiranghule27, is to delay the call to this.input.focus() by making it asynchronous:

  select(): void {
    this.selected = true;
    setTimeout(() => {
      this.input.focus();
    }, 0);
  }  

Solution 2

You can use the @ViewChildren decorator to get a QueryList and subscribe to the changes observable to get updates whenever the DOM is actually updated.

import { Component, Input, Output, EventEmitter, ViewChildren } from '@angular/core';
import { MatInput } from '@angular/material';
import { AfterViewInit } from '@angular/core/src/metadata/lifecycle_hooks';

@Component({
  selector: 'app-click-to-input',
  templateUrl: './click-to-input.component.html',
  styleUrls: ['./click-to-input.component.scss']
})
export class ClickToInputComponent implements AfterViewInit {
  @Input() text: string;
  @Output() saved = new EventEmitter<string>();
  @ViewChildren(MatInput) input: QueryList<MatInput>;

  selected = false;

  ngAfterViewInit(): void {
    // Will get called everytime the input gets added/removed.
    this.input.changes.subscribe((list: any) => {
        if (!this.selected || list.first == null)
            return;

        console.log(list.first);
        list.first.focus();
    });
  }

  save(): void {
    this.saved.emit(this.text);
    this.selected = false;
  }

  select(): void {
    this.selected = true;  // ngIf should now add the input to the template
  }
}

Solution 3

Are you sure the selector is matching the same input?

Another way you can do is declare input as a template variable like this

<input matInput #myInput type="text" [(ngModel)]="text" (blur)="save()">

and access it in your component as

@ViewChild('#myInput') input: MatInput

Or may be use setTimeout()

<input matInput id="myInput" type="text" [(ngModel)]="text" (blur)="save()">

and

setTimeout(() => {
  document.getElementById('myInput').focus();
});
Share:
12,637
Seth
Author by

Seth

I code for a living. I enjoy reading and learning new things all the time. I am currently working on a personal project, creating a video game. Many of my questions will probably relate back to my game world, either the story of it or the technical aspects of its creation. I'll post the game here when I'm done...but it may take awhile!

Updated on June 08, 2022

Comments

  • Seth
    Seth almost 2 years

    I have a template which uses MatInput from Angular Material. I am trying to access this component from code using @ViewChild so I can programmatically alter the focused state, but this MatInput is not there when the view initializes - its presence is determined by an *ngIf directive. Basically, when the view loads, there is a p element with some text. If a user clicks that element, it will be replaced with an input where the starting value is the text of the original element. When it loses focus, it is saved and reverts back to a p element. The problem is that when they first click on the text to change it, the input that's created does not have focus - they have to click it again to start editing. I want them to be able to click once and start typing.

    Here's my relevant code.

    Template:

    <mat-form-field *ngIf="selected; else staticText" class="full-width">
      <input matInput type="text" [(ngModel)]="text" (blur)="save()">
    </mat-form-field>
    <ng-template #staticText>
      <p class="selectable" (click)="select()">{{ text }}</p>
    </ng-template>
    

    Typescript:

    import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core';
    import { MatInput } from '@angular/material';
    import { AfterViewInit } from '@angular/core/src/metadata/lifecycle_hooks';
    
    @Component({
      selector: 'app-click-to-input',
      templateUrl: './click-to-input.component.html',
      styleUrls: ['./click-to-input.component.scss']
    })
    export class ClickToInputComponent implements AfterViewInit {
      @Input() text: string;
      @Output() saved = new EventEmitter<string>();
      @ViewChild(MatInput) input: MatInput;
    
      selected = false;
    
      ngAfterViewInit(): void {
        console.log(this.input); // shows undefined - no elements match the ViewChild selector at this point
      }
    
      save(): void {
        this.saved.emit(this.text);
        this.selected = false;
      }
    
      select(): void {
        this.selected = true;  // ngIf should now add the input to the template
        this.input.focus();    // but input is still undefined
      }
    }
    

    From the docs:

    You can use ViewChild to get the first element or the directive matching the selector from the view DOM. If the view DOM changes, and a new child matches the selector, the property will be updated.

    Is *ngIf working too slow, and I'm trying to access this.input too soon before the property is updated? If so, how can I wait until *ngIf is done replacing the DOM and then access the MatInput? Or is there some other way to solve my focusing problem altogether that I'm just not seeing?