diff --git a/docs/content/error/ngModel/numfmt.ngdoc b/docs/content/error/ngModel/numfmt.ngdoc index 19e50f522ee3..068c50701bbd 100644 --- a/docs/content/error/ngModel/numfmt.ngdoc +++ b/docs/content/error/ngModel/numfmt.ngdoc @@ -3,7 +3,7 @@ @fullName Model is not of type `number` @description -The number input directive `` 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. diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index df7af367e2d3..a01ce2fa662b 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1031,6 +1031,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 ``, + * 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 + + + +
+ + Model as range: +
+ Model as number:
+ Min:
+ Max:
+ value = {{value}}
+ myForm.range.$valid = {{myForm.range.$valid}}
+ myForm.range.$error = {{myForm.range.$error}} +
+
+
+ + * ## Range Input with ngMin & ngMax attributes + + * @example + + + +
+ Model as range: +
+ Model as number:
+ Min:
+ Max:
+ value = {{value}}
+ myForm.range.$valid = {{myForm.range.$valid}}
+ myForm.range.$error = {{myForm.range.$error}} +
+
+
+ + */ + 'range': rangeInputType, /** * @ngdoc input @@ -1382,10 +1489,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; @@ -1402,6 +1506,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; @@ -1436,6 +1546,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! diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js index 90b4bafabcbc..dfb192b63bff 100644 --- a/src/ng/directive/ngModel.js +++ b/src/ng/directive/ngModel.js @@ -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); } } diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 6b80cce7c1b3..23df77d88b6b 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -2819,6 +2819,430 @@ describe('input', function() { }); }); + describe('range', function() { + + var scope; + + var rangeTestEl = angular.element(''); + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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('', { + 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(''); + }).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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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(''); + + 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() {