I would like to create a custom form element with ControlValueAccessor interface in Angular 2+. This element would be a wrapper over a <select>
. Is it possible to propagate the formControl properties to the wrapped element? In my case, the validation state is not getting propagated to nested select as you can see on the attached screenshot.
My component is available as following:
const OPTIONS_VALUE_ACCESSOR: any = {
multi: true,
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => OptionsComponent)
};
@Component({
providers: [OPTIONS_VALUE_ACCESSOR],
selector: 'inf-select[name]',
templateUrl: './options.component.html'
})
export class OptionsComponent implements ControlValueAccessor, OnInit {
@Input() name: string;
@Input() disabled = false;
private propagateChange: Function;
private onTouched: Function;
private settingsService: SettingsService;
selectedValue: any;
constructor(settingsService: SettingsService) {
this.settingsService = settingsService;
}
ngOnInit(): void {
if (!this.name) {
throw new Error('Option name is required. eg.: <options [name]="myOption"></options>>');
}
}
writeValue(obj: any): void {
this.selectedValue = obj;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}
This is my component template:
<select class="form-control"
[disabled]="disabled"
[(ngModel)]="selectedValue"
(ngModelChange)="propagateChange($event)">
<option value="">Select an option</option>
<option *ngFor="let option of settingsService.getOption(name)" [value]="option.description">
{{option.description}}
</option>
</select>
What are form controls in Angular? In Angular, form controls are classes that can hold both the data values and the validation information of any form element. Every form input you have in a reactive form should be bound by a form control. These are the basic units that make up reactive forms.
Control Value Accessor is an interface that provides us the power to leverage the Angular forms API and create a communication between Angular Form API and the DOM element. It provides us many facilities in angular like we can create custom controls or custom component with the help of control value accessor interface.
SAMPLE PLUNKER
I see two options:
FormControl
to <select>
FormControl
whenever the <select>
FormControl
value changesFormControl
to <select>
FormControl
Below the following variables are available:
selectModel
is the NgModel
of the <select>
formControl
is the FormControl
of the component received as an argumentOption 1: propagate errors
ngAfterViewInit(): void {
this.selectModel.control.valueChanges.subscribe(() => {
this.selectModel.control.setErrors(this.formControl.errors);
});
}
Option 2: propagate validators
ngAfterViewInit(): void {
this.selectModel.control.setValidators(this.formControl.validator);
this.selectModel.control.setAsyncValidators(this.formControl.asyncValidator);
}
The difference between the two is that propagating the errors means having already the errors, while the seconds option involves executing the validators a second time. Some of them, like async validators might be too costly to perform.
Propagating all properties?
There is no general solution to propagate all the properties. Various properties are set by various directives, or other means, thus having different lifecycle, which means that require particular handling. Current solution regards propagating validation errors and validators. There are many properties available up there.
Note that you might get different status changes from the FormControl
instance by subscribing to FormControl.statusChanges()
. This way you can get whether the the control is VALID
, INVALID
, DISABLED
or PENDING
(async validation is still running).
How validation works under the hood?
Under the hood the validators are applied using directives (check the source code). The directives have providers: [REQUIRED_VALIDATOR]
which means that own hierarchical injector is used to register that validator instance. So depending on the attributes applied on the element, the directives will add validator instances on the injector associated to the target element.
Next, these validators are retrieved by NgModel
and FormControlDirective
.
Validators as well as value accessors are retrieved like:
constructor(@Optional() @Host() parent: ControlContainer,
@Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
@Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR)
and respectively:
constructor(@Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
@Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR)
valueAccessors: ControlValueAccessor[])
Note that @Self()
is used, therefore own injector (of the element to which the directive is being applied) is used in order to obtain the dependencies.
NgModel
and FormControlDirective
have an instance of FormControl
which actually update the value and execute the validators.
Therefore the main point to interact with is the FormControl
instance.
Also all validators or value accessors are registered in the injector of the element to which they are applied. This means that the parent should not access that injector. So would be a bad practice to access from current component the injector provided by the <select>
.
Sample code for Option 1 (easily replaceable by Option 2)
The following sample has two validators: one which is required and another which is a pattern which forces the option to match "option 3".
The PLUNKER
options.component.ts
import {AfterViewInit, Component, forwardRef, Input, OnInit, ViewChild} from '@angular/core';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, NgModel} from '@angular/forms';
import {SettingsService} from '../settings.service';
const OPTIONS_VALUE_ACCESSOR: any = {
multi: true,
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => OptionsComponent)
};
@Component({
providers: [OPTIONS_VALUE_ACCESSOR],
selector: 'inf-select[name]',
templateUrl: './options.component.html',
styleUrls: ['./options.component.scss']
})
export class OptionsComponent implements ControlValueAccessor, OnInit, AfterViewInit {
@ViewChild('selectModel') selectModel: NgModel;
@Input() formControl: FormControl;
@Input() name: string;
@Input() disabled = false;
private propagateChange: Function;
private onTouched: Function;
private settingsService: SettingsService;
selectedValue: any;
constructor(settingsService: SettingsService) {
this.settingsService = settingsService;
}
ngOnInit(): void {
if (!this.name) {
throw new Error('Option name is required. eg.: <options [name]="myOption"></options>>');
}
}
ngAfterViewInit(): void {
this.selectModel.control.valueChanges.subscribe(() => {
this.selectModel.control.setErrors(this.formControl.errors);
});
}
writeValue(obj: any): void {
this.selectedValue = obj;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}
options.component.html
<select #selectModel="ngModel"
class="form-control"
[disabled]="disabled"
[(ngModel)]="selectedValue"
(ngModelChange)="propagateChange($event)">
<option value="">Select an option</option>
<option *ngFor="let option of settingsService.getOption(name)" [value]="option.description">
{{option.description}}
</option>
</select>
options.component.scss
:host {
display: inline-block;
border: 5px solid transparent;
&.ng-invalid {
border-color: purple;
}
select {
border: 5px solid transparent;
&.ng-invalid {
border-color: red;
}
}
}
Usage
Define the FormControl
instance:
export class AppComponent implements OnInit {
public control: FormControl;
constructor() {
this.control = new FormControl('', Validators.compose([Validators.pattern(/^option 3$/), Validators.required]));
}
...
Bind the FormControl
instance to the component:
<inf-select name="myName" [formControl]="control"></inf-select>
Dummy SettingsService
/**
* TODO remove this class, added just to make injection work
*/
export class SettingsService {
public getOption(name: string): [{ description: string }] {
return [
{ description: 'option 1' },
{ description: 'option 2' },
{ description: 'option 3' },
{ description: 'option 4' },
{ description: 'option 5' },
];
}
}
Here is what in my opinion is the cleanest solution to access FormControl in a ControlValueAccessor
based component. Solution was based on what is mention here in Angular Material documentation.
// parent component template
<my-text-input formControlName="name"></my-text-input>
@Component({
selector: 'my-text-input',
template: '<input
type="text"
[value]="value"
/>',
})
export class MyComponent implements AfterViewInit, ControlValueAccessor {
// Here is missing standard stuff to implement ControlValueAccessor interface
constructor(@Optional() @Self() public ngControl: NgControl) {
if (ngControl != null) {
// Setting the value accessor directly (instead of using
// the providers) to avoid running into a circular import.
ngControl.valueAccessor = this;
}
}
ngAfterContentInit(): void {
const control = this.ngControl && this.ngControl.control;
if (control) {
// FormControl should be available here
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With