diff --git a/docs/content/error/ngModel/numfmt.ngdoc b/docs/content/error/ngModel/numfmt.ngdoc index d59248f79393..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 number input directive `` requires 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 81e11f9309e1..9c305b53db6b 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1027,6 +1027,145 @@ var inputType = { */ 'radio': radioInputType, + /** + * @ngdoc input + * @name input[range] + * + * @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 + * 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 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 + * 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 +1517,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 +1534,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); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + numberFormatterParser(ctrl); if (isDefined(attr.min) || attr.ngMin) { var minVal; @@ -1406,10 +1555,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 +1568,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! @@ -1719,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/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..1d81f9a5efcf 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -2791,6 +2791,618 @@ describe('input', function() { }); }); + describe('range', function() { + + var scope; + + var rangeTestEl = angular.element(''); + var supportsRange = rangeTestEl[0].type === 'range'; + beforeEach(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 + // 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 = compileRangeInput('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 and default min/max', function() { + var inputElm = compileRangeInput('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 when default min/max', function() { + var inputElm = compileRangeInput('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 = compileRangeInput('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 = compileRangeInput('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 = compileRangeInput('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 = compileRangeInput('ng-model="value"'); + }).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 = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); + + 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 = compileRangeInput('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 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 = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); + + 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 = compileRangeInput('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 { + // 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 = compileRangeInput('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 not assume a min val of 0 if the min interpolates to a non-number', function() { + scope.value = -10; + var inputElm = compileRangeInput('ng-model="value" name="alias" min="{{min}}"'); + + 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 = compileRangeInput('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('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 = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); + + 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 = compileRangeInput('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 greater than the max val', function() { + scope.value = 110; + // Default max is 100 + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); + + 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 = compileRangeInput('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 = compileRangeInput('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 not assume a max val of 100 if the max attribute interpolates to a non-number', function() { + scope.value = 120; + var inputElm = compileRangeInput('ng-model="value" name="alias" max="{{max}}"'); + + 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 = compileRangeInput('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'); + }); + } + }); + + 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 = compileRangeInput('ng-model="value" name="alias" max="{{max}}" min="{{min}}"'); + + 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 = compileRangeInput('ng-model="value" name="alias" max="{{max}}" min="{{min}}"'); + + 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 = compileRangeInput('ng-model="value" name="alias" step="5"'); + + 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 = compileRangeInput('ng-model="value" name="alias" step="5"'); + + 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 = compileRangeInput('ng-model="value" name="alias" step="{{step}}"'); + + 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 = compileRangeInput('ng-model="value" name="alias" step="{{step}}"'); + + 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(); + }); + } + }); + + // Helpers + function compileRangeInput(attrs, opts) { + return helper.compileInput('', opts); + } + }); describe('email', function() { 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"');