Custom components controlled by a FormControl.
See this and many other articles at lucaspaganini.com
Angular allows us to control form inputs using the FormsModule or the ReactiveFormsModule. With them, you can bind a FormControl to your input and control its value.
<input type="text" [(ngModel)]="name" />
<input type="text" [formControl]="nameControl" />
But what if you create your own custom component? Like a datepicker, a star rating, or a regex input. Can you bind a FormControl to it?
<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>
Native Inputs and FormControls
Your first guess may have been to add an @Input() in your component to receive the formControl. That would work, but not when using formControlName or [(ngModel)].
What we really want is to reuse the same logic that Angular uses for binding FormControls to native input elements.
If you look at the FormsModule source code, you'll see directives for the native input elements implementing an interface called ControlValueAccessor.
This interface is what allows the FormControl to connect to the component.
Control Value Accessor
Let's create a simple date input component to test this out. Our component needs to implement the ControlValueAccessor interface.
@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();
}
This interface defines 4 methods:
writeValue(value: T | null): voidregisterOnChange(onChange: (value: T | null) => void): voidregisterOnTouched(onTouched: () => void)setDisabledState(isDisabled: boolean): void
registerOnChange receives a callback function that you need to call when the value changes. Similarly, registerOnTouched receives a callback function that you need to call when the input is touched.
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 is called when the FormControl value is changed programmatically, like when you call FormControl.setValue(x). It can receive anything, but if you're using it correctly, it should only receive T (T = Date in our case) or 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);
}
The last method is optional. setDisabledState() is called when the FormControl status changes to or from the disabled state.
This method receives a single argument indicating if the new state is disabled. If it was disabled, and now it's enabled, it's called with false. If it was enabled, and now it's disabled, it's called with 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();
}
}
Providing the NG_VALUE_ACCESSOR
The last step to make this work is to tell Angular that our component is ready to connect to FormControls.
All classes that implement the ControlValueAccessor interface are provided through the NG_VALUE_ACCESSOR token. Angular uses this token to grab the ControlValueAccessor and connect the FormControl to it.
So, we'll provide our component in this token and Angular will use it to connect to the FormControl.
By the way, since we're providing our component before its declaration, we'll need to use Angular's forwardRef() function to make this work.
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateInputComponent),
multi: true,
},
],
...
})
export class DateInputComponent implements ControlValueAccessor { ... }
Conclusion
Everything should be working now. You can play with the code in this repository.
There's another thing I'd like to do with our custom date input: I want it to validate the inputs. February 31 is not a valid date, and we shouldn't be accepting that.
Also, I only want to accept business days. For that, we'll need a synchronous validation to see if it's a weekday and an asynchronous validation to consult an API and see if it's not a holiday.
We'll do that in another article.
Have a great day, and I'll see you soon!
Top comments (0)