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>
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".
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' },
];
}
}