Search code examples
angularionic2angular2-directives

How to implement a currency input directive for ion-input


Update: Originally though the problem was in the implementation of the ControlValueAccessor and subsequently determined the issue was about applying the ControlValueAccessor to child elements. Question edited to reflect.

I want to provide an attribute directive that would show a currency value in 'dollar' format (e.g. 10.00) but would be stored in the underlying model as cents (e.g. 1000).

<!-- cost = 1000 would result in text input value of 10.00
<input type="text" [(ngModel)]="cost" name="cost" currencyInput>
<!-- or in Ionic 2 -->
<ion-input type="text" [(ngModel)]="cost" name="cost-ionic" currencyInput>

Previously in AngularJS 1.x I would use parse and render in the directives link function as follows:

(function() {
    'use strict';

    angular.module('app.directives').directive('ndDollarsToCents', ['$parse', function($parse) {
        return {
            require: 'ngModel',
            link: function(scope, element, attrs, ctrl) {
                var listener = function() {
                    element.val((value/100).toFixed(2));
                };

                ctrl.$parsers.push(function(viewValue) {
                    return Math.round(parseFloat(viewValue) * 100);
                });

                ctrl.$render = function() {
                    element.val((ctrl.$viewValue / 100).toFixed(2));
                };

                element.bind('change', listener);
            }
        };
    }]);
})();

In Ionic 2/Angular 2 I implemented this using the ControlValueAccessor interface has follows:

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

const CURRENCY_VALUE_ACCESSOR = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => CurrencyInputDirective),
  multi: true
}

@Directive({
    selector: '[currencyInput]',
    host: {
        '(input)': 'handleInput($event.target.value)'
     },
     providers: [ CURRENCY_VALUE_ACCESSOR ]
})
export class CurrencyInputDirective implements ControlValueAccessor, AfterViewInit
{
    onChange = (_: any) => {};
    onTouched = () => {};
    inputElement: HTMLInputElement = null;

    constructor(private renderer: Renderer, private elementRef: ElementRef) {}

    ngAfterViewInit()
    {
        let element = this.elementRef.nativeElement;

        if(element.tagName === 'INPUT')
        {
            this.inputElement = element;
        }
        else
        {
             this.inputElement = element.getElementsByTagName('input')[0];
        }
    }

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

    handleInput(value : string)
    {
        if (value)
        {
            value = String(Math.round(parseFloat(value) * 100));
        }

        this.onChange(value);
    }


    writeValue(value: any): void
    {
        if (value)
        {
            value = (parseInt(value) / 100).toFixed(2);
        }

        this.renderer.setElementProperty(this.inputElement, 'value', value);
    }
}

While this works fine when applied to a straight input element when applied to an ion-input it does not function. Is there a way I can get the ControlValueAccessor to apply to the child input element of the ion-input?


Solution

  • While the ControlAccessorValue approach was suitable for a plain <input> element I could not get the directive to act as a control accessor for the <input> child element of the <ion-input> directive (interested in an solution for this).

    Alternatively the following directive, achieves the desired result when applied to a <ion-input> or plain <input>:

    import { Directive, Renderer, ElementRef, Input, Output, EventEmitter, AfterViewInit } from '@angular/core';
    
    @Directive({
        selector: '[currencyInput]',
        host: {
            '(input)': 'handleInput($event.target.value)'
        },
    })
    export class CurrencyInputDirective implements AfterViewInit
    {
    
        @Input('currencyInput') currency: number;
        @Output('currencyInputChange') currencyChange: EventEmitter<number> = new EventEmitter<number>();
        inputElement: HTMLInputElement = null;
    
        constructor(private renderer: Renderer, private el: ElementRef) { }
    
        ngAfterViewInit()
        {
            let element = this.el.nativeElement;
    
            if(element.tagName === 'INPUT')
            {
                this.inputElement = element;
            }
            else
            {
                this.inputElement = element.getElementsByTagName('input')[0];
            }
    
            setTimeout(() => {
                this.renderer.setElementProperty(this.inputElement, 'value', (this.currency/100).toFixed(2));
            }, 150);
        }
    
        handleInput(value: string)
        {
            let v : number = Math.round(parseFloat(value) * 100)
            this.currencyChange.next(v);
        }
    }
    

    And then applying it on the element as follows:

    <ion-input type="text" name="measured_cost" [(currencyInput)]="item.cost">
    

    The setTimeout is required to ensure that the input field is initialised by the CurrencyInputDirective rather than ionic (I welcome a better alternative).

    This works fine but it only provides one way flow i.e. if the item.cost is changed outside of the input element it is not reflected in the input element value. This issue can be addressed using a setter method for currencyInput as shown in the following this less performant solution:

    import { Directive, Renderer, ElementRef, Input, Output, EventEmitter, AfterViewInit } from '@angular/core';
    @Directive({
        selector: '[currencyInput]',
        host: {
            '(input)': 'handleInput($event.target.value)'
        },
    })
    export class CurrencyInputDirective implements AfterViewInit
    {
        _cents: number;
        myUpdate: boolean = false;
        inputElement: HTMLInputElement = null;
        @Input('currencyInput')
        set cents(value: number) {
            if(value !== this._cents)
            {
                this._cents = value;
                this.updateElement();
            }
        }
        @Output('currencyInputChange') currencyChange: EventEmitter<number> = new EventEmitter<number>();
    
        constructor(private renderer: Renderer, private el: ElementRef) { }
    
        ngAfterViewInit()
        {
            let element = this.el.nativeElement;
    
            if(element.tagName === 'INPUT')
            {
                this.inputElement = element;
            }
            else
            {
                this.inputElement = element.getElementsByTagName('input')[0];
            }
    
            setTimeout(() => {
                this.renderer.setElementProperty(this.inputElement, 'value', (this._cents/100).toFixed(2));
            }, 150);
        }
    
        handleInput(value: string)
        {
            let v : number = Math.round(parseFloat(value) * 100);
            this.myUpdate = true;
            this.currencyChange.next(v);
        }
    
        updateElement()
        {
            if(this.inputElement)
            {
                let startPos = this.inputElement.selectionStart;
                let endPos = this.inputElement.selectionEnd;
    
                this.renderer.setElementProperty(this.inputElement, 'value', (this._cents/100).toFixed(2));
                if(this.myUpdate)
                {
                    this.inputElement.setSelectionRange(startPos, endPos);
                    this.myUpdate = false;
                }
            }
        }
    }