From 9900b9b4d7b7a1061e62cfe982f9e87bd7af4232 Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Mon, 10 Oct 2016 17:20:32 +0300 Subject: [PATCH 1/3] test(validators): fix broken test --- test/ng/directive/validatorsSpec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ng/directive/validatorsSpec.js b/test/ng/directive/validatorsSpec.js index 6cd2c14dd7b9..224b848a42cc 100644 --- a/test/ng/directive/validatorsSpec.js +++ b/test/ng/directive/validatorsSpec.js @@ -351,7 +351,7 @@ describe('validators', function() { it('should accept values of any length when maxlength is non-numeric', function() { - var inputElm = helper.compileInput(''); + var inputElm = helper.compileInput(''); helper.changeInputValueTo('aaaaaaaaaa'); $rootScope.$apply('maxlength = "5"'); From c2f6986ebd0171c30be57771dd60b76142f8d17f Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Sat, 8 Oct 2016 11:18:15 +0300 Subject: [PATCH 2/3] feat(input): re-add support for binding to `input[range]` This commit re-applies the related (previously reverted) commits. A follow-up commit will make the support opt-in in order to avoid a breaking change. Included commits: - 296da4b - `feat(input): add support for binding to input[type=range]` (previously reverted with 6a167e8) - b78539b - `fix(input[range]): correctly handle min/max; remove ngMin/ngMax support` (previously reverted with aa60491) - 90c08b8 - `feat(input[range]): support step` (previously reverted with 5b633d8) --- docs/content/error/ngModel/numfmt.ngdoc | 2 +- src/ng/directive/input.js | 276 ++++++++++- src/ng/directive/ngModel.js | 3 +- test/ng/directive/inputSpec.js | 587 ++++++++++++++++++++++++ 4 files changed, 854 insertions(+), 14 deletions(-) diff --git a/docs/content/error/ngModel/numfmt.ngdoc b/docs/content/error/ngModel/numfmt.ngdoc index d59248f79393..7d32fc595b3c 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 81e11f9309e1..a0963ce18717 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1027,6 +1027,121 @@ 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 without any default values for `min`, `max` and `step`. 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. + * - additionally, the current `step` is respected, so the nearest value that satisfies a step + * is used. + * + * See the [HTML Spec on input[type=range]](https://www.w3.org/TR/html5/forms.html#range-state-(type=range)) + * for more info. + * + * 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`, + * `max`, and `step` 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. + * + * However, `step` is currently only fully implemented by Firefox. Other browsers have problems + * when the step value changes dynamically - they do not adjust the element value correctly, but + * instead may set the `stepMismatch` error. If that's the case, the Angular will set the `step` + * error on the input, and set the model to `undefined`. + * + * Note that `input[range]` is not compatible with`ngMax`, `ngMin`, and `ngStep`, because they do + * not set the `min` and `max` attributes, which means that the browser won't automatically adjust + * the input value based on their values, and will always assume min = 0, max = 100, and step = 1. + * + * @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=} step Sets the `step` validation to ensure that the value entered matches the `step` + * Can be interpolated. + * @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 @@ -1378,10 +1493,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; @@ -1398,6 +1510,19 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { } return value; }); +} + +function parseNumberAttrVal(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val); + } + return !isNumberNaN(val) ? val : undefined; +} + +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; @@ -1406,10 +1531,7 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { }; attr.$observe('min', function(val) { - if (isDefined(val) && !isNumber(val)) { - val = parseFloat(val); - } - minVal = isNumber(val) && !isNaN(val) ? val : undefined; + minVal = parseNumberAttrVal(val); // TODO(matsko): implement validateLater to reduce number of validations ctrl.$validate(); }); @@ -1422,16 +1544,146 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { }; attr.$observe('max', function(val) { - if (isDefined(val) && !isNumber(val)) { - val = parseFloat(val); - } - maxVal = isNumber(val) && !isNaN(val) ? val : undefined; + maxVal = parseNumberAttrVal(val); // TODO(matsko): implement validateLater to reduce number of validations ctrl.$validate(); }); } } +function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); + numberFormatterParser(ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var supportsRange = ctrl.$$hasNativeValidators && element[0].type === 'range', + minVal = supportsRange ? 0 : undefined, + maxVal = supportsRange ? 100 : undefined, + stepVal = supportsRange ? 1 : undefined, + validity = element[0].validity, + hasMinAttr = isDefined(attr.min), + hasMaxAttr = isDefined(attr.max), + hasStepAttr = isDefined(attr.step); + + 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; + + if (hasMinAttr) { + ctrl.$validators.min = supportsRange ? + // Since all browsers set the input to a valid value, we don't need to check validity + function noopMinValidator() { return true; } : + // non-support browsers validate the min val + function minValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal; + }; + + setInitialValueAndObserver('min', minChange); + } + + if (hasMaxAttr) { + ctrl.$validators.max = supportsRange ? + // Since all browsers set the input to a valid value, we don't need to check validity + function noopMaxValidator() { return true; } : + // non-support browsers validate the max val + function maxValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal; + }; + + setInitialValueAndObserver('max', maxChange); + } + + if (hasStepAttr) { + ctrl.$validators.step = supportsRange ? + function nativeStepValidator() { + // Currently, only FF implements the spec on step change correctly (i.e. adjusting the + // input element value to a valid value). It's possible that other browsers set the stepMismatch + // validity error instead, so we can at least report an error in that case. + return !validity.stepMismatch; + } : + // ngStep doesn't set the setp attr, so the browser doesn't adjust the input value as setting step would + function stepValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || viewValue % stepVal === 0; + }; + + setInitialValueAndObserver('step', stepChange); + } + + function setInitialValueAndObserver(htmlAttrName, changeFn) { + // interpolated attributes set the attribute value only after a digest, but we need the + // attribute value when the input is first rendered, so that the browser can adjust the + // input value based on the min/max value + element.attr(htmlAttrName, attr[htmlAttrName]); + attr.$observe(htmlAttrName, changeFn); + } + + function minChange(val) { + minVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + + if (supportsRange) { + var elVal = element.val(); + // IE11 doesn't set the el val correctly if the minVal is greater than the element value + if (minVal > elVal) { + elVal = minVal; + element.val(elVal); + } + ctrl.$setViewValue(elVal); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } + + function maxChange(val) { + maxVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + + if (supportsRange) { + 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); + // IE11 and Chrome don't set the value to the minVal when max < min + elVal = maxVal < minVal ? minVal : maxVal; + } + ctrl.$setViewValue(elVal); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } + + function stepChange(val) { + stepVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + + // Some browsers don't adjust the input value correctly, but set the stepMismatch error + if (supportsRange && ctrl.$viewValue !== element.val()) { + ctrl.$setViewValue(element.val()); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } +} + 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 b5ff080393fd..18365350774a 100644 --- a/src/ng/directive/ngModel.js +++ b/src/ng/directive/ngModel.js @@ -883,7 +883,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 8c6dce8b8a6e..82111d97371b 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -2791,6 +2791,593 @@ 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 and default min/max', 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 when default min/max', 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) { + + it('should initialize correctly with non-default model and min value', function() { + scope.value = -3; + scope.min = -5; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-3'); + expect(scope.value).toBe(-3); + expect(scope.form.alias.$error.min).toBeFalsy(); + }); + + // Browsers that implement range will never allow you to set the value < min values + it('should adjust invalid input values', 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 set the model to the min val if it is less than the min val', function() { + scope.value = -10; + // Default min is 0 + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('0'); + expect(scope.value).toBe(0); + + scope.$apply('value = 5; min = 10'); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect(scope.value).toBe(10); + }); + + 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 { + // input[type=range] will become type=text in browsers that don't support it + + it('should validate if "range" is not implemented', function() { + 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 not assume a min val of 0 if the min interpolates to a non-number', function() { + scope.value = -10; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-10'); + expect(scope.value).toBe(-10); + expect(scope.form.alias.$error.min).toBeFalsy(); + + helper.changeInputValueTo('-5'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-5'); + expect(scope.value).toBe(-5); + expect(scope.form.alias.$error.min).toBeFalsy(); + + scope.$apply('max = "null"'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-5'); + expect(scope.value).toBe(-5); + expect(scope.form.alias.$error.max).toBeFalsy(); + + scope.$apply('max = "asdf"'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-5'); + expect(scope.value).toBe(-5); + expect(scope.form.alias.$error.max).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('max', function() { + + if (supportsRange) { + // Browsers that implement range will never allow you to set the value > max value + it('should initialize correctly with non-default model and max value', function() { + scope.value = 130; + scope.max = 150; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('130'); + expect(scope.value).toBe(130); + expect(scope.form.alias.$error.max).toBeFalsy(); + }); + + 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 greater than the max val', function() { + scope.value = 110; + // Default max is 100 + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('100'); + expect(scope.value).toBe(100); + + scope.$apply('value = 90; 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(''); + + 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 not assume a max val of 100 if the max attribute interpolates to a non-number', function() { + scope.value = 120; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('120'); + expect(scope.value).toBe(120); + expect(scope.form.alias.$error.max).toBeFalsy(); + + helper.changeInputValueTo('140'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('140'); + expect(scope.value).toBe(140); + expect(scope.form.alias.$error.max).toBeFalsy(); + + scope.$apply('max = null'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('140'); + expect(scope.value).toBe(140); + expect(scope.form.alias.$error.max).toBeFalsy(); + + scope.$apply('max = "asdf"'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('140'); + expect(scope.value).toBe(140); + 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'); + }); + } + }); + + if (supportsRange) { + + describe('min and max', function() { + + it('should set the correct initial value when min and max are specified', function() { + scope.max = 80; + scope.min = 40; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('60'); + expect(scope.value).toBe(60); + }); + + 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('70'); + expect(scope.value).toBe(70); + + scope.max = 20; + scope.$digest(); + + expect(inputElm.val()).toBe('40'); + expect(scope.value).toBe(40); + }); + }); + + } + + + describe('step', function() { + + if (supportsRange) { + // Browsers that implement range will never allow you to set a value that doesn't match the step value + // However, currently only Firefox fully inplements the spec when setting the value after the step value changes. + // Other browsers fail in various edge cases, which is why they are not tested here. + it('should round the input value to the nearest step on user input', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('9'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('7'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('7.5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + }); + + it('should round the input value to the nearest step when setting the model', function() { + var inputElm = helper.compileInput(''); + + scope.$apply('value = 10'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 5'); + expect(inputElm.val()).toBe('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 7.5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 7'); + expect(inputElm.val()).toBe('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 9'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + }); + + } else { + it('should validate if "range" is not implemented', function() { + scope.step = 10; + scope.value = 20; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('20'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(20); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('18'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('18'); + expect(scope.value).toBeUndefined(); + expect(scope.form.alias.$error.step).toBeTruthy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 12'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('12'); + expect(scope.value).toBe(12); + expect(scope.form.alias.$error.step).toBeTruthy(); + }); + + it('should validate even if the step value changes on-the-fly', function() { + scope.step = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + + // Step changes, but value matches + scope.$apply('step = 5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + // Step changes, value does not match + scope.$apply('step = 6'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeTruthy(); + + // null = valid + scope.$apply('step = null'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeFalsy(); + + // Step val as string + scope.$apply('step = "7"'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeTruthy(); + + // unparsable string is ignored + scope.$apply('step = "abc"'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeFalsy(); + }); + } + }); + }); describe('email', function() { From c94a921faf5159b41eb0f3bdc54c30020e272839 Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Sat, 8 Oct 2016 17:23:45 +0300 Subject: [PATCH 3/3] fix(input[range]): make support for `input[range]` opt-in --- docs/content/error/ngModel/numfmt.ngdoc | 5 +- src/ng/directive/input.js | 40 +++++++++++-- test/ng/directive/inputSpec.js | 79 ++++++++++++++++--------- 3 files changed, 89 insertions(+), 35 deletions(-) diff --git a/docs/content/error/ngModel/numfmt.ngdoc b/docs/content/error/ngModel/numfmt.ngdoc index 7d32fc595b3c..9a361a74158e 100644 --- a/docs/content/error/ngModel/numfmt.ngdoc +++ b/docs/content/error/ngModel/numfmt.ngdoc @@ -3,7 +3,8 @@ @fullName Model is not of type `number` @description -The `input[number]` and `input[range]` directives require the model to be a `number`. +The `input[type="number"]` and `input[type="range"][ng-input-range]` directives require the model to +be a `number`. If the model is something else, this error will be thrown. @@ -17,7 +18,7 @@ pipeline. ## Example In this example, our model stores the number as a string, so we provide the `stringToNumber` -directive to convert it into the format the `input[number]` directive expects. +directive to convert it into the format the `input[type="number"]` directive expects. diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index a0963ce18717..9c305b53db6b 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1034,6 +1034,28 @@ var inputType = { * @description * Native range input with validation and transformation. * + *
+ *

+ * In v1.5.9+, in order to avoid interfering with already existing, custom directives for + * `input[range]`, you need to let Angular know that you want to enable its built-in support. + * You can do this by adding the `ng-input-range` attribute to the input element. E.g.: + * `` + *


+ *

+ * Input elements without the `ng-input-range` attibute will continue to be treated the same + * as in previous versions (e.g. their model value will be a string not a number and Angular + * will not take `min`/`max`/`step` attributes and properties into account). + *


+ *

+ * **Note:** From v1.6.x onwards, the support for `input[range]` will be always enabled and + * the `ng-input-range` attribute will have no effect. + *


+ *

+ * This documentation page refers to elements which have the built-in support enabled; i.e. + * elements _with_ the `ng-input-range` attribute. + *

+ *
+ * * The model for the range input must always be a `Number`. * * IE9 and other browsers that do not support the `range` type fall back @@ -1055,7 +1077,7 @@ var inputType = { * * 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 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. * @@ -1074,10 +1096,12 @@ var inputType = { * instead may set the `stepMismatch` error. If that's the case, the Angular will set the `step` * error on the input, and set the model to `undefined`. * - * Note that `input[range]` is not compatible with`ngMax`, `ngMin`, and `ngStep`, because they do + * Note that `input[range]` is not compatible with `ngMax`, `ngMin`, and `ngStep`, because they do * not set the `min` and `max` attributes, which means that the browser won't automatically adjust * the input value based on their values, and will always assume min = 0, max = 100, and step = 1. * + * @param ngInputRange The presense of this attribute enables the built-in support for + * `input[range]`. * @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 @@ -1102,7 +1126,7 @@ var inputType = {
- Model as range: + Model as range:
Model as number:
Min:
@@ -1128,7 +1152,7 @@ var inputType = { }]); - Model as range: + Model as range:
Model as number:
Min:
@@ -1521,8 +1545,8 @@ function parseNumberAttrVal(val) { function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { badInputChecker(scope, element, attr, ctrl); - numberFormatterParser(ctrl); baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + numberFormatterParser(ctrl); if (isDefined(attr.min) || attr.ngMin) { var minVal; @@ -1971,7 +1995,11 @@ var inputDirective = ['$browser', '$sniffer', '$filter', '$parse', link: { pre: function(scope, element, attr, ctrls) { if (ctrls[0]) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, + var type = lowercase(attr.type); + if ((type === 'range') && !attr.hasOwnProperty('ngInputRange')) { + type = 'text'; + } + (inputType[type] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, $browser, $filter, $parse); } } diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 82111d97371b..1d81f9a5efcf 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -2801,6 +2801,26 @@ describe('input', function() { scope = $rootScope; }); + it('should be treated as `input[text]` without the `ng-input-range` attribute', function() { + var inputElm = helper.compileInput(''); + var ngModel = inputElm.controller('ngModel'); + + helper.changeInputValueTo(25); + + expect(scope.age).toBe('25'); + expect(ngModel.$$parserName).toBeUndefined(); + }); + + it('should not be treated as `input[text]` with the `ng-input-range` attribute', function() { + var inputElm = helper.compileInput(''); + var ngModel = inputElm.controller('ngModel'); + + helper.changeInputValueTo('25'); + + expect(scope.age).toBe(25); + expect(ngModel.$$parserName).toBe('number'); + }); + 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 @@ -2809,7 +2829,7 @@ describe('input', function() { // 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(''); + var inputElm = compileRangeInput('ng-model="age"'); helper.changeInputValueTo('25'); expect(scope.age).toBe(25); @@ -2820,7 +2840,7 @@ describe('input', function() { }); it('should set model to 50 when no value specified and default min/max', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="age"'); expect(inputElm.val()).toBe('50'); @@ -2830,7 +2850,7 @@ describe('input', function() { }); it('should parse non-number values to 50 when default min/max', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="age"'); scope.$apply('age = 10'); expect(inputElm.val()).toBe('10'); @@ -2843,7 +2863,7 @@ describe('input', function() { } else { it('should reset the model if view is invalid', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="age"'); scope.$apply('age = 100'); expect(inputElm.val()).toBe('100'); @@ -2856,7 +2876,7 @@ describe('input', function() { } it('should parse the input value to a Number', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="age"'); helper.changeInputValueTo('75'); expect(scope.age).toBe(75); @@ -2866,7 +2886,7 @@ describe('input', function() { it('should only invalidate the model if suffering from bad input when the data is parsed', function() { scope.age = 60; - var inputElm = helper.compileInput('', { + var inputElm = compileRangeInput('ng-model="age"', { valid: false, badInput: true }); @@ -2883,7 +2903,7 @@ describe('input', function() { it('should throw if the model value is not a number', function() { expect(function() { scope.value = 'one'; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value"'); }).toThrowMinErr('ngModel', 'numfmt', 'Expected `one` to be a number'); }); @@ -2895,7 +2915,7 @@ describe('input', function() { it('should initialize correctly with non-default model and min value', function() { scope.value = -3; scope.min = -5; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); expect(inputElm).toBeValid(); expect(inputElm.val()).toBe('-3'); @@ -2905,7 +2925,7 @@ describe('input', function() { // Browsers that implement range will never allow you to set the value < min values it('should adjust invalid input values', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="10"'); helper.changeInputValueTo('5'); expect(inputElm).toBeValid(); @@ -2921,7 +2941,7 @@ describe('input', function() { it('should set the model to the min val if it is less than the min val', function() { scope.value = -10; // Default min is 0 - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); expect(inputElm).toBeValid(); expect(inputElm.val()).toBe('0'); @@ -2936,7 +2956,7 @@ describe('input', function() { it('should adjust the element and model value when the min value changes on-the-fly', function() { scope.min = 10; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); helper.changeInputValueTo('15'); expect(inputElm).toBeValid(); @@ -2970,7 +2990,7 @@ describe('input', function() { // input[type=range] will become type=text in browsers that don't support it it('should validate if "range" is not implemented', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="10"'); helper.changeInputValueTo('5'); expect(inputElm).toBeInvalid(); @@ -2985,7 +3005,7 @@ describe('input', function() { it('should not assume a min val of 0 if the min interpolates to a non-number', function() { scope.value = -10; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); expect(inputElm).toBeValid(); expect(inputElm.val()).toBe('-10'); @@ -3013,7 +3033,7 @@ describe('input', function() { it('should validate even if the min value changes on-the-fly', function() { scope.min = 10; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); helper.changeInputValueTo('15'); expect(inputElm).toBeValid(); @@ -3054,7 +3074,7 @@ describe('input', function() { it('should initialize correctly with non-default model and max value', function() { scope.value = 130; scope.max = 150; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); expect(inputElm).toBeValid(); expect(inputElm.val()).toBe('130'); @@ -3063,7 +3083,7 @@ describe('input', function() { }); it('should validate', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="10"'); helper.changeInputValueTo('20'); expect(inputElm).toBeValid(); @@ -3079,7 +3099,7 @@ describe('input', function() { it('should set the model to the max val if it is greater than the max val', function() { scope.value = 110; // Default max is 100 - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); expect(inputElm).toBeValid(); expect(inputElm.val()).toBe('100'); @@ -3094,7 +3114,7 @@ describe('input', function() { it('should adjust the element and model value if the max value changes on-the-fly', function() { scope.max = 10; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); helper.changeInputValueTo('5'); expect(inputElm).toBeValid(); @@ -3126,7 +3146,7 @@ describe('input', function() { } else { it('should validate if "range" is not implemented', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="10"'); helper.changeInputValueTo('20'); expect(inputElm).toBeInvalid(); @@ -3141,7 +3161,7 @@ describe('input', function() { it('should not assume a max val of 100 if the max attribute interpolates to a non-number', function() { scope.value = 120; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); expect(inputElm).toBeValid(); expect(inputElm.val()).toBe('120'); @@ -3169,7 +3189,7 @@ describe('input', function() { it('should validate even if the max value changes on-the-fly', function() { scope.max = 10; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); helper.changeInputValueTo('5'); expect(inputElm).toBeValid(); @@ -3209,7 +3229,7 @@ describe('input', function() { it('should set the correct initial value when min and max are specified', function() { scope.max = 80; scope.min = 40; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}" min="{{min}}"'); expect(inputElm.val()).toBe('60'); expect(scope.value).toBe(60); @@ -3217,7 +3237,7 @@ describe('input', function() { it('should set element and model value to min if max is less than min', function() { scope.min = 40; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}" min="{{min}}"'); expect(inputElm.val()).toBe('70'); expect(scope.value).toBe(70); @@ -3240,7 +3260,7 @@ describe('input', function() { // However, currently only Firefox fully inplements the spec when setting the value after the step value changes. // Other browsers fail in various edge cases, which is why they are not tested here. it('should round the input value to the nearest step on user input', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" step="5"'); helper.changeInputValueTo('5'); expect(inputElm).toBeValid(); @@ -3269,7 +3289,7 @@ describe('input', function() { }); it('should round the input value to the nearest step when setting the model', function() { - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" step="5"'); scope.$apply('value = 10'); expect(inputElm.val()).toBe('10'); @@ -3306,7 +3326,7 @@ describe('input', function() { it('should validate if "range" is not implemented', function() { scope.step = 10; scope.value = 20; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" step="{{step}}"'); expect(inputElm.val()).toBe('20'); expect(inputElm).toBeValid(); @@ -3334,7 +3354,7 @@ describe('input', function() { it('should validate even if the step value changes on-the-fly', function() { scope.step = 10; - var inputElm = helper.compileInput(''); + var inputElm = compileRangeInput('ng-model="value" name="alias" step="{{step}}"'); helper.changeInputValueTo('10'); expect(inputElm).toBeValid(); @@ -3377,6 +3397,11 @@ describe('input', function() { }); } }); + + // Helpers + function compileRangeInput(attrs, opts) { + return helper.compileInput('', opts); + } }); describe('email', function() {