22

I'm having an issue formatting an input field, while leaving the underlying scope variable non-formatted.

What I want to achieve is a text field to display currency. It should format itself on the fly, while handling wrong input. I got that working, but my problem is that I want to store the non-formatted value in my scope variable. The issue with input is that it requires a model which goes both ways, so changing the input field updates the model, and the other way around.

I came upon $parsers and $formatters which appears to be what I am looking for. Unfortunately they are not affecting each other (which might actually be good to avoid endless loops).

I've created a simple jsFiddle: http://jsfiddle.net/cruckie/yE8Yj/ and the code is as follows:

HTML:

<div data-ng-app="app" data-ng-controller="Ctrl">
    <input type="text" data-currency="" data-ng-model="data" />
    <div>Model: {{data}}</div>
</div>

JS:

var app = angular.module("app", []);

function Ctrl($scope) {
    $scope.data = 1234567;
}

app.directive('currency', function() {
    return {
        restrict: 'A',
        require: 'ngModel',
        link: function (scope, element, attr, ctrl) {

            ctrl.$formatters.push(function(modelValue) {
                return modelValue.toString().replace(/\B(?=(?:\d{3})+(?!\d))/g, ',');
            });

            ctrl.$parsers.push(function(viewValue) {
                return parseFloat(viewValue.replace(new RegExp(",", "g"), ''));
            });
        }
    };
});

Again, this is just a simple example. When it loads everything looks as it's supposed to. The input field is formatted and the variable is not. However, when changing the value in the input field it no longer formats itself - the variable however gets updated correctly.

Is there a way to ensure the text field being formatted while the variable is not? I guess what I am looking for is a filter for text fields, but I can't seen to find anything on that.

Best regards

6 Answers 6

17

Here's a fiddle that shows how I implemented the exact same behavior in my application. I ended up using ngModelController#render instead of $formatters, and then adding a separate set of behavior that triggered on keydown and change events.

http://jsfiddle.net/KPeBD/2/

Sign up to request clarification or add additional context in comments.

9 Comments

Thanks for the reply. It looks promising and a good basis for what I want to achieve! I'll look into it tomorrow and come back to let you know if it worked out.
Hi again. I've looked into your example and made it work as I wanted to. Thank you! I do however have a question: What exactly does $browser.defer(listener) do? Replacing it with listener() appears to have the same effect. Also removing the change event doesn't seem to have an affect either. Is it safe to make these changes or is there a good reason for them to be there?
Looks like change was a legacy of my first pass at implementing. Probably isn't necessary anymore. The $browser.defer call basically runs it on a timeout of 0. If you switch to just listener(), I had issues where the formatting was always one keypress behind. So an input of 12345 would have formatting of 1,2345, since it was highlighted based on the previous input of 1234. The timeout of 0 fixes this.
I was able to update this to work for version 1.2.23 (and probably higher) by simply passing 0 instead of false to the two usages of the number filter
In addition to changing those 'false' values as second arguments to angular's number filter, I had to fiddle with the code a bit more to make changes to the model appear correctly in the input. Specifically, using the code as posted resulted in NaN being put into the input rather than the data stored in the model. To remedy this, I used a formatter instead of $render: controller.$formatters.push(function(value) { element.val($filter('number')(value)); });
|
5

I've revised a little what Wade Tandy had done, and added support for several features:

  1. thousands separator is taken from $locale
  2. number of digits after decimal points is taken by default from $locale, and can be overridden by fraction attribute
  3. parser is activated only on change, and not on keydown, cut and paste, to avoid sending the cursor to the end of the input on every change
  4. Home and End keys are also allowed (to select the entire text using keyboard)
  5. set validity to false when input is not numeric, this is done in the parser:

            // This runs when we update the text field
        ngModelCtrl.$parsers.push(function(viewValue) {
            var newVal = viewValue.replace(replaceRegex, '');
            var newValAsNumber = newVal * 1;
    
            // check if new value is numeric, and set control validity
            if (isNaN(newValAsNumber)){
                ngModelCtrl.$setValidity(ngModelCtrl.$name+'Numeric', false);
            }
            else{
                newVal = newValAsNumber.toFixed(fraction);
                ngModelCtrl.$setValidity(ngModelCtrl.$name+'Numeric', true);
            }
            return newVal;
    
        });
    

You can see my revised version here - http://jsfiddle.net/KPeBD/64/

1 Comment

nice work, however: the localization is not done by just using $locale.NUMBER_FORMATS.GROUP_SEP, there is also the comma separator which has to be treated. In Germany the format is 1.200,50 although the javascript number will be 1200.5
5

I have refactored the original directive, so that it uses $parses and $formatters instead of listening to keyboard events. There is also no need to use $browser.defer

See working demo here http://jsfiddle.net/davidvotrubec/ebuqo6Lm/

    var myApp = angular.module('myApp', []);

    myApp.controller('MyCtrl', function($scope) {
      $scope.numericValue = 12345678;
    });

    //Written by David Votrubec from ST-Software.com
    //Inspired by http://jsfiddle.net/KPeBD/2/
    myApp.directive('sgNumberInput', ['$filter', '$locale', function ($filter, $locale) {
            return {
                require: 'ngModel',
                restrict: "A",
                link: function ($scope, element, attrs, ctrl) {
                    var fractionSize = parseInt(attrs['fractionSize']) || 0;
                    var numberFilter = $filter('number');
                    //format the view value
                    ctrl.$formatters.push(function (modelValue) {
                        var retVal = numberFilter(modelValue, fractionSize);
                        var isValid = isNaN(modelValue) == false;
                        ctrl.$setValidity(attrs.name, isValid);
                        return retVal;
                    });
                    //parse user's input
                    ctrl.$parsers.push(function (viewValue) {
                        var caretPosition = getCaretPosition(element[0]), nonNumericCount = countNonNumericChars(viewValue);
                        viewValue = viewValue || '';
                        //Replace all possible group separators
                        var trimmedValue = viewValue.trim().replace(/,/g, '').replace(/`/g, '').replace(/'/g, '').replace(/\u00a0/g, '').replace(/ /g, '');
                        //If numericValue contains more decimal places than is allowed by fractionSize, then numberFilter would round the value up
                        //Thus 123.109 would become 123.11
                        //We do not want that, therefore I strip the extra decimal numbers
                        var separator = $locale.NUMBER_FORMATS.DECIMAL_SEP;
                        var arr = trimmedValue.split(separator);
                        var decimalPlaces = arr[1];
                        if (decimalPlaces != null && decimalPlaces.length > fractionSize) {
                            //Trim extra decimal places
                            decimalPlaces = decimalPlaces.substring(0, fractionSize);
                            trimmedValue = arr[0] + separator + decimalPlaces;
                        }
                        var numericValue = parseFloat(trimmedValue);
                        var isEmpty = numericValue == null || viewValue.trim() === "";
                        var isRequired = attrs.required || false;
                        var isValid = true;
                        if (isEmpty && isRequired) {
                            isValid = false;
                        }
                        if (isEmpty == false && isNaN(numericValue)) {
                            isValid = false;
                        }
                        ctrl.$setValidity(attrs.name, isValid);
                        if (isNaN(numericValue) == false && isValid) {
                            var newViewValue = numberFilter(numericValue, fractionSize);
                            element.val(newViewValue);
                            var newNonNumbericCount = countNonNumericChars(newViewValue);
                            var diff = newNonNumbericCount - nonNumericCount;
                            var newCaretPosition = caretPosition + diff;
                            if (nonNumericCount == 0 && newCaretPosition > 0) {
                                newCaretPosition--;
                            }
                            setCaretPosition(element[0], newCaretPosition);
                        }
                        return isNaN(numericValue) == false ? numericValue : null;
                    });
                } //end of link function
            };
            //#region helper methods
            function getCaretPosition(inputField) {
                // Initialize
                var position = 0;
                // IE Support
                if (document.selection) {
                    inputField.focus();
                    // To get cursor position, get empty selection range
                    var emptySelection = document.selection.createRange();
                    // Move selection start to 0 position
                    emptySelection.moveStart('character', -inputField.value.length);
                    // The caret position is selection length
                    position = emptySelection.text.length;
                }
                else if (inputField.selectionStart || inputField.selectionStart == 0) {
                    position = inputField.selectionStart;
                }
                return position;
            }
            function setCaretPosition(inputElement, position) {
                if (inputElement.createTextRange) {
                    var range = inputElement.createTextRange();
                    range.move('character', position);
                    range.select();
                }
                else {
                    if (inputElement.selectionStart) {
                        inputElement.focus();
                        inputElement.setSelectionRange(position, position);
                    }
                    else {
                        inputElement.focus();
                    }
                }
            }
            function countNonNumericChars(value) {
                return (value.match(/[^a-z0-9]/gi) || []).length;
            }
            //#endregion helper methods
        }]);

Github code is here [https://github.com/ST-Software/STAngular/blob/master/src/directives/SgNumberInput]

4 Comments

That's one hell of allot of code just to format a number as currency!
Is it just me? Entered 123456789, got 456,789,321... Any ideas?
@DavidVotrubec I'm trying in Chrome (51...). Just did it again, and the result is the same. I would paste a screenshot if I could. :(
If you type the same digit twice at the end of the two existing decimal places, you can temporarily get three decimal places. Goes away when you type a third. Doesn't appear if you type two different digits. e.g. Start with 1,234.56. Put caret after 6. Key 7 twice. Will end with 1234.567.
3

Indeed the $parsers and $formatters are "independent" as you say (probably for loops, again as you say). In our application we explicitly format with the onchange event (inside the link function), roughly as:

element.bind("change", function() {
    ...
    var formattedModel = format(ctrl.$modelValue);
    ...
    element.val(formattedModel);
});

See your updated fiddle for the detailed and working example: http://jsfiddle.net/yE8Yj/1/

I like binding to the onchange event, because I find it annoying to change the input as the user is typing.

3 Comments

Thanks for the input. I have been experimenting with the onchange event. Even though I see the point of it appearing annoying to the user, this is unfortunately not the behavior I am looking for. Unless there is absolutely no way of formatting while the user is typing.
Replacing the change event with keyup almost gets you there. The pitfall is that if the user edits in the middle of the text, the carret is placed at the end (really annoying). There may be ways to correct that and a quick googling provides some, but I have no experience with that.
I can handle the cursor not being reset, except that when the user deletes a separator, nothing happens (the separator gets deleted and readded) :) The issue with keyup is that you can hold down a key to add it multiple times and first when releasing we format. But I guess that's probably not the biggest issue. I would, however, still prefer a nicer way of doing it, like $formatters and $parsers updating each other ONCE :)
0

The fiddle is using an old version of angular(1.0.7).

On updating to a recent version, 1.2.6, the $render function of the ngModelCtrl is never called, meaning if the model value is changed in the controller,

the number is never formatted as required in the view.

//check if new value is numeric, and set control validity
if (isNaN(newValAsNumber)){
  ngModelCtrl.$setValidity(ngModelCtrl.$name+'Numeric', false);
}

Here is the updated fiddle http://jsfiddle.net/KPeBD/78/

Comments

0

Based on the answer from Wade Tandy here is a new jsfiddle with following improvements:

  • decimal numbers possible
  • thousands separator and decimal separator based on locals
  • and some other tweaks ...

I have also replaces all String.replace(regex) by split().join(), because this allows me to use variables in the expression.

http://jsfiddle.net/KPeBD/283/

1 Comment

Warning: this fiddle has multiple issues and should not be used an a guide on how to properly perform these operations. See others.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.