Ввод пользовательской формы Angular 2

93

Как я могу создать собственный компонент, который будет работать как собственный <input>тег? Я хочу, чтобы мой настраиваемый элемент управления формой мог поддерживать ngControl, ngForm, [(ngModel)].

Насколько я понимаю, мне нужно реализовать некоторые интерфейсы, чтобы мой собственный элемент управления формой работал так же, как собственный.

Кроме того, похоже, что директива ngForm привязана только к <input>тегу, это правильно? Как я могу с этим справиться?


Позвольте мне объяснить, зачем мне это вообще нужно. Я хочу обернуть несколько элементов ввода, чтобы они могли работать вместе как один ввод. Есть ли другой способ справиться с этим? Еще раз: я хочу сделать этот элемент управления таким же, как родной. Проверка, двусторонняя привязка ngForm, ngModel и другие.

ps: я использую Typescript.

Максим Фомин
источник
1
Большинство ответов на текущие версии Angular устарели. Взгляните на stackoverflow.com/a/41353306/2176962
hgoebl

Ответы:

85

На самом деле нужно реализовать две вещи:

  • Компонент, который обеспечивает логику вашего компонента формы. Он не требует ввода, так как он будет предоставлен ngModelсам по себе
  • Кастом ControlValueAccessor, который реализует мост между этим компонентом и ngModel/ngControl

Возьмем образец. Я хочу реализовать компонент, который управляет списком тегов для компании. Компонент позволит добавлять и удалять теги. Я хочу добавить проверку, чтобы убедиться, что список тегов не пуст. Я определю его в своем компоненте, как описано ниже:

(...)
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]
    });
  }
}

TagsComponentКомпонент определяет логику добавления и удаления элементов в tagsсписке.

@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 != undefined) {
      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 = '';
  }
}

Как видите, в этом компоненте нет ввода, кроме setValueодного (имя здесь не важно). Мы используем его позже, чтобы передать значение из ngModelкомпонента. Этот компонент определяет событие для уведомления, когда состояние компонента (список тегов) обновляется.

Давайте теперь реализуем связь между этим компонентом и ngModel/ ngControl. Это соответствует директиве, реализующей ControlValueAccessorинтерфейс. Поставщик должен быть определен для этого метода доступа к значению для NG_VALUE_ACCESSORтокена (не забудьте использовать, forwardRefпоскольку директива определяется после).

Директива прикрепит прослушиватель событий к tagsChangeсобытию хоста (то есть к компоненту, к которому прикреплена директива, то есть к TagsComponent). onChangeМетод будет вызываться при возникновении события. Этот метод соответствует тому, который зарегистрирован Angular2. Таким образом, он будет знать об изменениях и соответственно обновлять связанный элемент управления формой.

writeValueВызывается , когда значение , связанное в ngFormобновлении. После внедрения компонента, прикрепленного к нему (например, TagsComponent), мы сможем вызвать его для передачи этого значения (см. Предыдущий setValueметод).

Не забудьте указать CUSTOM_VALUE_ACCESSORв привязках директивы.

Вот полный код кастома 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; }
}

Таким образом, когда я удаляю все tagsкомпании, validатрибут элемента companyForm.controls.tagsуправления становится falseавтоматически.

См. Эту статью (раздел «Компонент, совместимый с NgModel») для более подробной информации:

Тьерри Темплиер
источник
Благодарность! Ты восхитителен! Как вы думаете - это действительно нормально? Я имею в виду: не используйте элементы ввода и создавайте собственные элементы управления, например: <textfield>, <dropdown>? Это «угловатый» способ?
Максим Фомин
1
Я бы сказал, что если вы хотите реализовать собственное поле в форме (что-то нестандартное), используйте этот подход. В противном случае используйте собственные HTML-элементы. Тем не менее, если вы хотите модулировать способ отображения input / textarea / select (например, с помощью Bootstrap3), вы можете использовать ng-content. См. Этот ответ: stackoverflow.com/questions/34950950/…
Тьерри Темплиер,
3
В приведенном выше коде отсутствует код и есть некоторые несоответствия, например «removeLabel» вместо «removeLabel». См. Здесь полный рабочий пример. Спасибо Тьерри за то, что выложил первый пример!
Blue
1
Нашел, импортирует из @ angular / forms вместо @ angular / common, и он работает. импортировать {NG_VALUE_ACCESSOR, ControlValueAccessor} из '@ angular / forms';
Cagatay Civici
1
эта ссылка тоже должна быть полезной ..
рефакторинг
110

Я не понимаю, почему каждый пример, который я нахожу в Интернете, должен быть таким сложным. Я считаю, что при объяснении новой концепции всегда лучше иметь самый простой из возможных рабочий пример. Я немного его переработал:

HTML для внешней формы с использованием компонента, реализующего ngModel:

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

Автономный компонент (нет отдельного класса-аксессуара - возможно, я упускаю суть):

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; }
}

Фактически, я просто абстрагировал все это до абстрактного класса, который теперь расширяю с каждым компонентом, который мне нужен для использования ngModel. Для меня это тонна накладных расходов и шаблонного кода, без которого я могу обойтись.

Изменить: вот это:

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
  };
}

Вот компонент, который его использует: (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>
Дэвид
источник
1
Интересно, что принятый ответ, похоже, перестал работать с RC2, я попробовал этот подход, и он работает, хотя не знаю почему.
3urdoch
1
@ 3urdoch Конечно, одна секунда
Дэвид
6
Чтобы заставить его работать с новым @angular/formsимпортом, просто обновите: import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
ulfryk
6
Provider () не поддерживается в Angular2 Final. Вместо этого используйте MakeProvider () return {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef (() => type), multi: true};
DSoa
2
Вам больше не нужно импортировать CORE_DIRECTIVESи добавлять их в @Componentфайл, поскольку они предоставляются по умолчанию, начиная с Angular2 final. Однако, согласно моей IDE, «Конструкторы для производных классов должны содержать вызов super», поэтому мне пришлось добавить super();в конструктор моего компонента.
Джозеф Уэббер
16

В этой ссылке есть пример для версии RC5: 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;
    }

}

Затем мы можем использовать этот настраиваемый элемент управления следующим образом:

<form>
  <custom-input name="someValue"
                [(ngModel)]="dataModel">
    Enter data:
  </custom-input>
</form>
Даниэль Кис
источник
4
Хотя эта ссылка может дать ответ на вопрос, лучше включить сюда основные части ответа и предоставить ссылку для справки. Ответы, содержащие только ссылки, могут стать недействительными, если ссылка на страницу изменится.
Максимилиан Аст,
5

Пример Тьерри полезен. Вот импорт, который необходим для запуска TagsValueAccessor ...

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';
Синий
источник
1

Я написал библиотеку , которая помогает уменьшить некоторые шаблонные для этого случая: s-ng-utils. В некоторых других ответах приводится пример упаковки одного элемента управления формой. Используя s-ng-utilsэто можно сделать очень просто с помощью 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);
  }
}

В своем сообщении вы упоминаете, что хотите объединить несколько элементов управления формой в один компонент. Вот полный пример этого с 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);
  }
}

Затем вы можете использовать <app-location>с [(ngModel)], [formControl], пользовательские валидаторы - все , что можно сделать с помощью кнопок управления угловые опоры из коробки.

Эрик Саймонтон
источник
-1

Зачем создавать новый метод доступа к значениям, если вы можете использовать внутренний ngModel. Всякий раз, когда вы создаете пользовательский компонент, в котором есть вход [ngModel], мы уже создаем экземпляр ControlValueAccessor. И это тот аксессуар, который нам нужен.

шаблон:

<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>

Составная часть:

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;
    }
}

Использовать как:

<my-input class="col-sm-6" label="First Name" name="firstname" 
    [(ngModel)]="user.name" required 
    minlength="5" maxlength="20"></my-input>
Нишант
источник
Хотя это выглядит многообещающе, раз уж вы называете супер, там отсутствует «расширяет»
Дэйв Ноттэдж
1
Ага, я не скопировал сюда весь свой код и забыл удалить super ().
Nishant
9
Кроме того, откуда взялась outerNgModel? Этот ответ будет лучше подавать с полным кодом
Дэйв Ноттэдж
Согласно angular.io/docs/ts/latest/api/core/index/… innerNgModel определено вngAfterViewInit
Маттео Суппо
2
Это вообще не работает. innerNgModel никогда не инициализируется, outerNgModel никогда не объявляется, а ngModel, переданный конструктору, никогда не используется.
user2350838