14

What I am trying to achieve is to add extra interface for input fields to be able to increace and decrease numeric value in them by clicking + and - buttons.

(In essence it is what input[type=number] fields have on chrome, but I want this to be cross-broswer compatible and also have full control of presentation accross all browsers).

Code in view:

<input data-ng-model="session.amountChosen" type="text" min="1" class="form-control input-small" data-number-input>

Directive code:

app.directive('numberInput', function() {
return {
    require: 'ngModel',
    scope: true,
    link: function(scope, elm, attrs, ctrl) {

        var currValue = parseInt(scope.$eval(attrs.ngModel)),
            minValue = attrs.min || 0,
            maxValue = attrs.max || Infinity,
            newValue;

        //puts a wrap around the input and adds + and - buttons
        elm.wrap('<div class="number-input-wrap"></div>').parent().append('<div class="number-input-controls"><a href="#" class="btn btn-xs btn-pluimen">+</a><a href="#" class="btn btn-xs btn-pluimen">-</a></div>');

        //finds the buttons ands binds a click event to them where the model increase/decrease should happen
        elm.parent().find('a').bind('click',function(e){

            if(this.text=='+' && currValue<maxValue) {
                newValue = currValue+1;    
            } else if (this.text=='-' && currValue>minValue) {
                newValue = currValue-1;    
            }

            scope.$apply(function(){
                scope.ngModel = newValue;
            });

            e.preventDefault();
        });


    }
  };

})

This is able to retrieve the current model value via scope.$eval(attrs.ngModel), but fails to set the new value.

Aftermath edit: this is the code that now works (in case you wan't to see the solution for this problem)

app.directive('numberInput', function() {
  return {
    require: 'ngModel',
    scope: true,
    link: function(scope, elm, attrs, ctrl) {

        var minValue = attrs.min || 0,
            maxValue = attrs.max || Infinity;

        elm.wrap('<div class="number-input-wrap"></div>').parent().append('<div class="number-input-controls"><a href="#" class="btn btn-xs btn-pluimen">+</a><a href="#" class="btn btn-xs btn-pluimen">-</a></div>');
        elm.parent().find('a').bind('click',function(e){

            var currValue = parseInt(scope.$eval(attrs.ngModel)),
                newValue = currValue;

            if(this.text=='+' && currValue<maxValue) {
                newValue = currValue+1;    
            } else if (this.text=='-' && currValue>minValue) {
                newValue = currValue-1;    
            }

            scope.$eval(attrs.ngModel + "=" + newValue);
            scope.$apply();            

            e.preventDefault();
        });
    }
  };
})
Harijs Deksnis
  • 1,366
  • 1
  • 13
  • 24

3 Answers3

33

ngModelController methods should be used instead of $eval() to get and set the ng-model property's value.

parseInt() is not required when evaluating an attribute with a numeric value, because $eval will convert the value to a number. $eval should be used to set variables minValue and maxValue.

There is no need for the directive to create a child scope.

$apply() is not needed because the ngModelController methods ($render() in particular) will automatically update the view. However, as @Harijs notes in a comment below, $apply() is needed if other parts of the app also need to be updated.

app.directive('numberInput', function ($parse) {
    return {
        require: 'ngModel',
        link: function (scope, elm, attrs, ctrl) {
            var minValue = scope.$eval(attrs.min) || 0,
                maxValue = scope.$eval(attrs.max) || Infinity;
            elm.wrap('<div class="number-input-wrap"></div>').parent()
                .append('<div class="number-input-controls"><a href="#" class="btn btn-xs btn-pluimen">+</a><a href="#" class="btn btn-xs btn-pluimen">-</a></div>');
            elm.parent().find('a').bind('click', function (e) {
                var currValue = ctrl.$modelValue,
                    newValue;
                if (this.text === '+' && currValue < maxValue) {
                    newValue = currValue + 1;
                } else if (this.text === '-' && currValue > minValue) {
                    newValue = currValue - 1;
                }
                ctrl.$setViewValue(newValue);
                ctrl.$render();
                e.preventDefault();
                scope.$apply(); // needed if other parts of the app need to be updated
            });
        }
    };
});

fiddle

Mark Rajcok
  • 362,217
  • 114
  • 495
  • 492
  • The problem (or insufficiency) with this in my use case is that it only updates this one input field, not the whole model - all other app fields that depend on this input. Therefore scope.$apply() is necessary for me. And you are corrrect about the scope, however I don´t fully see why $eval should be called upon attributes. It works just as well with parseInt. – Harijs Deksnis Aug 16 '13 at 08:06
  • 1
    @HarijsDeksnis, thanks, I updated my answer to reflect your comments. Yes, you can use parseInt() or $eval. In your original code, you used both, so I was trying to point out that you only need to use one. In "Aftermath edit" you are not using either with minValue and maxValue, and you should use one of them, otherwise minValue and maxValue will be of type string. $eval() is a little more general, as it will correctly parse booleans, ints or strings, and assign the correct type to the variable, whereas parseInt() obviously only works for numeric values. So I tend to favor $eval(). – Mark Rajcok Aug 16 '13 at 15:38
  • Now I learned to appreciate the benefit of $eval for attrs - it allows the value to be an expression, e.g. passed from some other scope variable. I was trying to find where you used $parse, but seems you injected it only out of convention, right? – Harijs Deksnis Aug 20 '13 at 13:56
  • 1
    @HarijsDeksnis, I forgot to remove $parse -- it is not needed here. Use $parse if you want to pass/specify a scope object property as a directive attribute, and then modify the value inside the directive: http://stackoverflow.com/a/15725402/215945 – Mark Rajcok Aug 20 '13 at 14:01
  • @MarkRajcok: The solution does not work under one condition. Manually change the value in input text box, and then press + or -. You will notice that the model is not updated. And also it behaves as a string instead of number after this event. – Ritesh Kumar Gupta May 27 '15 at 20:35
4

You would not want to replace the scope.ngModel variable, but the value that's behind that variable. You did it already when you read the value in the first line of the link function:

 currValue = parseInt(scope.$eval(attrs.ngModel))
 //                         ^^^^^^^^^^^^^^^^^^^^

If it's a plain value, like myProperty, you could use that on the scope:

 scope[attr.ngModel] = newValue

But this will not work, if you have an expression-value, like container.myProperty. In that case (and this is the more generic type, you should be aiming for) you'd have to eval the value being set to the scope, like this:

scope.$eval(attrs.ngModel + "=" + newValue)

I must admit, that the $eval part is a bit ugly, as it is in JavaScript with the eval pendant, but it does the trick. Just keep in mind that it might not work this way, when you want String values to be set. Then you'd have to escape those values.

Hope that helps ;)

Tharabas
  • 3,402
  • 1
  • 30
  • 29
  • Thanks, it does update the model value! Albeit it does not change the view on itself. I have to add this to get the input field to be updated on the view. ctrl.$viewValue = newValue; ctrl.$render(); However, I still have to figure out how to update other fields in my app that use this model's value. – Harijs Deksnis Aug 15 '13 at 10:47
  • It was easier than expected. Just had to add scope.$apply() at the end. – Harijs Deksnis Aug 15 '13 at 10:56
  • Ahh, yeah, forgot to mention, that any jQuery event listener like the `bind("click")` does not trigger angular's `digest` cycle, so you'd have to do that manually. But apparently you figured that already ;) – Tharabas Aug 15 '13 at 11:37
  • 1
    ngModelController methods should be used instead of $eval (see my answer). – Mark Rajcok Aug 16 '13 at 01:20
3
.directive('numberInput', function ($parse) {
   return {
      require: 'ngModel',
      link: function (scope, elm, attrs){ 
        elm.bind("click", function() {
          var model = $parse(attrs.ngModel);
          var modelSetter = model.assign;
          scope.$apply(function() {
               modelSetter(scope, model);
          });
     }
  }
})
Mosh Feu
  • 28,354
  • 16
  • 88
  • 135
Vaibhao
  • 31
  • 1