Angular custom form controls

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  Reactiveform, 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 ControlValueAccessorit 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 ControlValueAccessorfor different types of input controls  to update the view.

Common ControlValueAccessors in Angular are:

  • DefaultValueAccessor-used for  text and  textarea 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 or DISABLED changes from  state to  ENABLEstate. 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:

image description

Reference resources

Guess you like

Origin blog.csdn.net/u013475983/article/details/103292170