Validação de componente customizado

Veja como criar um input customizado para senha e como deixá-lo compatível com reactive forms e template-driven, bem como com todos os validadores de formulário integrados e personalizados.

Validação de componente customizado

O Angular nos provê uma série de mecanismos para ajudar na validação de formulários, tarefa que no mundo Front End é algo bem rotineiro. Inclusive já escrevi um pouco sobre isto neste artigo.

Quando comecei a estudar essa parte de validação, fiz meu primeiro formulário reativo conforme o exemplo abaixo e na sequência construí um componente e tentei vinculá-lo a um FormControl através do formControlName. O resultado foi a seguinte mensagem:

ERROR
Error: No value accessor for form control with name: '<nome do formControl>'

É justamente sobre isto que vamos falar neste artigo!

Considere o formulário abaixo e suas validações código abaixo que nada mais é do que um formulário com validação abaixo e veja o resultado na imagem logo a seguir:

<form [formGroup]="formLogin"
      (ngSubmit)="processarLogin()">

  <div>
    <label>
      Login:
      <input type="email"
             formControlName="login" />
    </label>
  </div>
  <div *ngIf="formLogin.controls.login.touched &&
              formLogin.controls.login.invalid">
    Informe um e-mail válido
  </div>

  <div>
    <label>
      Senha:
      <input type="password"
             formControlName="senha" />
    </label>
  </div>
  <div *ngIf="formLogin.controls.senha.touched &&
              formLogin.controls.senha.invalid">
    Informe uma senha
  </div>

  <input type="submit"
        [disabled]="formLogin.invalid" />
</form> 
app.component.html
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html'
})
export class AppComponent {
  
  formLogin: FormGroup;
  
  constructor(formBuilder: FormBuilder) {
    this.formLogin = formBuilder.group({
      login: ['', [Validators.required, Validators.email]],
      senha: ['', Validators.required],
    });
  }

  processarLogin() {
    if (this.formLogin.invalid) {
      return;
    }
    
    const valoresFormulario = this.formLogin.value;
    console.log('Valores: ', valoresFormulario);
  }
}
app.component.ts
Animação mostrando a validação de formulário com o código acima
Resultado do código acima

Até este ponto não temos nenhuma novidade. Estamos validando dois <input> e pegando os respectivos valores no submit do formulário.

Criar um componente customizado de password

Para chegarmos no nosso problema, vamos criar um componente para o usuário informar sua senha, um componente sem inputs, por exemplo:

import { Component } from '@angular/core';

@Component({
  selector: 'app-senha',
  styleUrls: ['./senha.component.css'],
  template: `
      <div class="valor">
        <div>Senha:</div>
        <div>{{senha}}</div>
      </div>
      <div class="opcoes">
        <div *ngFor="let opcao of opcoes"
             (click)="atualizarSenha(opcao)"
             class="opcao">
          {{opcao}}
        </div>
        <div (click)="limpar()">
          Limpar
        </div>
      </div>
  `
})
export class SenhaComponent {
  opcoes: Array<string> = [
    '1', '2', '3', '4', '5', '6', '7', '8', '9'
  ];

  senha: string = '';

  atualizarSenha(opcao: string) {
    this.senha += opcao;
  }

  limpar() {
    this.senha = '';
  }
}
senha.component.ts
Renderização do componente app-senha
Renderização do componente <app-senha>

A ideia do componente acima é que o usuário vá clicando nos números e conforme ele for clicando o valor da senha que está se formando aparecerá na frente do label Senha.

Agora que o componente está pronto, vamos substituir o:

<input type="password"
       formControlName="senha" />

por:

<!-- sem o formControlName por enquanto -->
<app-senha></app-senha>

Como resultado, nosso formLogin nunca terá um valor válido (formLogin.valid === true) . Veja que temos dois FormControl no formLogin, login e senha, porém retiramos o formControlName="senha" durante a substitução acima. Veja no resultado abaixo que o botão Submit nunca é habilitado:

Animação mostrando que o app-senha ainda não está integrado com o formLogin
app-senha ainda não está integrado ao formLogin

Então basta adicionar o formControlName no <app-senha> conforme abaixo:

<form [formGroup]="formLogin"
      (ngSubmit)="processarLogin()">

  <div>
    <label>
      Login:
      <input type="email"
             formControlName="login" />
    </label>
  </div>
  <div *ngIf="formLogin.controls.login.touched &&
              formLogin.controls.login.invalid">
    Informe um e-mail válido
  </div>

  <app-senha formControlName="senha">
  </app-senha>
  <div *ngIf="formLogin.controls.senha.touched &&
              formLogin.controls.senha.invalid">
    Informe uma senha
  </div>

  <input type="submit"
        [disabled]="formLogin.invalid" />
</form> 
app.component.html

Ao adicionar o formControlName no <app-senha> pegamos o seguinte erro:

ERROR
Error: No value accessor for form control with name: 'senha'

O  Angular não sabe como obter o valor desse nosso componente <app-senha>, ele não sabe dizer se o componente está com erro e muito menos desabilitar o componente caso o comando a seguir seja executado: formLogin.controls.senha.disable()

Integrando o componente ao Angular Forms

Para "dizer" ao Angular como o nosso componente SenhaComponent funciona, precisamos implementar a interface ControlValueAccessor:

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}

A implementação desta interface faz a "ligação" entre o seu componente e o Angular Forms. Para facilitar o entendimento, pense que de um lado temos o componente, do outro lado temos o FormControl e no meio temos essa interface de comunicação.

Antes de explicar cada método desta interface, veja sua implementação no componente app-senha:

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-senha',
  styleUrls: ['./senha.component.css'],
  template: `
      <div class="valor">
        <div>Senha:</div>
        <div>{{senha}}</div>
      </div>
      <div class="opcoes">
        <div *ngFor="let opcao of opcoes"
             (click)="!this.desabilitado && atualizarSenha(opcao)"
             class="opcao">
          {{opcao}}
        </div>
        <div (click)="limpar()">
          Limpar
        </div>
      </div>
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => SenhaComponent),
    multi: true
  }]
})
export class SenhaComponent implements ControlValueAccessor {

  desabilitado: boolean = false;
  avisaFormControlNoOnBlur = () => void;
  avisaFormControlSobreNovoValor = (valor: string) => void;

  opcoes: Array<string> = [
    '1', '2', '3', '4', '5', '6', '7', '8', '9'
  ];

  senha: string = '';

  atualizarSenha(opcao: string) {
    this.senha += opcao;
    this.avisaFormControlNoOnBlur();
    this.avisaFormControlSobreNovoValor(this.senha);
  }

  limpar() {
    this.senha = '';
    this.avisaFormControlSobreNovoValor('');
  }

  writeValue(obj: any): void {
    this.senha = obj;
  }

  registerOnChange(fn: any): void {
    this.avisaFormControlSobreNovoValor = fn;
  }

  registerOnTouched(fn: any): void {
    this.avisaFormControlNoOnBlur = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.desabilitado = isDisabled;
  }
}
senha.component.ts

writeValue

Quando você quiser passar um valor um valor para o componente via FormControl, por exemplo formLogin.login.setValue('123');, este valor 123 chegará na implementação do método writeValue. Então na sua implementação, você deverá receber este valor e ajustar seu componente para ter aquele valor.

registerOnChange

Aqui temos duas etapas:

  1. Quando seu componente for instanciado, o método registerOnChange será chamado automaticamente recebendo em seu parâmetro uma função que deverá ser salva em uma variável (ver exemplo acima).
  2. Quando o valor do componente for alterado, "avisaremos" o Angular através desta função que foi salva em uma variável o novo valor do componente.

registerOnTouched

Muito similar ao registerOnChange, porém a função que é passada via parâmetro na primeira etapa (ler acima) deve ser utilizada para "avisar" o Angular que seu componente já recebeu algum tipo de interação. Quem define o que é esta interação é você. Pode ser um onblur ou um onclick por exemplo.

Então quando você executar esta função, a propriedade touched (formLogin.controls.senha.touched ) do seu FormControl passará a ser true.

setDisabledState

Quando você executar formLogin.controls.senha.disable() ou formLogin.controls.senha.enable(), a implementação deste método receberá como parâmetro se o componente deve ou não ser desabilitado. Ao receber este valor você deve configurar seu componente de acordo.

Entender bem o que estes 4 métodos fazem, é crucial para realizar uma boa integração.

Código fonte completo

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html'
})
export class AppComponent {
  
  formLogin: FormGroup;
  
  constructor(formBuilder: FormBuilder) {
    this.formLogin = formBuilder.group({
      login: ['', [Validators.required, Validators.email]],
      senha: ['', Validators.required],
    }); 
  }

  processarLogin() {
    if (this.formLogin.invalid) {
      return;
    }
    
    const valoresFormulario = this.formLogin.value;
    console.log('Valores: ', valoresFormulario);
  }

  desabilitarSenha() {
    this.formLogin.controls.senha.disable();
  }

  habilitarSenha() {
    this.formLogin.controls.senha.enable();
  }
} 
app.component.ts
<form [formGroup]="formLogin"
      (ngSubmit)="processarLogin()">

  <div>
    <label>
      Login:
      <input type="email"
             formControlName="login" />
    </label>
  </div>
  <div *ngIf="formLogin.controls.login.touched &&
              formLogin.controls.login.invalid">
    Informe um e-mail válido
  </div>

  <app-senha formControlName="senha"></app-senha>
  <div *ngIf="formLogin.controls.senha.touched &&
              formLogin.controls.senha.invalid">
    Informe uma senha
  </div> 

  <input type="submit"
        [disabled]="formLogin.invalid" />
</form> 
<hr>
<div>
  formLogin.controls.senha.value: {{formLogin.controls.senha.value}}
</div>
<div>
  formLogin.controls.senha.touched: {{formLogin.controls.senha.touched}}
</div>
<div>
  formLogin.controls.senha.invalid: {{formLogin.controls.senha.invalid}}
</div>
<div>
  <button (click)="desabilitarSenha()">Desabilitar senha</button>
  <button (click)="habilitarSenha()">Habilitar senha</button>
</div>
app.component.html
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-senha',
  styleUrls: ['./senha.component.css'],
  template: `
      <div class="valor">
        <div>Senha:</div>
        <div>{{senha}}</div>
      </div>
      <div class="opcoes">
        <div *ngFor="let opcao of opcoes"
             (click)="!this.desabilitado && atualizarSenha(opcao)"
             class="opcao">
          {{opcao}}
        </div>
        <div (click)="limpar()">
          Limpar
        </div>
      </div>
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => SenhaComponent),
    multi: true
  }]
})
export class SenhaComponent implements ControlValueAccessor {

  desabilitado: boolean = false;
  avisaFormControlNoOnBlur: () => void;
  avisaFormControlSobreNovoValor: (valor: string) => void;

  opcoes: Array<string> = [
    '1', '2', '3', '4', '5', '6', '7', '8', '9'
  ];

  senha: string = '';

  atualizarSenha(opcao: string) {
    this.senha += opcao;
    this.avisaFormControlNoOnBlur();
    this.avisaFormControlSobreNovoValor(this.senha);
  }

  limpar() {
    this.senha = '';
    this.avisaFormControlSobreNovoValor('');
  }

  writeValue(obj: any): void {
    this.senha = obj;
  }

  registerOnChange(fn: any): void {
    this.avisaFormControlSobreNovoValor = fn;
  }

  registerOnTouched(fn: any): void {
    this.avisaFormControlNoOnBlur = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.desabilitado = isDisabled;
  }
}
senha.component.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { HelloComponent } from './hello.component';
import { SenhaComponent } from './senha.component';

@NgModule({
  imports:      [ BrowserModule, CommonModule, FormsModule, ReactiveFormsModule ],
  declarations: [ AppComponent, HelloComponent, SenhaComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }
app.module.ts

Veja o resultado final abaixo:

Animação mostrando a validação do formulário junto com o componente customizado app-senha
Validação do formLogin

Considerações

Agora que você sabe como integrar seu componente customizado ao Forms do Angular, repare na documentação que existe várias classes que implementam essa interface ControlValueAccessor para checkbox, input, range, etc. Este é o "segredo" do FormControl funcionar em um <input type="text" por exemplo.

validacao-componente-customizado-angular - StackBlitz
Validação de componente customizado
Exemplo da implementação

Links interessantes: