Entrada de formulário personalizado Angular 2

89

Como posso criar um componente personalizado que funcione como uma <input>tag nativa ? Quero fazer com que meu controle de formulário personalizado seja capaz de oferecer suporte a ngControl, ngForm, [(ngModel)].

Pelo que entendi, preciso implementar algumas interfaces para fazer meu próprio controle de formulário funcionar como o nativo.

Além disso, parece que a diretiva ngForm se vincula apenas a <input>tag, certo? Como posso lidar com isso?


Deixe-me explicar por que preciso disso. Quero agrupar vários elementos de entrada para torná-los capazes de trabalhar juntos como uma única entrada. Existe outra maneira de lidar com isso? Mais uma vez: quero fazer esse controle igualzinho ao nativo. Validação, ngForm, ngModel ligação bidirecional e outros.

ps: Eu uso o Typescript.

Maksim Fomin
fonte
1
A maioria das respostas está desatualizada em relação às versões angulares atuais. Dê uma olhada em stackoverflow.com/a/41353306/2176962
hgoebl

Respostas:

82

Na verdade, há duas coisas a serem implementadas:

  • Um componente que fornece a lógica do seu componente de formulário. Não é uma entrada, pois será fornecida por ngModelsi mesma
  • Um costume ControlValueAccessorque irá implementar a ponte entre este componente e ngModel/ngControl

Vamos dar uma amostra. Quero implementar um componente que gerencia uma lista de tags para uma empresa. O componente permitirá adicionar e remover tags. Quero adicionar uma validação para garantir que a lista de tags não está vazia. Vou defini-lo em meu componente conforme descrito abaixo:

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

O TagsComponentcomponente define a lógica para adicionar e remover elementos da tagslista.

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

Como você pode ver, não há nenhuma entrada neste componente, mas uma setValue(o nome não é importante aqui). Nós o usaremos posteriormente para fornecer o valor de ngModelpara o componente. Este componente define um evento para notificar quando o estado do componente (a lista de tags) é atualizado.

Vamos implementar agora o link entre este componente e ngModel/ ngControl. Isso corresponde a uma diretiva que implementa a ControlValueAccessorinterface. Um provedor deve ser definido para este acessador de valor em relação ao NG_VALUE_ACCESSORtoken (não se esqueça de usarforwardRef pois a diretiva é definida depois).

A diretiva anexará um ouvinte de evento no tagsChangeevento do host (ou seja, o componente ao qual a diretiva está anexada, ou seja, o TagsComponent). O onChangemétodo será chamado quando o evento ocorrer. Este método corresponde ao registrado pelo Angular2. Desta forma, ele estará ciente das alterações e atualizações de acordo com o controle de formulário associado.

O writeValueé chamado quando o valor vinculado a ngFormé atualizado. Depois de injetar o componente anexado (isto é, TagsComponent), poderemos chamá-lo para passar este valor (veja o setValuemétodo anterior ).

Não se esqueça de fornecer CUSTOM_VALUE_ACCESSORnas ligações da diretiva.

Aqui está o código completo do 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; }
}

Desta forma, quando eu removo todos os tagsda empresa, o validatributo do companyForm.controls.tagscontrole passa falsea ser automático.

Consulte este artigo (seção "Componente compatível com NgModel") para obter mais detalhes:

Thierry Templier
fonte
Obrigado! Você é incrível! Como você pensa - é realmente bom assim? Quer dizer: não use elementos de entrada e faça seus próprios controladores como: <textfield>, <dropdown>? É esta forma "angular"?
Maksim Fomin
1
Eu diria que se você deseja implementar seu próprio campo no formulário (algo customizado), use essa abordagem. Caso contrário, use elementos HTML nativos. Dito isso, se você quiser modularizar a maneira de exibir input / textarea / select (por exemplo, com Bootstrap3), você pode aproveitar o ng-content. Veja esta resposta: stackoverflow.com/questions/34950950/…
Thierry Templier
3
O código acima está faltando e tem algumas discrepâncias, como 'removeLabel' em vez de 'removeLabel'. Veja aqui um exemplo de trabalho completo. Obrigado Thierry por divulgar o exemplo inicial!
Azul
1
Encontre-o, importe de @ angular / forms em vez de @ angular / common e funciona. importar {NG_VALUE_ACCESSOR, ControlValueAccessor} de '@ angular / forms';
Cagatay Civici
1
este link também deve ser útil ..
refatorar
109

Não entendo porque todos os exemplos que encontro na internet têm que ser tão complicados. Ao explicar um novo conceito, acho que é sempre melhor ter o exemplo mais simples e funcional possível. Eu destilei um pouco:

HTML para formulário externo usando componente que implementa ngModel:

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

Componente independente (sem classe de 'acessador' separada - talvez eu esteja perdendo o ponto):

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

Na verdade, acabei de abstrair todas essas coisas para uma classe abstrata que agora estendo com todos os componentes de que preciso para usar o ngModel. Para mim, isso é uma tonelada de código indireto e clichê que posso dispensar.

Editar: Aqui está:

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

Este é um componente que o usa: (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
fonte
1
Curiosamente, a resposta aceita parece ter parado de funcionar desde RC2, tentei essa abordagem e funciona, mas não tenho certeza do porquê.
3urdoch
1
@ 3urdoch Claro, um segundo
David
6
Para fazer funcionar com as novas @angular/formsimportações, basta atualizar: import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
ulfryk
6
Provider () não é compatível com Angular2 Final. Em vez disso, faça com que MakeProvider () retorne {fornecer: NG_VALUE_ACCESSOR, useExisting: forwardRef (() => tipo), multi: true};
DSoa
2
Você não precisa mais importar CORE_DIRECTIVESe adicioná-los ao, @Componentpois eles são fornecidos por padrão desde o Angular2 final. No entanto, de acordo com meu IDE, "Construtores para classes derivadas devem conter uma 'super' chamada.", Então eu tive que adicionar super();ao construtor do meu componente.
Joseph Webber
16

Há um exemplo neste link para a versão 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;
    }

}

Podemos então usar esse controle personalizado da seguinte maneira:

<form>
  <custom-input name="someValue"
                [(ngModel)]="dataModel">
    Enter data:
  </custom-input>
</form>
Dániel Kis
fonte
4
Embora este link possa responder à pergunta, é melhor incluir as partes essenciais da resposta aqui e fornecer o link para referência. As respostas somente com link podem se tornar inválidas se a página vinculada mudar.
Maximilian Ast
5

O exemplo de Thierry é útil. Aqui estão as importações que são necessárias para o TagsValueAccessor rodar ...

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';
Azul
fonte
1

Eu escrevi uma biblioteca que ajuda a reduzir alguns clichê para este caso: s-ng-utils. Algumas das outras respostas são exemplos de agrupamento de um único controle de formulário. Usando s-ng-utilsque pode ser feito muito simplesmente usando 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);
  }
}

Em sua postagem, você menciona que deseja agrupar vários controles de formulário em um único componente. Aqui está um exemplo completo de como fazer isso com 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);
  }
}

Então você pode usar <app-location>com [(ngModel)], [formControl], validadores customizados - tudo o que você pode fazer com os controles suportes angulares fora da caixa.

Eric Simonton
fonte
-1

Por que criar um novo acessador de valor quando você pode usar o ngModel interno. Sempre que você está criando um componente personalizado que tem uma entrada [ngModel] nele, já estamos instanciando um ControlValueAccessor. E esse é o acessor de que precisamos.

modelo:

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

Componente:

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

<my-input class="col-sm-6" label="First Name" name="firstname" 
    [(ngModel)]="user.name" required 
    minlength="5" maxlength="20"></my-input>
Nishant
fonte
Embora pareça promissor, como você está chamando de super, está faltando um "estende"
Dave Nottage
1
Sim, não copiei todo o meu código aqui e esqueci de remover o super ().
Nishant
9
Além disso, de onde vem outerNgModel? Esta resposta seria melhor servida com o código completo
Dave Nottage
De acordo com angular.io/docs/ts/latest/api/core/index/… innerNgModel é definido emngAfterViewInit
Matteo Suppo
2
Isso não funciona de jeito nenhum. innerNgModel nunca é inicializado, outerNgModel nunca é declarado e ngModel passado para o construtor nunca é usado.
user2350838