Angular 2 do wprowadzania niestandardowych formularzy

93

Jak mogę stworzyć komponent niestandardowy, który będzie działał jak <input>tag natywny ? Chcę, aby moja niestandardowa kontrolka formularza obsługiwała ngControl, ngForm, [(ngModel)].

Jak rozumiem, muszę zaimplementować kilka interfejsów, aby moja własna formant formularza działała jak natywna.

Wygląda na to, że dyrektywa ngForm wiąże się tylko z <input>tagiem, czy to prawda? Jak sobie z tym poradzić?


Pozwól mi wyjaśnić, dlaczego w ogóle tego potrzebuję. Chcę owinąć kilka elementów wejściowych, aby mogły współpracować jako jedno wejście. Czy jest inny sposób, aby sobie z tym poradzić? Jeszcze raz: chcę, aby ta kontrolka była jak natywna. Validation, ngForm, ngModel dwukierunkowe wiązanie i inne.

ps: Używam Typescript.

Maksim Fomin
źródło
1
Większość odpowiedzi dotyczących aktualnych wersji Angular jest nieaktualna. Zajrzyj na stackoverflow.com/a/41353306/2176962
hgoebl

Odpowiedzi:

85

W rzeczywistości należy wdrożyć dwie rzeczy:

  • Składnik zapewniający logikę komponentu formularza. Nie potrzebuje danych wejściowych, ponieważ zostanie dostarczony ngModelsamodzielnie
  • Niestandardowy ControlValueAccessor, który zaimplementuje most między tym komponentem a ngModel/ngControl

Weźmy próbkę. Chcę zaimplementować komponent zarządzający listą tagów dla firmy. Komponent pozwoli na dodawanie i usuwanie tagów. Chcę dodać weryfikację, aby upewnić się, że lista tagów nie jest pusta. Zdefiniuję to w moim komponencie, jak opisano poniżej:

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

TagsComponentKomponent definiuje logikę dodawania i usuwania elementów w tagsliście.

@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 = '';
  }
}

Jak widać, w tym komponencie nie ma wejścia, ale setValuejeden (nazwa nie jest tutaj ważna). Użyjemy go później do podania wartości z elementu ngModeldo komponentu. Ten komponent definiuje zdarzenie, które ma być powiadamiane o aktualizacji stanu komponentu (listy tagów).

Zaimplementujmy teraz połączenie między tym komponentem a ngModel / ngControl. Odpowiada to dyrektywie implementującej ControlValueAccessorinterfejs. Dostawca musi być zdefiniowany dla tego akcesora wartości względem NG_VALUE_ACCESSORtokenu (nie zapomnij go użyć, forwardRefponieważ dyrektywa jest zdefiniowana później).

Dyrektywa dołączy detektor zdarzeń do tagsChangezdarzenia hosta (tj. Komponentu, do którego jest dołączona dyrektywa, tj. TagsComponent). onChangeMetoda zostanie wywołana, gdy wystąpi zdarzenie. Ta metoda odpowiada metodzie zarejestrowanej przez Angular2. W ten sposób będzie świadomy zmian i odpowiednio aktualizuje powiązaną kontrolkę formularza.

Plik writeValueJest wywoływana, gdy wartość związana w ngFormjest aktualizowana. Po wstrzyknięciu dołączonego komponentu (tj. TagsComponent), będziemy mogli wywołać go w celu przekazania tej wartości (zobacz poprzednią setValuemetodę).

Nie zapomnij podać CUSTOM_VALUE_ACCESSORw wiązaniach dyrektywy.

Oto pełny kod niestandardowego 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; }
}

W ten sposób, gdy usunę całą tagsfirmę, plikvalid atrybut companyForm.controls.tagskontroli staje się falseautomatycznie.

Więcej informacji można znaleźć w tym artykule (sekcja „Komponent zgodny z NgModel”):

Thierry Templier
źródło
Dzięki! Jesteś niesamowity! Jak myślisz - czy ten sposób jest w porządku? Mam na myśli: nie używaj elementów wejściowych i twórz własne kontrolery, takie jak: <textfield>, <dropdown>? Czy jest to sposób „kanciasty”?
Maksim Fomin
1
Powiedziałbym, że jeśli chcesz zaimplementować własne pole w formularzu (coś niestandardowego), użyj tego podejścia. W przeciwnym razie użyj natywnych elementów HTML. To powiedziawszy, jeśli chcesz modularyzować sposób wyświetlania wejścia / obszaru tekstu / wyboru (na przykład za pomocą Bootstrap3), możesz wykorzystać ng-content. Zobacz tę odpowiedź: stackoverflow.com/questions/34950950/…
Thierry Templier
3
W powyższym brakuje kodu i występują w nim rozbieżności, na przykład „removeLabel” zamiast „removeLabel”. Zobacz tutaj pełny przykład roboczy. Dzięki Thierry za udostępnienie pierwszego przykładu!
Blue
1
Znalazłem to, importuj z @ angular / form zamiast @ angular / common i działa. importuj {NG_VALUE_ACCESSOR, ControlValueAccessor} z „@ angular / forms”;
Cagatay Civici
1
ten link też powinien być pomocny ...
refactor
110

Nie rozumiem, dlaczego każdy przykład, który znajduję w Internecie, musi być tak skomplikowany. Przy wyjaśnianiu nowej koncepcji myślę, że zawsze najlepiej jest mieć jak najprostszy, działający przykład. Trochę to przedestylowałem:

HTML dla zewnętrznego formularza za pomocą komponentu implementującego ngModel:

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

Samodzielny komponent (brak oddzielnej klasy „akcesor” - być może brakuje mi sensu):

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

W rzeczywistości właśnie wyabstrahowałem to wszystko do klasy abstrakcyjnej, którą teraz rozszerzam o każdy komponent, którego potrzebuję, aby użyć ngModel. Dla mnie jest to mnóstwo kodów ogólnych i standardowych, bez których mogę się obejść.

Edycja: oto jest:

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

Oto komponent, który go używa: (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>
David
źródło
1
Co ciekawe, przyjęta odpowiedź wydaje się przestać działać od czasu RC2, wypróbowałem to podejście i działa, chociaż nie wiem dlaczego.
3urdoch
1
@ 3urdoch Jasne, sekunda
David
6
Aby to działało z nowym, @angular/formswystarczy zaktualizować import: import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
ulfryk
6
Provider () nie jest obsługiwany w wersji Angular2 Final. Zamiast tego, niech MakeProvider () return {include: NG_VALUE_ACCESSOR, useExisting: forwardRef (() => type), multi: true};
DSoa
2
Nie musisz już ich importować CORE_DIRECTIVESi dodawać, @Componentponieważ są one dostarczane domyślnie od czasu wersji ostatecznej Angular2. Jednak według mojego IDE „Konstruktory klas pochodnych muszą zawierać wywołanie„ super ”.”, Więc musiałem dodać super();do konstruktora mojego komponentu.
Joseph Webber,
16

W tym linku znajduje się przykład dla wersji 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;
    }

}

Następnie możemy użyć tej kontrolki niestandardowej w następujący sposób:

<form>
  <custom-input name="someValue"
                [(ngModel)]="dataModel">
    Enter data:
  </custom-input>
</form>
Dániel Kis
źródło
4
Chociaż ten link może odpowiedzieć na pytanie, lepiej jest zawrzeć tutaj zasadnicze części odpowiedzi i podać link do odniesienia. Odpowiedzi zawierające tylko łącze mogą stać się nieprawidłowe, jeśli połączona strona ulegnie zmianie.
Maximilian Ast
5

Przykład Thierry'ego jest pomocny. Oto importy potrzebne do uruchomienia 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';
niebieski
źródło
1

Napisałem bibliotekę, która pomaga zredukować pewne boilerplate dla tego przypadku: s-ng-utils. Niektóre inne odpowiedzi podają przykład zawijania pojedynczej kontrolki formularza. Korzystanie s-ng-utilsco można zrobić w bardzo prosty sposób za pomocą 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);
  }
}

W swoim poście wspominasz, że chcesz zawinąć wiele kontrolek formularza w jeden składnik. Oto pełny przykład, jak to zrobić z 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);
  }
}

Można wtedy skorzystać <app-location>z [(ngModel)], [formControl], niestandardowe validators - wszystko, co można zrobić z kontroli kątowe wsporniki po wyjęciu z pudełka.

Eric Simonton
źródło
-1

Po co tworzyć nowe akcesorium wartości, skoro można użyć wewnętrznego ngModel. Za każdym razem, gdy tworzysz komponent niestandardowy, który ma dane wejściowe [ngModel], już tworzymy wystąpienie ControlValueAccessor. I to jest akcesorium, którego potrzebujemy.

szablon:

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

Składnik:

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

Użyj jako:

<my-input class="col-sm-6" label="First Name" name="firstname" 
    [(ngModel)]="user.name" required 
    minlength="5" maxlength="20"></my-input>
Nishant
źródło
Choć wygląda to obiecująco, ponieważ dzwonisz jako super, brakuje opcji „rozszerzeń”
Dave Nottage
1
Tak, nie skopiowałem tutaj całego kodu i zapomniałem usunąć super ().
Nishant
9
Skąd się bierze zewnętrznyNgModel? Ta odpowiedź byłaby lepsza z pełnym kodem
Dave Nottage
Według angular.io/docs/ts/latest/api/core/index/… innerNgModel jest zdefiniowane wngAfterViewInit
Matteo Suppo
2
To w ogóle nie działa. InternalNgModel nigdy nie jest inicjowany, externalNgModel nigdy nie jest deklarowany, a ngModel przekazany do konstruktora nigdy nie jest używany.
user2350838