7

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?

Lucas Weyne
  • 1,107
  • 7
  • 17
Seth
  • 342
  • 1
  • 4
  • 14

4 Answers4

4

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);
  }  
ConnorsFan
  • 70,558
  • 13
  • 122
  • 146
1

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();
});
kiranghule27
  • 439
  • 1
  • 7
  • 20
  • Yes the selector is working. When I debug in Chrome, and it tries to access `focus()` of `undefined` (input), it gives an Error Context for debugging, and includes the `ClickToInputComponent` object which has the `input` property populated with the correct `MatInput`. Only after the error is thrown is the `input` property populated, though. – Seth Jan 03 '18 at 19:26
1

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
  }
}
smoyer
  • 7,932
  • 3
  • 17
  • 26
0

Update on ViewChild - Due to changes on ViewChild

@ViewChildren(MatInput) input: QueryList<MatInput>;

wouldn't be enough anymore. Now, you have to use:

@ViewChildren(MatInput, {static: false}) input: QueryList<MatInput>;

Check out https://angular.io/api/core/ViewChild for more information.

Etoon
  • 306
  • 3
  • 10