feat(input): add support for binding to input[type=range] (#14870)

Thanks to @cironunes for the initial implementation in https://github.com/angular/angular.js/pull/9715

Adds support for binding to input[range] with the following behavior / features:

- Like input[number], it requires the model to be a Number, and will set the model to a Number
- it supports setting the min/max values via the min/max and ngMin/ngMax attributes
- it follows the browser behavior of never allowing an invalid value. That means, when the browser
converts an invalid value (empty: null, undefined, false ..., out of bounds: greater than max, less than min)
to a valid value, the input will in turn set the model to this new valid value via $setViewValue.
-- this means a range input will never be required and never have a non-Number model value, once the
ngModel directive is initialized.
-- this behavior is supported when the model changes and when the min/max attributes change in a way
that prompts the browser to update the input value.
-- ngMin / ngMax do not prompt the browser to update the values, as they don't set the attribute values.
Instead, they will set the min / max errors when appropriate
- browsers that do not support input[range] (IE9) handle the input like a number input (with validation etc.)

Closes #5892
Closes #9715
Close #14870
This commit is contained in:
Martin Staffa
2016-07-29 14:29:09 +02:00
committed by GitHub
parent cd2f6d9d3b
commit 9130166767
4 changed files with 645 additions and 6 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
@fullName Model is not of type `number`
@description
The number input directive `<input type="number">` requires the model to be a `number`.
The `input[number]` and `input[range]` directives require the model to be a `number`.
If the model is something else, this error will be thrown.
+218 -4
View File
@@ -1034,6 +1034,113 @@ var inputType = {
*/
'radio': radioInputType,
/**
* @ngdoc input
* @name input[range]
*
* @description
* Native range input with validation and transformation.
*
* The model for the range input must always be a `Number`.
*
* IE9 and other browsers that do not support the `range` type fall back
* to a text input. Model binding, validation and number parsing are nevertheless supported.
*
* Browsers that support range (latest Chrome, Safari, Firefox, Edge) treat `input[range]`
* in a way that never allows the input to hold an invalid value. That means:
* - any non-numerical value is set to `(max + min) / 2`.
* - any numerical value that is less than the current min val, or greater than the current max val
* is set to the min / max val respectively.
*
* This has the following consequences for Angular:
*
* Since the element value should always reflect the current model value, a range input
* will set the bound ngModel expression to the value that the browser has set for the
* input element. For example, in the following input `<input type="range" ng-model="model.value">`,
* if the application sets `model.value = null`, the browser will set the input to `'50'`.
* Angular will then set the model to `50`, to prevent input and model value being out of sync.
*
* That means the model for range will immediately be set to `50` after `ngModel` has been
* initialized. It also means a range input can never have the required error.
*
* This does not only affect changes to the model value, but also to the values of the `min` and
* `max` attributes. When these change in a way that will cause the browser to modify the input value,
* Angular will also update the model value.
*
* Automatic value adjustment also means that a range input element can never have the `required`,
* `min`, or `max` errors, except when using `ngMax` and `ngMin`, which are not affected by automatic
* value adjustment, because they do not set the `min` and `max` attributes.
*
* @param {string} ngModel Assignable angular expression to data-bind to.
* @param {string=} name Property name of the form under which the control is published.
* @param {string=} min Sets the `min` validation to ensure that the value entered is greater
* than `min`. Can be interpolated.
* @param {string=} max Sets the `max` validation to ensure that the value entered is less than `max`.
* Can be interpolated.
* @param {string=} ngMin Takes an expression. Sets the `min` validation to ensure that the value
* entered is greater than `min`. Does not set the `min` attribute and therefore
* adds no native HTML5 validation. It also means the browser won't adjust the
* element value in case `min` is greater than the current value.
* @param {string=} ngMax Takes an expression. Sets the `max` validation to ensure that the value
* entered is less than `max`. Does not set the `max` attribute and therefore
* adds no native HTML5 validation. It also means the browser won't adjust the
* element value in case `max` is less than the current value.
* @param {string=} ngChange Angular expression to be executed when the ngModel value changes due
* to user interaction with the input element.
*
* @example
<example name="range-input-directive" module="rangeExample">
<file name="index.html">
<script>
angular.module('rangeExample', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.value = 75;
$scope.min = 10;
$scope.max = 90;
}]);
</script>
<form name="myForm" ng-controller="ExampleController">
Model as range: <input type="range" name="range" ng-model="value" min="{{min}}" max="{{max}}">
<hr>
Model as number: <input type="number" ng-model="value"><br>
Min: <input type="number" ng-model="min"><br>
Max: <input type="number" ng-model="min"><br>
value = <code>{{value}}</code><br/>
myForm.range.$valid = <code>{{myForm.range.$valid}}</code><br/>
myForm.range.$error = <code>{{myForm.range.$error}}</code>
</form>
</file>
</example>
* ## Range Input with ngMin & ngMax attributes
* @example
<example name="range-input-directive-ng" module="rangeExample">
<file name="index.html">
<script>
angular.module('rangeExample', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.value = 75;
$scope.min = 10;
$scope.max = 90;
}]);
</script>
<form name="myForm" ng-controller="ExampleController">
Model as range: <input type="range" name="range" ng-model="value" ng-min="min" ng-max="max">
<hr>
Model as number: <input type="number" ng-model="value"><br>
Min: <input type="number" ng-model="min"><br>
Max: <input type="number" ng-model="min"><br>
value = <code>{{value}}</code><br/>
myForm.range.$valid = <code>{{myForm.range.$valid}}</code><br/>
myForm.range.$error = <code>{{myForm.range.$error}}</code>
</form>
</file>
</example>
*/
'range': rangeInputType,
/**
* @ngdoc input
@@ -1385,10 +1492,7 @@ function badInputChecker(scope, element, attr, ctrl) {
}
}
function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
badInputChecker(scope, element, attr, ctrl);
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
function numberFormatterParser(ctrl) {
ctrl.$$parserName = 'number';
ctrl.$parsers.push(function(value) {
if (ctrl.$isEmpty(value)) return null;
@@ -1405,6 +1509,12 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
return value;
});
}
function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
badInputChecker(scope, element, attr, ctrl);
numberFormatterParser(ctrl);
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
if (isDefined(attr.min) || attr.ngMin) {
var minVal;
@@ -1439,6 +1549,110 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
}
function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
badInputChecker(scope, element, attr, ctrl);
numberFormatterParser(ctrl);
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
var minVal = 0,
maxVal = 100,
supportsRange = ctrl.$$hasNativeValidators && element[0].type === 'range',
validity = element[0].validity;
var originalRender = ctrl.$render;
ctrl.$render = supportsRange && isDefined(validity.rangeUnderflow) && isDefined(validity.rangeOverflow) ?
//Browsers that implement range will set these values automatically, but reading the adjusted values after
//$render would cause the min / max validators to be applied with the wrong value
function rangeRender() {
originalRender();
ctrl.$setViewValue(element.val());
} :
originalRender;
function minChange(val) {
if (isDefined(val) && !isNumber(val)) {
val = parseFloat(val);
}
minVal = isNumber(val) && !isNaN(val) ? val : undefined;
// ignore changes before model is initialized
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
return;
}
if (supportsRange && minAttrType === 'min') {
var elVal = element.val();
// IE11 doesn't set the el val correctly if the minVal is greater than the element value
if (minVal > elVal) {
element.val(minVal);
elVal = minVal;
}
ctrl.$setViewValue(elVal);
} else {
// TODO(matsko): implement validateLater to reduce number of validations
ctrl.$validate();
}
}
var minAttrType = isDefined(attr.ngMin) ? 'ngMin' : isDefined(attr.min) ? 'min' : false;
if (minAttrType) {
ctrl.$validators.min = isDefined(attr.min) && supportsRange ?
function noopMinValidator(value) {
// Since all browsers set the input to a valid value, we don't need to check validity
return true;
} :
// ngMin doesn't set the min attr, so the browser doesn't adjust the input value as setting min would
function minValidator(modelValue, viewValue) {
return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal;
};
// Assign minVal when the directive is linked. This won't run the validators as the model isn't ready yet
minChange(attr.min);
attr.$observe('min', minChange);
}
function maxChange(val) {
if (isDefined(val) && !isNumber(val)) {
val = parseFloat(val);
}
maxVal = isNumber(val) && !isNaN(val) ? val : undefined;
// ignore changes before model is initialized
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
return;
}
if (supportsRange && maxAttrType === 'max') {
var elVal = element.val();
// IE11 doesn't set the el val correctly if the maxVal is less than the element value
if (maxVal < elVal) {
element.val(maxVal);
elVal = minVal;
}
ctrl.$setViewValue(elVal);
} else {
// TODO(matsko): implement validateLater to reduce number of validations
ctrl.$validate();
}
}
var maxAttrType = isDefined(attr.max) ? 'max' : attr.ngMax ? 'ngMax' : false;
if (maxAttrType) {
ctrl.$validators.max = isDefined(attr.max) && supportsRange ?
function noopMaxValidator() {
// Since all browsers set the input to a valid value, we don't need to check validity
return true;
} :
// ngMax doesn't set the max attr, so the browser doesn't adjust the input value as setting max would
function maxValidator(modelValue, viewValue) {
return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal;
};
// Assign maxVal when the directive is linked. This won't run the validators as the model isn't ready yet
maxChange(attr.max);
attr.$observe('max', maxChange);
}
}
function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) {
// Note: no badInputChecker here by purpose as `url` is only a validation
// in browsers, i.e. we can always read out input.value even if it is not valid!
+2 -1
View File
@@ -881,7 +881,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
ctrl.$render();
ctrl.$$runValidators(modelValue, viewValue, noop);
// It is possible that model and view value have been updated during render
ctrl.$$runValidators(ctrl.$modelValue, ctrl.$viewValue, noop);
}
}
+424
View File
@@ -2819,6 +2819,430 @@ describe('input', function() {
});
});
describe('range', function() {
var scope;
var rangeTestEl = angular.element('<input type="range">');
var supportsRange = rangeTestEl[0].type === 'range';
beforeEach(function() {
scope = $rootScope;
});
if (supportsRange) {
// This behavior only applies to browsers that implement the range input, which do not
// allow to set a non-number value and will set the value of the input to 50 even when you
// change it directly on the element.
// Other browsers fall back to text inputs, where setting a model value of 50 does not make
// sense if the input value is a string. These browsers will mark the input as invalid instead.
it('should render as 50 if null', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="age" />');
helper.changeInputValueTo('25');
expect(scope.age).toBe(25);
scope.$apply('age = null');
expect(inputElm.val()).toEqual('50');
});
it('should set model to 50 when no value specified', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="age" />');
expect(inputElm.val()).toBe('50');
scope.$apply('age = null');
expect(scope.age).toBe(50);
});
it('should parse non-number values to 50', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="age" />');
scope.$apply('age = 10');
expect(inputElm.val()).toBe('10');
helper.changeInputValueTo('');
expect(scope.age).toBe(50);
expect(inputElm).toBeValid();
});
} else {
it('should reset the model if view is invalid', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="age"/>');
scope.$apply('age = 100');
expect(inputElm.val()).toBe('100');
helper.changeInputValueTo('100X');
expect(inputElm.val()).toBe('100X');
expect(scope.age).toBeUndefined();
expect(inputElm).toBeInvalid();
});
}
it('should parse the input value to a Number', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="age" />');
helper.changeInputValueTo('75');
expect(scope.age).toBe(75);
});
it('should only invalidate the model if suffering from bad input when the data is parsed', function() {
scope.age = 60;
var inputElm = helper.compileInput('<input type="range" ng-model="age" />', {
valid: false,
badInput: true
});
expect(inputElm).toBeValid();
helper.changeInputValueTo('this-will-fail-because-of-the-badInput-flag');
expect(scope.age).toBeUndefined();
expect(inputElm).toBeInvalid();
});
it('should throw if the model value is not a number', function() {
expect(function() {
scope.value = 'one';
var inputElm = helper.compileInput('<input type="range" ng-model="value" />');
}).toThrowMinErr('ngModel', 'numfmt', 'Expected `one` to be a number');
});
describe('min', function() {
if (supportsRange) {
// Browsers that implement range will never allow you to set the value < min values
it('should validate', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" min="10" />');
helper.changeInputValueTo('5');
expect(inputElm).toBeValid();
expect(scope.value).toBe(10);
expect(scope.form.alias.$error.min).toBeFalsy();
helper.changeInputValueTo('100');
expect(inputElm).toBeValid();
expect(scope.value).toBe(100);
expect(scope.form.alias.$error.min).toBeFalsy();
});
it('should adjust the element and model value when the min value changes on-the-fly', function() {
scope.min = 10;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" min="{{min}}" />');
helper.changeInputValueTo('15');
expect(inputElm).toBeValid();
scope.min = 20;
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(20);
expect(inputElm.val()).toBe('20');
scope.min = null;
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(20);
expect(inputElm.val()).toBe('20');
scope.min = '15';
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(20);
expect(inputElm.val()).toBe('20');
scope.min = 'abc';
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(20);
expect(inputElm.val()).toBe('20');
});
} else {
it('should validate if "range" is not implemented', function() {
// This will become type=text in browsers that don't support it
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" min="10" />');
helper.changeInputValueTo('5');
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(scope.form.alias.$error.min).toBeTruthy();
helper.changeInputValueTo('100');
expect(inputElm).toBeValid();
expect(scope.value).toBe(100);
expect(scope.form.alias.$error.min).toBeFalsy();
});
it('should validate even if the min value changes on-the-fly', function() {
scope.min = 10;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" min="{{min}}" />');
helper.changeInputValueTo('15');
expect(inputElm).toBeValid();
expect(scope.value).toBe(15);
scope.min = 20;
scope.$digest();
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(inputElm.val()).toBe('15');
scope.min = null;
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(15);
expect(inputElm.val()).toBe('15');
scope.min = '16';
scope.$digest();
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(inputElm.val()).toBe('15');
scope.min = 'abc';
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(15);
expect(inputElm.val()).toBe('15');
});
}
});
describe('ngMin', function() {
it('should validate', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" ng-min="50" />');
helper.changeInputValueTo('1');
expect(inputElm).toBeInvalid();
expect(scope.value).toBeFalsy();
expect(scope.form.alias.$error.min).toBeTruthy();
helper.changeInputValueTo('100');
expect(inputElm).toBeValid();
expect(scope.value).toBe(100);
expect(scope.form.alias.$error.min).toBeFalsy();
});
it('should validate even if the ngMin value changes on-the-fly', function() {
scope.min = 10;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" ng-min="min" />');
helper.changeInputValueTo('15');
expect(inputElm).toBeValid();
scope.min = 20;
scope.$digest();
expect(inputElm).toBeInvalid();
scope.min = null;
scope.$digest();
expect(inputElm).toBeValid();
scope.min = '20';
scope.$digest();
expect(inputElm).toBeInvalid();
scope.min = 'abc';
scope.$digest();
expect(inputElm).toBeValid();
});
});
describe('max', function() {
if (supportsRange) {
// Browsers that implement range will never allow you to set the value > max value
it('should validate', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="10" />');
helper.changeInputValueTo('20');
expect(inputElm).toBeValid();
expect(scope.value).toBe(10);
expect(scope.form.alias.$error.max).toBeFalsy();
helper.changeInputValueTo('0');
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(scope.form.alias.$error.max).toBeFalsy();
});
it('should set the model to the max val if it is more than the max val', function() {
scope.value = 90;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="10" />');
expect(inputElm).toBeValid();
expect(inputElm.val()).toBe('10');
expect(scope.value).toBe(10);
});
it('should adjust the element and model value if the max value changes on-the-fly', function() {
scope.max = 10;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="{{max}}" />');
helper.changeInputValueTo('5');
expect(inputElm).toBeValid();
scope.max = 0;
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(inputElm.val()).toBe('0');
scope.max = null;
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(inputElm.val()).toBe('0');
scope.max = '4';
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(inputElm.val()).toBe('0');
scope.max = 'abc';
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(inputElm.val()).toBe('0');
});
} else {
it('should validate if "range" is not implemented', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="10" />');
helper.changeInputValueTo('20');
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(scope.form.alias.$error.max).toBeTruthy();
helper.changeInputValueTo('0');
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(scope.form.alias.$error.max).toBeFalsy();
});
it('should validate even if the max value changes on-the-fly', function() {
scope.max = 10;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="{{max}}" />');
helper.changeInputValueTo('5');
expect(inputElm).toBeValid();
expect(scope.value).toBe(5);
scope.max = 0;
scope.$digest();
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(inputElm.val()).toBe('5');
scope.max = null;
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(5);
expect(inputElm.val()).toBe('5');
scope.max = '4';
scope.$digest();
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(inputElm.val()).toBe('5');
scope.max = 'abc';
scope.$digest();
expect(inputElm).toBeValid();
expect(scope.value).toBe(5);
expect(inputElm.val()).toBe('5');
});
}
});
describe('ngMax', function() {
it('should validate', function() {
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" ng-max="5" />');
helper.changeInputValueTo('20');
expect(inputElm).toBeInvalid();
expect(scope.value).toBeUndefined();
expect(scope.form.alias.$error.max).toBeTruthy();
helper.changeInputValueTo('0');
expect(inputElm).toBeValid();
expect(scope.value).toBe(0);
expect(scope.form.alias.$error.max).toBeFalsy();
});
it('should validate even if the ngMax value changes on-the-fly', function() {
scope.max = 10;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" ng-max="max" />');
helper.changeInputValueTo('5');
expect(inputElm).toBeValid();
scope.max = 0;
scope.$digest();
expect(inputElm).toBeInvalid();
scope.max = null;
scope.$digest();
expect(inputElm).toBeValid();
scope.max = '4';
scope.$digest();
expect(inputElm).toBeInvalid();
scope.max = 'abc';
scope.$digest();
expect(inputElm).toBeValid();
});
});
if (supportsRange) {
describe('min and max', function() {
it('should keep the initial default value when min and max are specified', function() {
scope.max = 80;
scope.min = 40;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="{{max}}" min="{{min}}" />');
expect(inputElm.val()).toBe('50');
expect(scope.value).toBe(50);
});
it('should set element and model value to min if max is less than min', function() {
scope.min = 40;
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" max="{{max}}" min="{{min}}" />');
expect(inputElm.val()).toBe('50');
expect(scope.value).toBe(50);
scope.max = 20;
scope.$digest();
expect(inputElm.val()).toBe('40');
expect(scope.value).toBe(40);
});
});
}
});
describe('email', function() {