Reputation: 30428
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 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.
step
and ng-model
attributes in the template doesn’t have any effect.compile
function, instead of just having a link
function. But I can’t set stepSize
in compile
because scope
isn’t available during the compile phase.link
function into pre-link and post-link functions. But whether I set scope.stepSize
in pre
or in post
, the page works as before.scope.$digest()
right after setting scope.stepSize
just throws Error: [$rootScope:inprog] $digest already in progress
.step
’s value isn’t a custom directive, it is just raw {{}}
-binding. And I think binding step
is a simple enough task that it shouldn’t be wrapped in its own directive.Upvotes: 4
Views: 3926
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
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
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