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.
<input type="text" [(ngModel)]="name" />
<input type="text" [formControl]="nameControl" />
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?
<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>
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>
Inputs 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 FormControl
s 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.
Control Value Accessor
Vamos criar um simples componente de entrada de datas para testar isso. Nosso componente precisa implementar a interface ControlValueAccessor
.
@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();
}
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:
writeValue(value: T | null): void
registerOnChange(onChange: (value: T | null) => void): void
registerOnTouched(onTouched: () => void)
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.
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();
});
}
TypeScriptprivate _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
.
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);
}
TypeScriptpublic 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
.
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();
}
}
TypeScriptpublic 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();
}
}
Fornecendo 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 FormControl
s.
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.
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateInputComponent),
multi: true,
},
],
...
})
export class DateInputComponent implements ControlValueAccessor { ... }
TypeScript@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateInputComponent),
multi: true,
},
],
...
})
export class DateInputComponent implements ControlValueAccessor { ... }
Conclusã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!