Binding an input control to a number, but having custom display and edit modes

290 views
Skip to first unread message

angular...@gmail.com

unread,
Oct 2, 2013, 7:00:53 AM10/2/13
to ang...@googlegroups.com

I want to create a directive that will behave as follows... On the HTML side:

    <input data-ng-model="modelLogistics.inputValue1" data-currency="{decSep:','   ,    thSep:'.'}">

On the user side of code something like:

    controllerLogistics(...) {
        $scope.modelLogistics = {};
        $scope.modelLogistics.inputValue1 = 1234.23;
        ...
    }

Now for the tough part: I want the input control to behave in **two** ways, depending on whether it has the focus or not:

 - If the control has the focus, then it should display the number using only the decimal separator (decSep) and ignoring the thousand separator (thSep) - so the 1234.23 would appear in the input text that the user edits as "1234,23" (because decSep is set to ',' in the HTML directive).
 - If the control loses the focus, then it should display the number using both the decimal separator (decSep) and the thousand separator (thSep) - so the 1234.23 would appear in the input text that the user sees as "1.234,23" (thSep is set to '.' in the HTML directive).

My code so far is this:

    function currency() {
        return {
            require: '?ngModel',
            link: function(scope:ng.IScope, element, attrs, ngModel) {
                if(!ngModel) return; // do nothing if no ng-model

                var options = scope.$eval(attrs.currency);
                if (options === undefined)          options = {};
                if (options.decSep === undefined)   options.decSep = ',';
                if (options.thSep === undefined)    options.thSep = '.';

                element.blur(function(e) {
                    var parts = (ngModel.$viewValue || '').split(options.decSep);
                    parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, options.thSep);
                    element.val( parts.join(options.decSep));
                });

                ngModel.$render = () => {
                    element.val(ngModel.$viewValue || '');
                }
           }
      }

...and it works - provided that (a) my model is a string, not a number, and (b) I initialize the model with a "valid" number as per the directive specs in the HTML - that is, using model values like "1234,23" and not the number 1234.23

I am having difficulty figuring out how to change the implementation to have an underlying number (not a string) and automatically using the two modes (edit/view). I have seen the angular filters (i.e. the '|' syntax in things like '{{model.value | something}}' but I am not sure whether it fits with what I am trying to do...

Any help most appreciated.

angular...@gmail.com

unread,
Oct 2, 2013, 8:32:50 AM10/2/13
to ang...@googlegroups.com
I have seen other currency solutions that use $formatters and $parsers - but in my case, I can't use this pattern, because the $viewValue depends not just on the $modelValue, but also on whether the control has the focus or not. That is, if I just add a formatter that checks whether the element is in focus or not, that will work the first time only - when the user clicks on some other component and the focus is lost, the model hasn't changed - yet the view needs to be updated.

OpenNota

unread,
Oct 2, 2013, 9:43:42 AM10/2/13
to ang...@googlegroups.com
I was thinking about creating something like frontend input element, that will display all the formatting and react to focus/blur, whereas real element will be hidden and bound to ngModel. Something like

      // link function
      var copy = el.clone();
      el[0].parentNode.insertBefore(copy[0], el[0]);
      copy[0].removeAttribute('currency');
      copy[0].removeAttribute('ng-model');
      $compile(copy)(scope);
      el.css('display','none');
      copy.bind('focus', ...);
      copy.bind('blur', ...);
      // adding watches
etc.
However, one will need to organize two-way interaction between these elements (their values), which seems to be non-trivial task, so I don't think it's the right way to do it.

angular...@gmail.com

unread,
Oct 2, 2013, 10:53:01 AM10/2/13
to ang...@googlegroups.com

After a day's worth of work... I have it.

$formatters and $parsers simply don't work here, because the view state depends not just on the model state, but also on whether the component has the focus or not. I therefore maintain the state myself, assigning explicitly to ngModel.$modelValue, ngModel.$viewValue and element.val(). 

Here's the full code, in case it helps some poor soul out there - it zaps invalid input back to the latest valid one, and pops up a Bootstrap popover if the value is invalid:

    function currency($timeout) {
        return {
            // We will change the model via this directive
            require: '?ngModel',

            link: function(scope:ng.IScope, element, attrs, ngModel) {
                if(!ngModel) return; // do nothing if no ng-model

                // Read the options passed in the directive
                var options = scope.$eval(attrs.currency);
                if (options === undefined)          options = {};
                if (options.min === undefined)      options.min = Number.NEGATIVE_INFINITY;
                if (options.max === undefined)      options.max = Number.POSITIVE_INFINITY;
                if (options.decimals === undefined) options.decimals = 0;
                if (options.decSep === undefined)   options.decSep = ',';
                if (options.thSep === undefined)    options.thSep = '.';

                // cache the validation regexp inside our options object (don't compile it all the time)
                var regex = "^[0-9]*(" + options.decSep + "([0-9]{0," + options.decimals + "}))?$";
                options.compiledRegEx = new RegExp(regex);

                // Use a Bootstrap popover to notify the user of erroneous data
                function showError(msg:string) {
                    if (options.promise !== undefined) {
                        // An error popover is already there - cancel the timer, destroy the popover
                        $timeout.cancel(options.promise);
                        element.popover('destroy');
                    }
                    // Show the error
                    element.popover({
                        animation:true, html:false, placement:'right', trigger:'manual', content:msg
                    }).popover('show');
                    // Schedule a popover destroy after 3000ms
                    options.promise = $timeout(function() { element.popover('destroy'); }, 3000);
                }

                // Converters to and from between the model (number) and the two state strings (edit/view)

                function numberToEditText(n:number):string {
                    if (!n) return ''; // the model may be undefined by the user
                    return n.toString().split(localeDecSep).join(options.decSep);
                }

                function numberToViewText(n:number):string {
                    if (!n) return ''; // the model may be undefined by the user
                    var parts = n.toString().split(localeDecSep);
                    parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, options.thSep);
                    return parts.join(options.decSep);
                }

                function editTextToNumber(t:string):number {
                    return parseFloat(t.replace(options.thSep, '').replace(options.decSep, localeDecSep));
                }

                function viewTextToNumber(t:string):number {
                    return parseFloat(t.replace(options.decSep, localeDecSep));
                }

                // For debugging
                //function log() {
                //    console.log('oldModelValue:' + options.oldModelValue);
                //    console.log('modelValue:' + ngModel.$modelValue);
                //    console.log('viewValue:' + ngModel.$viewValue);
                //}

                // On keyup, the element.val() has the input's new value - 
                // which may be invalid, violating our restrictions:
                element.keyup(function(e) {
                    var newValue:string = element.val();
                    if (!options.compiledRegEx.test(newValue)) {
                        // it fails the regex, it's not valid
                        //console.log('This is invalid due to regex: ' + newValue);
                        $timeout(function() {
                            // schedule a call to render, to reset element.val to the last known good value
                            ngModel.$render(true);
                        }, 0);
                        // Show a bootstrap popever error window, which will autohide after 3 seconds
                        showError(' Μόνο ' + options.decimals + ' δεκαδικά και μία υποδιαστολή (' + options.decSep +')');
                        return;
                    }
                    var newValueNumber:number = scope.$eval(newValue.replace(options.decSep, localeDecSep));
                    if (newValueNumber>options.max || newValueNumber<options.min) {
                        // it fails the range check
                        //console.log('This is invalid due to range: ' + newValue);
                        $timeout(function() {
                            // schedule a call to render, to reset element.val to the last known good value
                            ngModel.$render(true);
                        }, 0);
                        // Show a bootstrap popever error window, which will autohide after 3 seconds
                        showError(' Από ' + options.min + ' έως ' + options.max);
                        return;
                    }
                    // The input may be empty - set the model to undefined then
                    // ('unset' is a valid result for our model - think of SQL 'NULL')
                    if (newValue === '') {
                        ngModel.$modelValue = undefined;
                        options.oldModelValue = undefined;
                    } else {
                        // The new input value is solid - update the $modelValue
                        ngModel.$modelValue = editTextToNumber(newValue);
                        // ...and keep this as the last known good value
                        options.oldModelValue = ngModel.$modelValue;
                        //console.log("oldModelValue set to " + options.oldModelValue);
                    }

                    // If we reached here and a popover is still up, waiting to be killed,
                    // then kill the timer and destroy the popover
                    if (options.promise !== undefined) {
                        $timeout.cancel(options.promise);
                        element.popover('destroy');
                    }
                });

                // schedule a call to render, to reset element.val to the last known good value
                element.focus(function(e) { ngModel.$render(true); });

                element.blur(function(e) { ngModel.$render(false); });

                // when the model changes, Angular will call this:
                ngModel.$render = (inFocus) => {
                    // how to obtain the first content for the oldModelValue that we will revert to
                    // when erroneous inputs are given in keyup() ?
                    // simple: just copy it here, and update in keyup if the value is valid.
                    options.oldModelValue = ngModel.$modelValue;
                    //console.log("oldModelValue set to " + options.oldModelValue);
                    if (!ngModel.$modelValue) {
                        element.val('');
                    } else {
                        // Set the $viewValue to a proper representation, based on whether
                        // we are in edit or view mode.
                        // Initially I was calling element.is(":focus") here, but this was not working
                        // properly - so I hack a bit: I know $render will be called by Angular
                        // with no parameters (so inFocus will be undefined, which evaluates to false)
                        // and I only call it myself with true from within 'element.focus' above.
                        var m2v = inFocus?numberToEditText:numberToViewText;
                        var viewValue = m2v(ngModel.$modelValue);
                        ngModel.$viewValue = viewValue;
                        // And set the content of the DOM element to the proper representation.
                        element.val(viewValue);
                    }
                }

                // we need the model of the input to update from the changes done by the user,
                // but only if it is valid - otherwise, we want to use the oldModelValue
                // (the last known good value).
                ngModel.$parsers.push(function(newValue) {
                    if (newValue === '')
                        return undefined;
                    if (!options.compiledRegEx.test(newValue))
                        return options.oldModelValue;
                    var newValueNumber:number = scope.$eval(newValue.replace(options.decSep, localeDecSep));
                    if (newValueNumber>options.max || newValueNumber<options.min)
                        return options.oldModelValue;
                    // The input was solid, update the model.
                    return viewTextToNumber(newValue);
                });
            }
        };
    }

Τη Τετάρτη, 2 Οκτωβρίου 2013 2:00:53 μ.μ. UTC+3, ο χρήστης angular...@gmail.com έγραψε:

Chris Nicola

unread,
Oct 2, 2013, 1:48:46 PM10/2/13
to ang...@googlegroups.com
We did something similar (http://www.wealthbar.com/plans/start) which supports numbers (with decimals) and prefix/postfix text (currency or percentages). I agree it is quite tricky and a formatter/parser does not work alone. Also since they are numbers it makes sense to include the default type="number" field behavior but that breaks because you've included non-numeric values in the field. We have a bit of a hack that involves:

- Starting the input as type number but changing it after to text (or 'tel' which will generally give you a numeric keyboard on some devices). 
- Extending ngModelController $setViewValue to handle cleaning of the input string of seperators and prefix/postfix values essentially it's a parser but not actually a $parser.
- A $formatter which formats the output of the number (the formatter is still good for doing this) 
- We use the built in 'number' filter from AngularJS using the filter service to format things
- Handle special cases with the 'keydown' that ensures typing and backspacing will always keep the cursor position correctly (fairly tricky with seperators appearing a dissapearing). Also some other small behaviour hacks are needed to make it "feel" right.

I'm considering posting it as an open source component to AngularUI if there is interest in a directive like this.
Reply all
Reply to author
Forward
0 new messages