Rory O'Kane
Rory O'Kane

Reputation: 30428

In Angular, bind attribute from scope variable *before* ngModel binds the input’s `value`

In my Angular app, I defined a custom slider directive that wraps <input type="range">. My custom directive supports ngModel to bind the slider’s value to a variable. The custom directive also requires a fraction-size attribute. It does a calculation on the value and then uses the result to set the step value of the wrapped <input>.

I am seeing a bug when I combine these two features – ngModel and my bound attribute value. They are run in the wrong order.

Here is a demonstration:

angular.module('HelloApp', []);

angular.module('HelloApp').directive('customSlider', function() {
    var tpl = "2 <input type='range' min='2' max='3' step='{{stepSize}}' ng-model='theNum' /> 3";
    
    return {
        restrict: 'E',
        template: tpl,
        require: 'ngModel',
        scope: {
            fractionSize: '='
        },
        link: function(scope, element, attrs, ngModelCtrl) {
            scope.stepSize = 1 / scope.fractionSize;
            
            scope.$watch('theNum', function(newValue, oldValue) {
                ngModelCtrl.$setViewValue(newValue);
            });
            
            ngModelCtrl.$render = function() {
                scope.theNum = ngModelCtrl.$viewValue;
            };
        }
    };
});

angular.module('HelloApp').controller('HelloController', function($scope) {
	$scope.someNumber = 2.5;
});
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.min.js"></script>

<div ng-app="HelloApp" ng-controller="HelloController">
    <h3>Custom slider</h3>
    
    <custom-slider ng-model="someNumber" fraction-size="10"></custom-slider>
    
    <h3>View/edit the slider’s value</h3>
    <input ng-model="someNumber"></input>
</div>

The above slider should start at the middle, which represents 2.5. But it actually starts all the way at the right (representing 3). The slider fixes itself and allows the value 2.5 if you drag it, or if you change its bound value by editing the text field.

I have figured out why this is happening in the demonstration – I just don’t know how to fix it. Currently, when a new custom slider is dynamically added to the page, the wrapped <input>’s step is undefined, and defaults to 1. Then ngModel sets the value to 2.5 – but since step is 1, the value in the input is rounded to 3. Finally, step is set to 0.1 – too late for it to matter.

How can I ensure that the step attribute’s value is bound before ngModel sets the input’s value?

In the example above, the slider is on the page at page load. In my real code, multiple new sliders are added dynamically. They should all bind their step and value in the correct order whenever they are added.

A workaround I don’t like

A workaround is to hard-code the step in the template instead of setting it dynamically. If I were to do this, my custom slider directive would have no use, and I would remove it and just use an <input> directly:

<input type="range" min="2" max="3" step="0.1" ng-model="someNumber">

If I use that, the slider’s value is set correctly, without rounding. But I want to to keep my custom directive, customSlider, so that the calculation of step is abstracted.

Things I tried that didn’t work

Upvotes: 4

Views: 3926

Answers (3)

Michael Kang
Michael Kang

Reputation: 52867

In your link function, manipulate the DOM by adding the step attribute.

You can also simplify your binding with the outer ngModel by putting theNum: '=ngModel' in scope.

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

app.directive('customSlider', function () {
    var tpl = "2 <input type='range' min='2' max='3' ng-model='theNum' /> 3";
    
    return {
        restrict: 'E',
        template: tpl,
        require: 'ngModel',
        scope: {
            fractionSize: '=',
            theNum: '=ngModel'
        },
        link: function (scope, element, attrs, ngModelCtrl) {
            var e = element.find('input')[0];
            
            var step = 1 / scope.fractionSize;
            e.setAttribute('step', step);
            
            scope.$watch('fractionSize', function (newVal) {
                if (newVal) {
                    var step = 1 / newVal;
                    e.setAttribute('step', step);
                }
            });
        }
    };
});

angular.module('HelloApp').controller('HelloController', function($scope) {
    $scope.someNumber = 2.5;
});
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.min.js"></script>

<div ng-app="HelloApp" ng-controller="HelloController">
    <h3>Custom slider</h3>
    
    <custom-slider ng-model="someNumber" fraction-size="10"></custom-slider>
    
    <h3>View/edit the slider’s value</h3>
    <input ng-model="someNumber"></input> {{ someNumber }}
</div>

Upvotes: 2

Michal Charemza
Michal Charemza

Reputation: 27062

Taking @pixelbits' answer of direct DOM manipulation further, I wouldn't have another ng-model on the inner input at all, and instead always set/get the properties on the input directly

  • You have one abstraction level to think about when setting/getting values from the input. Raw DOM events and elements.

  • As such you are not limited to what Angular allows on such elements (indeed: what it does seems to not be able to handle your use case without work-around). If the browser allows it on the range input, you can do it.

  • Have 2 ngModelControllers in play on the one UI widget that sets one value can get a bit confusing, at least for me!

  • You still have access to the outer ngModelController pipeline and all its functionality regarding parsers and validators, if you need/want to use it.

  • You save from having an extra watcher (but this could be a micro/premature optimization).

See an example below.

angular.module('HelloApp', []);

angular.module('HelloApp').directive('customSlider', function() {
    var tpl = "2 <input type='range' min='2' max='3' /> 3";
    
    return {
        restrict: 'E',
        template: tpl,
        require: 'ngModel',
        scope: {
            fractionSize: '='
        },
        link: function(scope, element, attrs, ngModelCtrl) {
            var input = element.find('input');          
            input.prop('step', 1 / scope.fractionSize);

            input.on('input', function() {
              scope.$apply(function() {
                ngModelCtrl.$setViewValue(input.prop('value'));
              });
            });

            ngModelCtrl.$render = function(value) {
               input.prop('value', ngModelCtrl.$viewValue);
            };
        }
    };
});

angular.module('HelloApp').controller('HelloController', function($scope) {
    $scope.someNumber = 2.5;
});
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.min.js"></script>

<div ng-app="HelloApp" ng-controller="HelloController">
    <h3>Custom slider</h3>
    
    <custom-slider ng-model="someNumber" fraction-size="10"></custom-slider>
    
    <h3>View/edit the slider’s value</h3>
    <input ng-model="someNumber"></input>
</div>

Also available at http://plnkr.co/edit/pMtmNSy6MVuXV5DbE1HI?p=preview

Upvotes: 2

Icycool
Icycool

Reputation: 7179

I'm not sure if this is the correct way to do it, but adding a timeout to your main controller solves the issue.

$timeout(function() {
    $scope.someNumber = 2.5;
});

Edit: While it seems like a dirty hack at first, but note that it is common for (final) $scope variables to be assigned later than templates, because of additional ajax calls to retrieve the values.

Upvotes: 0

Related Questions