角度2自定义形式输入

如何创建像本地 <input>标签一样工作的自定义组件?我希望我的自定义表单控件能够支持 ngControl,ngForm,[(ngModel)]。

据我所知,我需要实现一些接口来使我自己的表单控件像本机控件一样工作。

另外,ngForm 指令似乎只绑定到 <input>标签,是这样吗? 我该如何处理?


让我解释一下我为什么需要这个。我想包装几个输入元素,使它们能够作为一个单独的输入一起工作。还有别的办法吗? 再说一次: 我想让这个控件像本机控件一样。验证、 ngForm、 ngModel 双向绑定等。

Ps: 我使用 Typecript。

100224 次浏览

In fact, there are two things to implement:

  • A component that provides the logic of your form component. It doesn't need an input since it will be provided by ngModel itself
  • A custom ControlValueAccessor that will implement the bridge between this component and ngModel / ngControl

Let's take a sample. I want to implement a component that manages a list of tags for a company. The component will allow to add and remove tags. I want to add a validation to ensure that the tags list isn't empty. I will define it in my component as described below:

(...)
import {TagsComponent} from './app.tags.ngform';
import {TagsValueAccessor} from './app.tags.ngform.accessor';


function notEmpty(control) {
if(control.value == null || control.value.length===0) {
return {
notEmpty: true
}
}


return null;
}


@Component({
selector: 'company-details',
directives: [ FormFieldComponent, TagsComponent, TagsValueAccessor ],
template: `
<form [ngFormModel]="companyForm">
Name: <input [(ngModel)]="company.name"
[ngFormControl]="companyForm.controls.name"/>
Tags: <tags [(ngModel)]="company.tags"
[ngFormControl]="companyForm.controls.tags"></tags>
</form>
`
})
export class DetailsComponent implements OnInit {
constructor(_builder:FormBuilder) {
this.company = new Company('companyid',
'some name', [ 'tag1', 'tag2' ]);
this.companyForm = _builder.group({
name: ['', Validators.required],
tags: ['', notEmpty]
});
}
}

The TagsComponent component defines the logic to add and remove elements in the tags list.

@Component({
selector: 'tags',
template: `
<div *ngIf="tags">
<span *ngFor="#tag of tags" style="font-size:14px"
class="label label-default" (click)="removeTag(tag)">
\{\{label}} <span class="glyphicon glyphicon-remove"
aria-  hidden="true"></span>
</span>
<span>&nbsp;|&nbsp;</span>
<span style="display:inline-block;">
<input [(ngModel)]="tagToAdd"
style="width: 50px; font-size: 14px;" class="custom"/>
<em class="glyphicon glyphicon-ok" aria-hidden="true"
(click)="addTag(tagToAdd)"></em>
</span>
</div>
`
})
export class TagsComponent {
@Output()
tagsChange: EventEmitter;


constructor() {
this.tagsChange = new EventEmitter();
}


setValue(value) {
this.tags = value;
}


removeLabel(tag:string) {
var index = this.tags.indexOf(tag, 0);
if (index !== -1) {
this.tags.splice(index, 1);
this.tagsChange.emit(this.tags);
}
}


addLabel(label:string) {
this.tags.push(this.tagToAdd);
this.tagsChange.emit(this.tags);
this.tagToAdd = '';
}
}

As you can see, there is no input in this component but a setValue one (the name isn't important here). We use it later to provide the value from the ngModel to the component. This component defines an event to notify when the state of the component (the tags list) is updated.

Let's implement now the link between this component and ngModel / ngControl. This corresponds to a directive that implements the ControlValueAccessor interface. A provider must be defined for this value accessor against the NG_VALUE_ACCESSOR token (don't forget to use forwardRef since the directive is defined after).

The directive will attach an event listener on the tagsChange event of the host (i.e. the component the directive is attached on, i.e. the TagsComponent). The onChange method will be called when the event occurs. This method corresponds to the one registered by Angular2. This way it will be aware of changes and updates accordingly the associated form control.

The writeValue is called when the value bound in the ngForm is updated. After having injected the component attached on (i.e. TagsComponent), we will be able to call it to pass this value (see the previous setValue method).

Don't forget to provide the CUSTOM_VALUE_ACCESSOR in the bindings of the directive.

Here is the complete code of the custom ControlValueAccessor:

import {TagsComponent} from './app.tags.ngform';


const CUSTOM_VALUE_ACCESSOR = CONST_EXPR(new Provider(
NG_VALUE_ACCESSOR, {useExisting: forwardRef(() => TagsValueAccessor), multi: true}));


@Directive({
selector: 'tags',
host: {'(tagsChange)': 'onChange($event)'},
providers: [CUSTOM_VALUE_ACCESSOR]
})
export class TagsValueAccessor implements ControlValueAccessor {
onChange = (_) => {};
onTouched = () => {};


constructor(private host: TagsComponent) { }


writeValue(value: any): void {
this.host.setValue(value);
}


registerOnChange(fn: (_: any) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
}

This way when I remove all the tags of the company, the valid attribute of the companyForm.controls.tags control becomes false automatically.

See this article (section "NgModel-compatible component") for more details:

Thierry's example is helpful. Here are the imports that are needed for TagsValueAccessor to run...

import {Directive, Provider} from 'angular2/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR } from 'angular2/common';
import {CONST_EXPR} from 'angular2/src/facade/lang';
import {forwardRef} from 'angular2/src/core/di';

I don't understand why every example I find on the internet has to be so complicated. When explaining a new concept, I think it's always best to have the most simple, working example possible. I've distilled it down a little bit:

HTML for external form using component implementing ngModel:

EmailExternal=<input [(ngModel)]="email">
<inputfield [(ngModel)]="email"></inputfield>

Self-contained component (no separate 'accessor' class - maybe I'm missing the point):

import {Component, Provider, forwardRef, Input} from "@angular/core";
import {ControlValueAccessor, NG_VALUE_ACCESSOR, CORE_DIRECTIVES} from "@angular/common";


const CUSTOM_INPUT_CONTROL_VALUE_ACCESSOR = new Provider(
NG_VALUE_ACCESSOR, {
useExisting: forwardRef(() => InputField),
multi: true
});


@Component({
selector : 'inputfield',
template: `<input [(ngModel)]="value">`,
directives: [CORE_DIRECTIVES],
providers: [CUSTOM_INPUT_CONTROL_VALUE_ACCESSOR]
})
export class InputField implements ControlValueAccessor {
private _value: any = '';
get value(): any { return this._value; };


set value(v: any) {
if (v !== this._value) {
this._value = v;
this.onChange(v);
}
}


writeValue(value: any) {
this._value = value;
this.onChange(value);
}


onChange = (_) => {};
onTouched = () => {};
registerOnChange(fn: (_: any) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
}

In fact, I've just abstracted all of this stuff to an abstract class which I now extend with every component I need to use ngModel. For me this is a ton of overhead and boilerplate code which I can do without.

Edit: Here it is:

import { forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';


export abstract class AbstractValueAccessor implements ControlValueAccessor {
_value: any = '';
get value(): any { return this._value; };
set value(v: any) {
if (v !== this._value) {
this._value = v;
this.onChange(v);
}
}


writeValue(value: any) {
this._value = value;
// warning: comment below if only want to emit on user intervention
this.onChange(value);
}


onChange = (_) => {};
onTouched = () => {};
registerOnChange(fn: (_: any) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
}


export function MakeProvider(type : any){
return {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => type),
multi: true
};
}

Here's a component that uses it: (TS):

import {Component, Input} from "@angular/core";
import {CORE_DIRECTIVES} from "@angular/common";
import {AbstractValueAccessor, MakeProvider} from "../abstractValueAcessor";


@Component({
selector : 'inputfield',
template: require('./genericinput.component.ng2.html'),
directives: [CORE_DIRECTIVES],
providers: [MakeProvider(InputField)]
})
export class InputField extends AbstractValueAccessor {
@Input('displaytext') displaytext: string;
@Input('placeholder') placeholder: string;
}

HTML:

<div class="form-group">
<label class="control-label" >\{\{displaytext}}</label>
<input [(ngModel)]="value" type="text" placeholder="\{\{placeholder}}" class="form-control input-md">
</div>

There's an example in this link for RC5 version: http://almerosteyn.com/2016/04/linkup-custom-control-to-ngcontrol-ngmodel

import { Component, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';


const noop = () => {
};


export const CUSTOM_INPUT_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true
};


@Component({
selector: 'custom-input',
template: `<div class="form-group">
<label>
<ng-content></ng-content>
<input [(ngModel)]="value"
class="form-control"
(blur)="onBlur()" >
</label>
</div>`,
providers: [CUSTOM_INPUT_CONTROL_VALUE_ACCESSOR]
})
export class CustomInputComponent implements ControlValueAccessor {


//The internal data model
private innerValue: any = '';


//Placeholders for the callbacks which are later providesd
//by the Control Value Accessor
private onTouchedCallback: () => void = noop;
private onChangeCallback: (_: any) => void = noop;


//get accessor
get value(): any {
return this.innerValue;
};


//set accessor including call the onchange callback
set value(v: any) {
if (v !== this.innerValue) {
this.innerValue = v;
this.onChangeCallback(v);
}
}


//Set touched on blur
onBlur() {
this.onTouchedCallback();
}


//From ControlValueAccessor interface
writeValue(value: any) {
if (value !== this.innerValue) {
this.innerValue = value;
}
}


//From ControlValueAccessor interface
registerOnChange(fn: any) {
this.onChangeCallback = fn;
}


//From ControlValueAccessor interface
registerOnTouched(fn: any) {
this.onTouchedCallback = fn;
}


}

We are then able to use this custom control as follows:

<form>
<custom-input name="someValue"
[(ngModel)]="dataModel">
Enter data:
</custom-input>
</form>

Why to create a new value accessor when you can use the inner ngModel. Whenever you are creating a custom component which has an input[ngModel] in it, we already are instantiating an ControlValueAccessor. And that's the accessor we need.

template:

<div class="form-group" [ngClass]="{'has-error' : hasError}">
<div><label>\{\{label}}</label></div>
<input type="text" [placeholder]="placeholder" ngModel [ngClass]="{invalid: (invalid | async)}" [id]="identifier"        name="\{\{name}}-input" />
</div>

Component:

export class MyInputComponent {
@ViewChild(NgModel) innerNgModel: NgModel;


constructor(ngModel: NgModel) {
//First set the valueAccessor of the outerNgModel
this.outerNgModel.valueAccessor = this.innerNgModel.valueAccessor;


//Set the innerNgModel to the outerNgModel
//This will copy all properties like validators, change-events etc.
this.innerNgModel = this.outerNgModel;
}
}

Use as:

<my-input class="col-sm-6" label="First Name" name="firstname"
[(ngModel)]="user.name" required
minlength="5" maxlength="20"></my-input>

This is quite easy to do with ControlValueAccessor NG_VALUE_ACCESSOR.

You can read this article to make a simple custom field Create Custom Input Field Component with Angular

I wrote a library that helps reduce some boilerplate for this case: s-ng-utils. Some of the other answers are giving example of wrapping a single form control. Using s-ng-utils that can be done very simply using WrappedFormControlSuperclass:

@Component({
template: `
<!-- any fancy wrapping you want in the template -->
<input [formControl]="formControl">
`,
providers: [provideValueAccessor(StringComponent)],
})
class StringComponent extends WrappedFormControlSuperclass<string> {
// This looks unnecessary, but is required for Angular to provide `Injector`
constructor(injector: Injector) {
super(injector);
}
}

In your post you mention that you want to wrap multiple form controls into a single component. Here is a full example doing that with FormControlSuperclass.

import { Component, Injector } from "@angular/core";
import { FormControlSuperclass, provideValueAccessor } from "s-ng-utils";


interface Location {
city: string;
country: string;
}


@Component({
selector: "app-location",
template: `
City:
<input
[ngModel]="location.city"
(ngModelChange)="modifyLocation('city', $event)"
/>
Country:
<input
[ngModel]="location.country"
(ngModelChange)="modifyLocation('country', $event)"
/>
`,
providers: [provideValueAccessor(LocationComponent)],
})
export class LocationComponent extends FormControlSuperclass<Location> {
location!: Location;


// This looks unnecessary, but is required for Angular to provide `Injector`
constructor(injector: Injector) {
super(injector);
}


handleIncomingValue(value: Location) {
this.location = value;
}


modifyLocation<K extends keyof Location>(field: K, value: Location[K]) {
this.location = { ...this.location, [field]: value };
this.emitOutgoingValue(this.location);
}
}

You can then use <app-location> with [(ngModel)], [formControl], custom validators - everything you can do with the controls Angular supports out of the box.