Control Value Accessor: Componentes de Formulário Customizados em Angular

Componentes customizados controlados por um FormControl

Angular nos permite controlar formulários usando o FormsModule ou o ReactiveFormsModule. Com eles, você pode vincular um FormControl ao seu input e controlar o seu valor.

HTML
<input type="text" [(ngModel)]="name" />
<input type="text" [formControl]="nameControl" />

E se você criar o seu próprio componente? Como um selecionador de data, um classificador por estrelas ou um campo para expressões regulares. Você consegue vincular um FormControl nisso?

HTML
<app-datepicker [(ngModel)]="date"></app-datepicker>
<app-datepicker [formControl]="dateControl"></app-datepicker>

<app-stars [(ngModel)]="stars"></app-stars>
<app-stars [formControl]="starsControl"></app-stars>

<app-regex [(ngModel)]="regex"></app-regex>
<app-regex [formControl]="regexControl"></app-regex>

LinkInputs Nativos e FormControls

Seu palpite inicial talvez tenha sido adicionar um @Input no seu componente, para receber o formControl. Isso até funcionaria, mas não com formControlName ou [(ngModel)].

O que nós realmente queremos é reutilizar a mesma lógica que o Angular usa para vincular FormControls aos inputs nativos.

Olhando o código fonte do FormsModule, você verá que existem diretivas para inputs nativos implementando uma interface chamada ControlValueAccessor.

Essa interface é o que permite que o FormControl se vincule ao componente.

LinkControl Value Accessor

Vamos criar um simples componente de entrada de datas para testar isso. Nosso componente precisa implementar a interface ControlValueAccessor.

TypeScript
@Component({
  selector: 'app-date-input',
  ...
})
export class DateInputComponent implements ControlValueAccessor {
  public readonly dayControl = new FormControl();
  public readonly monthControl = new FormControl();
  public readonly yearControl = new FormControl();
}

Essa interface define 4 métodos:

  1. writeValue(value: T | null): void
  2. registerOnChange(onChange: (value: T | null) => void): void
  3. registerOnTouched(onTouched: () => void)
  4. setDisabledState(isDisabled: boolean): void

registerOnChange recebe uma função de callback que você deve chamar quando o valor mudar. De maneira similar, registerOnTouched recebe uma função de callback que você deve chamar quando o componente for tocado.

TypeScript
private _onChange = (value: Date | null) => undefined;
public registerOnChange(fn: (value: Date | null) => void): void {
  this._onChange = fn;
}

private _onTouched = () => undefined;
public registerOnTouched(fn: () => void): void {
  this._onTouched = fn;
}

public ngOnInit(): void {
  combineLatest([
    this.dayControl.valueChanges,
    this.monthControl.valueChanges,
    this.yearControl.valueChanges,
  ]).subscribe(([day, month, year]) => {
    const fieldsAreValid =
      this.yearControl.valid &&
      this.monthControl.valid &&
      this.dayControl.valid;
    const value = fieldsAreValid ? new Date(year, month - 1, day) : null;

    this._onChange(value);
    this._onTouched();
  });
}

writeValue é chamado quando o valor do FormControl é modificado programaticamente, como quando chamamos FormControl.setValue(x). Ele pode receber qualquer coisa, mas se você estiver usando-o corretamente, ele só deve receber T (T = Date no nosso caso) ou null.

TypeScript
public writeValue(value: Date | null): void {
    value = value ?? new Date();

    const day = value.getDate();
    const month = value.getMonth() + 1;
    const year = value.getFullYear();

    this.dayControl.setValue(day);
    this.monthControl.setValue(month);
    this.yearControl.setValue(year);
  }

O último método é opcional. setDisabledState é chamado quando o estado do FormControl muda para (ou do) estado desabilitado.

Esse método recebe um único argumento indicando se o novo estado é desabilitado. Se estava desabilitado e agora está habilitado, é chamado com false. Se estava habilitado e agora está desabilitado, é chamado com true.

TypeScript
public setDisabledState(isDisabled: boolean): void {
  if (isDisabled) {
    this.dayControl.disable();
    this.monthControl.disable();
    this.yearControl.disable();
  } else {
    this.dayControl.enable();
    this.monthControl.enable();
    this.yearControl.enable();
  }
}

LinkFornecendo o NG_VALUE_ACCESSOR

O último passo para fazer tudo funcionar é comunicar para o Angular que o nosso componente está pronto para se vincular a FormControls.

Todas as classes que implementam a interface ControlValueAccessor são fornecidas por meio do token NG_VALUE_ACCESSOR. Angular usa esse token para pegar o ControlValueAccessor e vincular o FormControl a ele.

Portanto, vamos fornecer o nosso componente nesse token e o Angular irá usá-lo para vincular o FormControl.

Aliás, como estamos fornecendo o nosso componente antes de sua declaração, teremos que usar a função forwardRef() do Angular para que tudo funcione.

TypeScript
@Component({
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => DateInputComponent),
      multi: true,
    },
  ],
  ...
})
export class DateInputComponent implements ControlValueAccessor { ... }

LinkConclusão

Tudo deve estar funcionando agora. Você pode brincar com o código neste repositório.

Tem mais uma coisa que eu gostaria de fazer com o nosso componente de data: Eu quero que ele valide as entradas. 31 de fevereiro não é uma data válida e não deveríamos aceitar isso.

Além disso, eu só quero aceitar dias úteis. Para isso, precisaremos de uma validação síncrona para verificar se é um dia de semana e uma validação assíncrona para consultar uma API e verificar se não é um feriado.

Faremos isso em outro artigo.

Tenha um ótimo dia e nos vemos em breve!

LinkReferências

  1. Repositório GitHub

Assine a nossa Newsletter e seja avisado quando eu lançar um curso, postar um vídeo ou escrever um artigo.

Campo obrigatório
Campo obrigatório