Ionic-framework: Custom Value Accessors with ion-input (Ionic 2)

Created on 19 May 2016  路  29Comments  路  Source: ionic-team/ionic-framework

hey guys!

I'm having problem using a custom value accessor with ion-input.
what I'm trying to do is to format an input text field while the user is typing, to enhance the user experience, like this: 12345678900a => 123.456.789-00 (it removes all the non-digit characters and apply the dots and dash).

to achieve this, I've created a custom value accessor as discussed in https://github.com/angular/angular/issues/6174.

the problem is that when I use the custom directive with a "pure" input, the value saved to ngFormControl is correct, but when I use the same directive in a ion-input, the value passed to the ngFormControl is not the one that the user is actually seeing, but the value before the formatting (for instance, if the user types 123a, what he sees in the input is 123, but the value in the model is 123a).

thank you and congrats for the outstanding work with Ionic 2!


the code (excerpts) from the custom value accessor, form component and form template:

custom value accessor

@Directive ({
  selector: '[agl-id-num]',
  host: {
    '(input)': 'doOnChange($event.target)',
    '(blur)': 'doOnBlur($event.target)'
  },
  bindings: [ CUSTOM_VALUE_ACCESSOR ]
})
export class IdNumAccessor extends DefaultValueAccessor {

  constructor(_renderer: Renderer, _elementRef: ElementRef) {
    super(_renderer, _elementRef);
  }

  writeValue(value:any):void {
    if (value!=null) {
      super.writeValue(value);
    }
  }

  doOnChange(elt) {
    // prevent user to input non-digit characters while typing
    // and limit user input to 11 characters
    let val = elt.value
      .replace(/\D/g, '')
      .substring(0,11);

    elt.value = val; // this is what changes the input value as user types

    this.onChange(val); // this is what saves the value to the model
  }

  doOnBlur(elt) {
    // format field on blur only
    let val = elt.value;
    if (val.length === 11) {
      elt.value = val.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
    }
  }
}

form component

@Component({
  selector: 'agl-client-register-form',
  templateUrl: 'build/components/clients/client-register-form/client-register-form.component.html',
  directives: [ FORM_DIRECTIVES, IdNumAccessor ]
})
export class ClientRegisterFormComponent implements OnInit {
  form: ControlGroup;  

  @Output() onFormSubmit = new EventEmitter();
  @Input() initialValues;

  constructor(
    private fb: FormBuilder) {}

  ngOnInit() {
    console.log(this.initialValues);

    this.form = this.fb.group({  
      'name':               [this.initialValues.name, Validators.required],
      'idNum':              [this.initialValues.idNum, Validators.required],
      'idNumTest':          [this.initialValues.idNum, Validators.required],
      'email':              [this.initialValues.email],
      'celphone':           [this.initialValues.celphone],
      'sendBoletoToMyself': [this.initialValues.sendBoletoToMyself]
    });
  }

  onSubmit():void {
    console.log(this.form.value);
  }
}

form template

<form novalidate
    [ngFormModel]="form"
    (ngSubmit)="onSubmit()">
    <ion-list>
        <!-- using with "pure" input -->
        <input
            agl-id-num
            type="tel"
            [ngFormControl]="form.controls['idNumTest']"
            required />
        <ion-item>
            <ion-label floating>Id Num*</ion-label>
            <!-- using with ion-input (described problem) -->
            <ion-input
                agl-id-num
                type="tel"
                [ngFormControl]="form.controls['idNum']"
                required>
            </ion-input>
        </ion-item>
    </ion-list>
    <div padding>
        <button block type="submit">Register</button>
    </div>
</form>

using Ionic 2.0.0-beta.6

v3

All 29 comments

Hello! Thanks for opening an issue with us! Would you mind making a plunker that demonstrates this issue please?

sure! there it is: http://embed.plnkr.co/b2rvq3uHcrxpmzTZ23f1/

I've applied the custom num accessor to both inputs (the ion one and the "pure" one).
note that in the ion-input, when I type a non digit character it is reflected in the form.value, but in the non-ion input it doesn't happen.

This might be related to https://github.com/angular/angular/issues/5328. Since upgrading to Ionic 2.0.0-beta.7 I'm struggling with the issue that Ionic and Angular form handling seems to be constantly out of sync by one event.

Edit: My issue only occurs when using ChangeDetectionStrategy.OnPush though.

@robertdp hum... it could be somehow related. thanks for referencing the issue!

in my case, what seems to be happening is that the DefaultValueAccessor is conflicting with my IdNumAccessor in the ion-input: the onChange event exists in both accessors, but only when used in an ion-input (with html input this doesn't happen, as shown in the plunker) it looks like the onChange in the IdNumAccessor is not being triggered and therefore the related model is binded to the value _before_ the element.value change that happens in IdNumAccessor.

Yeah, the similarities were superficial. The deeper I went into the issue with change detection the less it seemed to apply to this issue.

any news on this issue?

(ps: upgrading to the current beta 8 does not help on this...)

This is still a problem in 2.0.0-beta.10

Hi @endoooo,
any news on this issue? I have the same problem with ionic 2 RC-0

Regards

hi @jbgomez21!

i think it's still an issue... unfortunately.

in the end we used HTML inputs and styled them like ion-inputs. it's not the perfect solution and it was not what we really wanted (all the MD animations were gone), but it worked.

Hi @endoooo
It is not an ionic 2 problem
I have created a plunker example about this problem.
http://embed.plnkr.co/lWpjyCwL0l2r06u1BI0a/ (Running on Chrome)

with 16 digits work fine, but with 17 digits the input label show 1 extra character!

I think that I have a conceptual problem about what happened. Can someone explain it to me please?

@endoooo @jbgomez21 You can pass the FormControl inside the directive and call setValue manually. This way the FromGroup will be updated

@jbgomez21 I've made some changes in your plunker, and now it's working as it should: https://embed.plnkr.co/0vfCj6AyjfQgLGxDtDpj/

there were some problems with your implementation:

  • the value accessor (which is a @Directive) was mixed with the input @Component. roughly speaking, the value accessor cares about which value should be presented in the component and how it should be saved in its model, and the input component cares about the user interface.
  • your model was a primitive (string). the problem with this approach is that primitives values are passed by value and not by reference in javascript, which can cause some problems when you need to traverse the data across multiple components (3+) in the component tree.
  • the way your were passing @Input values to the child components were a bit messy (no []s).

there were some other changes I've made. check the code and see if you can understand them.

@NelsonBrandao thanks for the tip. I didn't test it (so it may work), but I don't think passing FormControl inside the value accessor is a clean solution. the point of using the value accessor's onChange is exactly to reflect the value to the control model without referencing the FormControl directly, isn't it?

@endoooo Thank you very much for your corrections.
They have been very helpful for me and surely be for other people.

Regards!

@endooo Yup, not the best solution, just a quick fix until there is better one that works on ion-input

Hi @endoooo

This Directive work for me with javascript<ion-input>.
Please, look at and give me your comment

import { Directive, ElementRef, Renderer, OnInit } from '@angular/core';
import { DefaultValueAccessor, NgControl } from '@angular/forms';

declare var VMasker: any;

const CardMaskLength: number = 16;
const Mask: string = '9999-9999-9999-9999';

@Directive({
  selector: '[card-mask][ngModel]', // Attribute selector
  providers: [DefaultValueAccessor]
})
export class CardMask implements OnInit {
  constructor(
    private renderer: Renderer,
    private elementRef: ElementRef, private model: NgControl) { }


  writeValue(value: any): void {
    // Write to view        
    if (value !== null && value !== undefined) {

      value = VMasker.toPattern(value, Mask);

      this.model.valueAccessor.writeValue(value);
      this.renderer.setElementProperty(this.elementRef.nativeElement.firstChild, 'value', value);    
    }
  }

  valueChange(value) {
    // prevent user to input non-digit characters while typing
    // and limit user input to CardMaskLength characters         
    let val = value.replace(/\D/g, '');
    val = val.substring(0, CardMaskLength);    

    //write formatted to control view
    this.writeValue(val);

    this.model.viewToModelUpdate(val);    
  }

  ngOnInit() {
    this.model.valueChanges.subscribe((value : any) => {
        this.valueChange(value);
    });
  }
}

then, I can use it

<ion-input type="text" placeholder="1234 5678 9012 3456" card-mask [(ngModel)]="_model.cardNumber"></ion-input>

<p>{{_model.cardNumber}}</p>

I do not know why I have to put both statements, but it works.
If I remove one does not work properly.

      this.model.valueAccessor.writeValue(value);
      this.renderer.setElementProperty(this.elementRef.nativeElement.firstChild, 'value', value);    

Can someone explain it to me please?

sorry for my bad english :-)

@jbgomez21 maybe I'm wrong (because I don't have a deep knowledge about what ng2 is doing behind the scenes), but I think that what you're doing here is imitating (in a simpler way) the implementation of the custom value accessor.

it should work for what you need, but I don't know for sure what are the side effects of this implementation. I have three main points of concern with your directive:

  • you are manually implementing some things that should happen "automatically" (or in different ways), like the valueChanges subscription as a way to trigger the this.valueChange(value) and the this.model.viewToModelUpdate(val), for instance. my concern here is that if Angular use those events to trigger another ones, it will start to happen twice
  • if I figured it out right, the this.model.valueAccessor.writeValue(value) may be triggering the DefaultValueAccessor writeValue method, which is:
writeValue(value: any): void {
  var normalizedValue = isBlank(value) ? '' : value;
  this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', normalizedValue);
}

this doesn't feel right, right? 馃槃
now, in order to figure out why this only works when you use both writeValue and setElementProperty, I'd need to run some tests, but I don't have the time for doing it now... 馃樁

  • and last but not least, how will it work with FormControl and FormGroup validators?

what do you think?

if anyone else could express your thoughts about this topic, it would be great!

@endoooo
Yes, It works with FormControl and FormGroup validators.

I am agree that it isn't the best solution, but my intent was someone explain to me why this works :-)
I don't know why with ion-input doesn't work @Directive that implements CustomValueAccessor. :-(

Regards!

@endoooo and @jbgomez21, thanks for your effort, but how can I solve this Can't bind to 'mask' since it isn't a known property of 'ion-input'. :sob:

<ion-input [mask] placeholder="Manu谩lne zadanie k贸du"
                    formControlName="serialNumber">
                </ion-input>

Hi @luckylooke
Please give me more detail about what are you doing.
If you have a "mask" @Directive then sintaxis is <ion-input mask placeholder="..."> </ion-input>

[mask] refer to input property that ion-input doesn't have

Regards.

mask directive is copy of your card-mask and I wanted to be input so I can pass mask as parameter like so: <ion-input [mask]="[0-9]{6}-[0-9]{6}" placeholder="..."> </ion-input> but it is true that mask attribute directive without [] is accepted by ionic input. Thanks for hint, maybe this will help me go forward 馃

Hi @luckylooke

I attached card-input.zip files, It's a fragment of how I implemented the mask for card input.
I finally wrapped ion-input tag.

I used it that way:

<card-input [card-brand]="card.id" [card-length]="card.length" formControlName="cardNumber" placeholder="1234 5678 9012 3456" [(ngModel)]="card.cardNumber" (ngModelChange)="onChangeCardNumber($event)" \> </card-input>
good lucky!

card-input.zip

Thank you @jbgomez21, I will try it in project and let you know then.

TLDR: Works in angular, but Ionic structures ion-input components differently so use a service(provider in Ionic).

TLDR2: It might be possible, but in an HTML DOM Input Text Object(<input>)(https://www.w3schools.com/jsref/dom_obj_text.asp), the value of the input is stored within an property of the input object. We don't have to access An ion-input tag, while expressed as an HTML tag, represents a _component_, and the value of the input is now stored in the component, and not within a node on the DOM. While easily implemented in angular as a directive, The ionic framework unintentionally(I assume) makes this type of behavior very difficult/maybe not possible for a directive, instead you should use a service to manipulate the data.

FULL:
I have been working on a problem that contains a very similar issue, the only difference is that I am formatting a phone number. My current solution works for an HTML input tag but it does not work for the ion-input tag, very similar to the issue in this thread. I don't have a solution yet, but here are some considerations after digging around in angular's and ionic's source code and lots of debugging. Also I am wanting to implement this as a custom attribute directive.

Firstly: I am using functions from the Renderer2 Angular library, specifically listen and setProperty. I use the listen():
renderer2.listen(this.nativeElement, 'input', (event => { //process the input string which is accessed by event.target.value }
and then after formatting setProperty():
this.renderer2.setProperty(this.nativeElement, 'value', returnString)

Why this works for an HTML input tag but not an ion-input tag: Initially I thought I had found a solution and was pretty happy with myself, turns out it didn't fix this issue but helped me learn a lot.

  1. Starting in the Ionic docs: Ionic documentation for input
    Within input.ts, (the ion-input and ion-textarea components are defined here) there is a line that says: copyInputAttributes(ionInputEle, nativeInputEle);
    This function takes the attributes from an input tag and copies them onto an ion-input tag. The ionic docs say as much, 'Ionic still uses an actual HTML element within the component, however, with Ionic wrapping the native HTML input element it's better able to handle the user experience and interactivity.' Okay so if all the attributes from a normal input tag are copied over, then why isn't setting the 'value' property working. Turns out we need to look at the source of copyInputAttributes() for the full story.
  2. copyInputAttributes() is defined in dom.ts
    Right before the function, there is a const defined as:
    const SKIP_INPUT_ATTR = ['value', 'checked', 'disabled', 'readonly', 'placeholder', 'type', 'class', 'style', 'id', 'autofocus', 'autocomplete', 'autocorrect'];
    So copyInputAttributes() excludes all attributes defined in SKIP_INPUT_ATTR
    The docs say this:
    // copy attributes from one element to another
    // however, skip over a few of them as they're already
    // handled in the angular world
  1. OKAY. So I see that 'value' is the first item in the list to be excluded. Well shoot, I'm trying to use Renderer2 to set the 'value' attribute of the 'nativeElement'. If the value attribute doesn't exist on ion-input then clearly this won't work, Ionic excludes the value attribute when constructing an ion-input, and in my directive, I'm trying to set the value of a property that doesn't exist.
    At this point I thought I had mastered the world. I went into the ionic-angular module of a test-project and found dom.ts and removed 'value' from SKIP_INPUT_ATTR.

  2. it did not work, I was freakin pissed, f* computers I want to go back to working outside again.

  3. A few hours later I added some console.log's and looked at it again. I inspected the ion-input, and this time there was a value property! And when I typed in a phone number, while it wasn't being formatted in the view, upon inspection the value property was showing a formatted input(such as (888) 888-888 ). So what I was trying to do actually did work, but what I was trying to do wasn't going to fix the problem.

Why this didn't work from the beginning should have been obvious to me. If the 'value' attribute is on the list of 'SKIP_INPUT_ATTR', then no part of ionic-angular will be looking for a value at the 'value' property that normally doesn't exist!

So at this point I've learned that ion-inputs don't use the html value property to store the value of the input. This should have been obvious to me. ion-input's are COMPONENTS!!! I've always though of them as just html tags. The value that the component represent is probably much better defined inside a component instead of an attribute on a HTML node.

Okay so now I need to figure out how and where ion-input's store the value of their input, presumably the variable name will be something like 'value'

  1. So input.ts doesn't have any type of 'value' variable, BUT it does extend BaseInput, and BaseInput does have a variable defined as '_value: T' . _value is of type ' T ', which is a generic. This T allows the function to capture the type that is provided when implemented.
    **input.ts does have
    @Output() input = new EventEmitter<UIEvent>();
    but I think this is for emitting a value on a 'UIEvent'

STOP.

I went and read the Angular documentation on the directive decorator and have come to some realizations.

  1. The first line on the Angular documentation for Attribute Directives.

An Attribute directive changes the appearance or behavior of a DOM element.

The formatting that we are trying to achieve here changes the actual value(in my case it does). So as much as I want to develop this as an attribute directive that you can easily insert into the template, I think angular is telling us that this would better be implemented in a service.

I'm not sure how clear my above post was so here are two points to take away.

  • Within an ion-input, the value of the input is stored in the component. This is a different structure than that of an HTML input tag. So when tying to use this directive in an ion-input, we now have to update the value INSIDE the component.

  • From Angulars page on Attribute Directives.

    An Attribute directive changes the appearance or behavior of a DOM element.

This makes me think that the behaviour we want to see, be it formatting a phone number or something else, would better be implemented as a service, since we are altering the actual value of the component.

Please feel free to tell me I'm wrong, I would like to be able to use this as a simple attribute directive within my Ionic projects.

Any update on this? The suggested workarounds aren't particularly neat.

I've created a directive that works with normal inputs as well as ion-inputs, if anyone needs it: https://stackoverflow.com/a/46981629/4850646

Thanks for the issue! We have moved the source code and issues for Ionic 3 into a separate repository. I am moving this issue to the repository for Ionic 3. Please track this issue over there.

Thank you for using Ionic!

Thanks for the issue! We have moved the source code and issues for Ionic 3 into a separate repository. I am moving this issue to the repository for Ionic 3. Please track this issue over there.

Thank you for using Ionic!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

brandyscarney picture brandyscarney  路  3Comments

brandyscarney picture brandyscarney  路  3Comments

daveshirman picture daveshirman  路  3Comments

danbucholtz picture danbucholtz  路  3Comments

SebastianGiro picture SebastianGiro  路  3Comments