When we plan to customize form controls, we should first consider the following questions:
-
Do you have native elements with the same semantics? Such as:
<input type="number">
-
If so, we should consider whether we can rely on the element and change its appearance/behavior by using CSS or incremental enhancement to meet our needs?
-
If not, what will the custom control look like?
-
How do we make it accessible?
-
Does the behavior of custom controls differ on different platforms?
-
How does the custom control realize the data validation function?
There may be many things to consider, but if we decide to use Angular to create custom controls, we need to consider the following issues:
-
How to implement model -> view data binding?
-
How to achieve view -> model data synchronization?
-
If custom authentication is required, how should it be implemented?
-
How to add validity status to DOM elements to facilitate setting different styles?
-
How to make the control accessible (accessible)?
-
Can this control be applied to template-driven forms?
-
Can this control be applied to model-driven forms?
(Note: The current accessibility support status of HTML 5 on the main browsers, please refer to- HTML5 Accessibility )
Creating a custom counter
Now we start with the simplest Counter component, the specific code is as follows:
counter.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'exe-counter',
template: `
<div>
<p>当前值: {
{ count }}</p>
<button (click)="increment()"> + </button>
<button (click)="decrement()"> - </button>
</div>
`
})
export class CounterComponent {
@Input() count: number = 0;
increment() {
this.count++;
}
decrement() {
this.count--;
}
}
app.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'exe-app',
template: `
<exe-counter></exe-counter>
`,
})
export class AppComponent { }
app.module.ts
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { CounterComponent } from './couter.component';
import { AppComponent } from './app.component';
@NgModule({
imports: [BrowserModule],
declarations: [AppComponent, CounterComponent],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }
Very good, the CounterComponent component was soon implemented. But now we want to use this component in the Template-Driven
or Reactive
form, as follows:
<!-- this doesn't work YET -->
<form #form="ngForm">
<exe-counter name="counter" ngModel></exe-counter>
<button type="submit">Submit</button>
</form>
Now we can't directly use it in this way, we need to realize this function. We have to figure ControlValueAccessor
it out first , because it is a bridge between the form model and DOM elements.
Understanding ControlValueAccessor
When we run the above example, the following exception information will be output in the browser console:
Uncaught (in promise): Error: No value accessor for form control with name: 'counter'
So ControlValueAccessor
what is it? So do you remember the things we mentioned before to confirm the implementation of custom controls? One of the things to be confirmed is to realize the data binding between Model -> View and View -> Model, and this is the problem that our ControlValueAccessor has to deal with.
ControlValueAccessor is an interface, its function is:
-
Map the values in the form model to the view
-
When the view changes, notify form directives or form controls
The reason why Angular introduced this interface is that different input control data update methods are different. For example, for our commonly used text input box, we set its value
value, and for a checkbox (checkbox) we set its checked
attribute. In fact, there is one ControlValueAccessor
for different types of input controls to update the view.
Common ControlValueAccessors in Angular are:
-
DefaultValueAccessor-used for
text
andtextarea
type input controls -
SelectControlValueAccessor-used to
select
select controls -
CheckboxControlValueAccessor-used for
checkbox
checkbox control
Next, our CounterComponent component needs to implement the ControlValueAccessor
interface so that we can update the value of count in the component and notify the outside world that the value has changed.
Implementing ControlValueAccessor
First, let's take a look at the ControlValueAccessor
interface, as follows:
// angular2/packages/forms/src/directives/control_value_accessor.ts
export interface ControlValueAccessor {
writeValue(obj: any): void;
registerOnChange(fn: any): void;
registerOnTouched(fn: any): void;
setDisabledState?(isDisabled: boolean): void;
}
-
writeValue(obj: any): This method is used to write the new value in the model to the view or DOM attribute.
-
registerOnChange(fn: any): Set the function to be called when the control receives the change event
-
registerOnTouched(fn: any): Set the function to be called when the control receives the touched event
-
setDisabledState? (isDisabled: boolean): This function is called when the control state changes
DISABLED
orDISABLED
changes from state toENABLE
state. This function will enable or disable the specified DOM element based on the parameter value.
Next we first implement the writeValue()
method:
@Component(...)
class CounterComponent implements ControlValueAccessor {
...
writeValue(value: any) {
this.counterValue = value;
}
}
When the form is initialized, the corresponding initial value in the form model will be used as a parameter to call the writeValue()
method. This means that it will override the default value of 0 and everything seems to be fine. But let's recall the expected usage of the CounterComponent component in the form:
<form #form="ngForm">
<exe-counter name="counter" ngModel></exe-counter>
<button type="submit">Submit</button>
</form>
You will find that we did not set the initial value for the CounterComponent component, so we need to adjust the code in writeValue() as follows:
writeValue(value: any) {
if (value) {
this.count = value;
}
}
Now, only when a legal value (non-undefined, null, "") is written to the control, it will override the default value. Next, let's implement registerOnChange()
and registerOnTouched()
method. registerOnChange() can be used to notify the outside that the component has changed. The registerOnChange() method receives a fn
parameter, which is used to set the function to be called when the control receives the change event. For the registerOnTouched() method, it also supports a fn
parameter for setting the function to be called when the control receives a touched event. In the example we do not intend to handle touched
events, so we set registerOnTouched() as an empty function. details as follows:
@Component(...)
class CounterComponent implements ControlValueAccessor {
...
propagateChange = (_: any) => {};
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(fn: any) {}
}
Very good, our CounterComponent component has implemented the ControlValueAccessor interface. The next thing we need to do is to call the propagateChange() method every time the value of count changes. In other words, when the user clicks the +
or -
button, we want to pass the new value to the outside.
@Component(...)
export class CounterComponent implements ControlValueAccessor {
...
increment() {
this.count++;
this.propagateChange(this.count);
}
decrement() {
this.count--;
this.propagateChange(this.count);
}
}
Do you feel that the above code is a bit redundant? Next, let's use the attribute modifier to reconstruct the above code, as follows:
counter.component.ts
import { Component, Input } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
@Component({
selector: 'exe-counter',
template: `
<p>当前值: {
{ count }}</p>
<button (click)="increment()"> + </button>
<button (click)="decrement()"> - </button>
`
})
export class CounterComponent implements ControlValueAccessor {
@Input() _count: number = 0;
get count() {
return this._count;
}
set count(value: number) {
this._count = value;
this.propagateChange(this._count);
}
propagateChange = (_: any) => { };
writeValue(value: any) {
if (value !== undefined) {
this.count = value;
}
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(fn: any) { }
increment() {
this.count++;
}
decrement() {
this.count--;
}
}
The CounterComponent component has been basically developed, but if it can be used normally, it needs to be registered.
Registering the ControlValueAccessor
For the CounterComponent component we developed, implementing the ControlValueAccessor interface only completed half of the work. To allow Angular to recognize our custom ControlValueAccessor
, we also need to perform a registration operation. The specific method is as follows:
-
Step 1: Create EXE_COUNTER_VALUE_ACCESSOR
import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
export const EXE_COUNTER_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CounterComponent),
multi: true
};
Friendly reminder: For detailed information about forwardRef and multi, please refer to these two articles , Angular 2 Forward Reference and Angular 2 Multi Providers .
-
Step 2: Set the provider information of the component
@Component({
selector: 'exe-counter',
...
providers: [EXE_COUNTER_VALUE_ACCESSOR]
})
Everything is ready, we only owe Dongfeng, we immediately enter the actual combat link to actually test the CounterComponent
components we developed . The complete code is as follows:
counter.component.ts
import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
export const EXE_COUNTER_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CounterComponent),
multi: true
};
@Component({
selector: 'exe-counter',
template: `
<div>
<p>当前值: {
{ count }}</p>
<button (click)="increment()"> + </button>
<button (click)="decrement()"> - </button>
</div>
`,
providers: [EXE_COUNTER_VALUE_ACCESSOR]
})
export class CounterComponent implements ControlValueAccessor {
@Input() _count: number = 0;
get count() {
return this._count;
}
set count(value: number) {
this._count = value;
this.propagateChange(this._count);
}
propagateChange = (_: any) => { };
writeValue(value: any) {
if (value) {
this.count = value;
}
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(fn: any) { }
increment() {
this.count++;
}
decrement() {
this.count--;
}
}
Using it inside template-driven forms
There are two forms in Angular 4.x:
-
Template-Driven Forms-Template-driven forms (similar to forms in Angular 1.x)
-
Reactive Forms-Reactive forms
For more information about Angular 4.x Template-Driven Forms, please refer to- Angular 4.x Template-Driven Forms . Next we look at how to use it:
1. Import the FormsModule module
app.module.ts
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [BrowserModule, FormsModule],
...
})
export class AppModule { }
2. Update AppComponent
2.1 The initial value of the CounterComponent component is not set
app.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'exe-app',
template: `
<form #form="ngForm">
<exe-counter name="counter" ngModel></exe-counter>
</form>
<pre>{
{ form.value | json }}</pre>
`,
})
export class AppComponent { }
Friendly reminder: In the above sample code, form.value is used to obtain the value in the form, and json is an Angular built-in pipeline for performing object serialization operations (internal implementation-JSON.stringify(value, null, 2)). If you want to learn more about Angular pipelines, please refer to- Angular 2 Pipe .
2.2 Set the initial value of the CounterComponent component-use the [ngModel] syntax
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'exe-app',
template: `
<form #form="ngForm">
<exe-counter name="counter" [ngModel]="outerCounterValue"></exe-counter>
</form>
<pre>{
{ form.value | json }}</pre>
`,
})
export class AppComponent {
outerCounterValue: number = 5;
}
2.3 Set up data two-way binding-use [(ngModel)] syntax
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'exe-app',
template: `
<form #form="ngForm">
<p>outerCounterValue value: {
{outerCounterValue}}</p>
<exe-counter name="counter" [(ngModel)]="outerCounterValue"></exe-counter>
</form>
<pre>{
{ form.value | json }}</pre>
`,
})
export class AppComponent {
outerCounterValue: number = 5;
}
Using it inside reactive forms
For more information about Angular 4.x Reactive (Model-Driven) Forms, please refer to- Angular 4.x Reactive Forms . Next we look at how to use it:
1. Import ReactiveFormsModule
app.module.ts
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
imports: [BrowserModule, ReactiveFormsModule],
...
})
export class AppModule { }
2. Update AppComponent
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
@Component({
selector: 'exe-app',
template: `
<form [formGroup]="form">
<exe-counter formControlName="counter"></exe-counter>
</form>
<pre>{
{ form.value | json }}</pre>
`,
})
export class AppComponent {
form: FormGroup;
constructor(private fb: FormBuilder) { }
ngOnInit() {
this.form = this.fb.group({
counter: 5 // 设置初始值
});
}
}
Friendly reminder: In the above code, we removed the ngModel and name attributes in the Template-Driven form, and replaced it with the formControlName attribute. In addition, we
group()
create the FromGroup object through the methods provided by the FormBuilder object , and then use[formGroup]="form"
the method in the template to achieve the binding of the model and the DOM elements. For more information about Reactive Forms, please refer to Angular 4.x Reactive Forms .
Finally, we are looking at how to add validation rules to our custom controls.
Adding custom validation
In Angular 4.x custom form validation based on AbstractControl , we introduced how to customize form validation. For our custom control, it is also very convenient to add a custom verification function (limit the valid range of control value: 0 <= value <=10). Specific examples are as follows:
1. Customize VALIDATOR
1.1 Define verification function
export const validateCounterRange: ValidatorFn = (control: AbstractControl):
ValidationErrors => {
return (control.value > 10 || control.value < 0) ?
{ 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};
1.2 Register a custom validator
export const EXE_COUNTER_VALIDATOR = {
provide: NG_VALIDATORS,
useValue: validateCounterRange,
multi: true
};
2. Update AppComponent
Next, we update the AppComponent component and display the exception information in the component template:
@Component({
selector: 'exe-app',
template: `
<form [formGroup]="form">
<exe-counter formControlName="counter"></exe-counter>
</form>
<p *ngIf="!form.valid">Counter is invalid!</p>
<pre>{
{ form.get('counter').errors | json }}</pre>
`,
})
The complete code of the CounterComponent component is as follows:
counter.component.ts
import { Component, Input, forwardRef } from '@angular/core';
import {
ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS,
AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';
export const EXE_COUNTER_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CounterComponent),
multi: true
};
export const validateCounterRange: ValidatorFn = (control: AbstractControl):
ValidationErrors => {
return (control.value > 10 || control.value < 0) ?
{ 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};
export const EXE_COUNTER_VALIDATOR = {
provide: NG_VALIDATORS,
useValue: validateCounterRange,
multi: true
};
@Component({
selector: 'exe-counter',
template: `
<div>
<p>当前值: {
{ count }}</p>
<button (click)="increment()"> + </button>
<button (click)="decrement()"> - </button>
</div>
`,
providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor {
@Input() _count: number = 0;
get count() {
return this._count;
}
set count(value: number) {
this._count = value;
this.propagateChange(this._count);
}
propagateChange = (_: any) => { };
writeValue(value: any) {
if (value) {
this.count = value;
}
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(fn: any) { }
increment() {
this.count++;
}
decrement() {
this.count--;
}
}
In addition to configuring a custom validator in the Metadata of the CounterComponent component, we can also FormGroup
set the validation rules for each control (FormControl) object when creating the object. The code to be adjusted is as follows:
counter.component.ts
@Component({
selector: 'exe-counter',
...,
providers: [EXE_COUNTER_VALUE_ACCESSOR] // 移除自定义EXE_COUNTER_VALIDATOR
})
app.component.ts
import { validateCounterRange } from './couter.component';
...
export class AppComponent {
...
ngOnInit() {
this.form = this.fb.group({
counter: [5, validateCounterRange] // 设置validateCounterRange验证器
});
}
}
We have implemented the custom verification function, but the verification rule is that the valid range of data is fixed (0 <= value <=10). In fact, a better way is to allow users to flexibly configure the valid range of data. Next, we will optimize the existing functions to make the components we develop more flexible.
Making the validation configurable
The expected usage of our custom CounterComponent component is as follows:
<exe-counter
formControlName="counter"
counterRangeMax="10"
counterRangeMin="0">
</exe-counter>
First, we need to update the CounterComponent component to increment the counterRangeMax and counterRangeMin input attributes:
@Component(...)
class CounterInputComponent implements ControlValueAccessor {
...
@Input() counterRangeMin: number;
@Input() counterRangeMax: number;
...
}
Then we need to add a createCounterRangeValidator()
factory function to dynamically create validateCounterRange()
functions based on the set maximum value (maxValue) and minimum value (minValue) . Specific examples are as follows:
export function createCounterRangeValidator(maxValue: number, minValue: number) {
return (control: AbstractControl): ValidationErrors => {
return (control.value > +maxValue || control.value < +minValue) ?
{ 'rangeError': { current: control.value, max: maxValue,
min: minValue }} : null;
}
}
In the Angular 4.x custom verification instructions article, we introduced how to customize the verification instructions. To implement the custom verification function of the command, we need to implement the Validator
interface:
export interface Validator {
validate(c: AbstractControl): ValidationErrors|null;
registerOnValidatorChange?(fn: () => void): void;
}
In addition, when we detect counterRangeMin
and counterRangeMax
input attributes, we need to call the createCounterRangeValidator()
method, dynamically create the validateCounterRange()
function, and then validate()
call the verification function in the method, and return the return value of the function call. Is it a bit convoluted, let's take a look at the specific code immediately:
import { Component, Input, OnChanges, SimpleChanges, forwardRef } from '@angular/core';
import {
ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator,
AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';
...
export const EXE_COUNTER_VALIDATOR = {
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CounterComponent),
multi: true
};
export function createCounterRangeValidator(maxValue: number, minValue: number) {
return (control: AbstractControl): ValidationErrors => {
return (control.value > +maxValue || control.value < +minValue) ?
{ 'rangeError': { current: control.value, max: maxValue, min: minValue } }
: null;
}
}
@Component({
selector: 'exe-counter',
template: `
<div>
<p>当前值: {
{ count }}</p>
<button (click)="increment()"> + </button>
<button (click)="decrement()"> - </button>
</div>
`,
providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor, Validator,
OnChanges {
...
private _validator: ValidatorFn;
private _onChange: () => void;
@Input() counterRangeMin: number; // 设置数据有效范围的最大值
@Input() counterRangeMax: number; // 设置数据有效范围的最小值
// 监听输入属性变化,调用内部的_createValidator()方法,创建RangeValidator
ngOnChanges(changes: SimpleChanges): void {
if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
this._createValidator();
}
}
// 动态创建RangeValidator
private _createValidator(): void {
this._validator = createCounterRangeValidator(this.counterRangeMax,
this.counterRangeMin);
}
// 执行控件验证
validate(c: AbstractControl): ValidationErrors | null {
return this.counterRangeMin == null || this.counterRangeMax == null ?
null : this._validator(c);
}
...
}
The above code is very long, let's break it down:
Register Validator
export const EXE_COUNTER_VALIDATOR = {
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CounterComponent),
multi: true
};
@Component({
selector: 'exe-counter',
...,
providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
Create the createCounterRangeValidator() factory function
export function createCounterRangeValidator(maxValue: number, minValue: number) {
return (control: AbstractControl): ValidationErrors => {
return (control.value > +maxValue || control.value < +minValue) ?
{ 'rangeError': { current: control.value, max: maxValue, min: minValue } }
: null;
}
}
Implement the OnChanges interface, monitor the input attribute changes to create a RangeValidator
export class CounterComponent implements ControlValueAccessor, Validator,
OnChanges {
...
@Input() counterRangeMin: number; // 设置数据有效范围的最大值
@Input() counterRangeMax: number; // 设置数据有效范围的最小值
// 监听输入属性变化,调用内部的_createValidator()方法,创建RangeValidator
ngOnChanges(changes: SimpleChanges): void {
if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
this._createValidator();
}
}
...
}
Call the _createValidator() method to create a RangeValidator
export class CounterComponent implements ControlValueAccessor, Validator,
OnChanges {
...
// 动态创建RangeValidator
private _createValidator(): void {
this._validator = createCounterRangeValidator(this.counterRangeMax,
this.counterRangeMin);
}
...
}
Implement the Validator interface to implement the control verification function
export class CounterComponent implements ControlValueAccessor, Validator,
OnChanges {
...
// 执行控件验证
validate(c: AbstractControl): ValidationErrors | null {
return this.counterRangeMin == null || this.counterRangeMax == null ?
null : this._validator(c);
}
...
}
At this point, our custom CounterComponent component has finally been developed, and it is short of functional verification. Specific usage examples are as follows:
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
@Component({
selector: 'exe-app',
template: `
<form [formGroup]="form">
<exe-counter formControlName="counter"
counterRangeMin="5"
counterRangeMax="8">
</exe-counter>
</form>
<p *ngIf="!form.valid">Counter is invalid!</p>
<pre>{
{ form.get('counter').errors | json }}</pre>
`,
})
export class AppComponent {
form: FormGroup;
constructor(private fb: FormBuilder) { }
ngOnInit() {
this.form = this.fb.group({
counter: 5
});
}
}
After the above code runs successfully, the result displayed on the browser page is as follows: