diff --git a/src/jqLite.js b/src/jqLite.js index 802fdb574a13..e4ad42f86932 100644 --- a/src/jqLite.js +++ b/src/jqLite.js @@ -576,7 +576,8 @@ var ALIASED_ATTR = { 'ngMaxlength': 'maxlength', 'ngMin': 'min', 'ngMax': 'max', - 'ngPattern': 'pattern' + 'ngPattern': 'pattern', + 'ngStep': 'step' }; function getBooleanAttrName(element, name) { diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 94978da4e7e9..13990b4c0b68 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -679,7 +679,17 @@ var inputType = { * @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 error key if the value entered is less than `min`. + * Can be interpolated. * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * Can be interpolated. + * @param {string=} ngMin Like `min`, sets the `min` validation error key if the value entered is less than `ngMin`, + * but does not trigger HTML5 native validation. Takes an expression. + * @param {string=} ngMax Like `max`, sets the `max` validation error key if the value entered is greater than `ngMax`, + * but does not trigger HTML5 native validation. Takes an expression. + * @param {string=} step Sets the `step` validation error key if the value entered does not fit the `step` constraint. + * Can be interpolated. + * @param {string=} ngStep Like `step`, sets the `max` validation error key if the value entered does not fit the `ngStep` constraint, + * but does not trigger HTML5 native validation. Takes an expression. * @param {string=} required Sets `required` validation error key if the value is not entered. * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of @@ -1037,13 +1047,19 @@ var inputType = { * 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. + * 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: * @@ -1056,16 +1072,21 @@ var inputType = { * 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. + * 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. * - * Note that `input[range]` is not compatible with`ngMax` and `ngMin`, 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 and max = 100. + * 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. @@ -1073,6 +1094,8 @@ var inputType = { * 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. * @@ -1499,6 +1522,13 @@ function numberFormatterParser(ctrl) { }); } +function parseNumberAttrVal(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val); + } + return isNumber(val) && !isNaN(val) ? val : undefined; +} + function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { badInputChecker(scope, element, attr, ctrl); numberFormatterParser(ctrl); @@ -1511,10 +1541,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(); }); @@ -1527,10 +1554,20 @@ 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(); + }); + } + + if (isDefined(attr.step) || attr.ngStep) { + var stepVal; + ctrl.$validators.step = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || viewValue % stepVal === 0; + }; + + attr.$observe('step', function(val) { + stepVal = parseNumberAttrVal(val); // TODO(matsko): implement validateLater to reduce number of validations ctrl.$validate(); }); @@ -1545,9 +1582,11 @@ function rangeInputType(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); + hasMaxAttr = isDefined(attr.max), + hasStepAttr = isDefined(attr.step); var originalRender = ctrl.$render; @@ -1564,7 +1603,7 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { 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 range + // non-support browsers validate the min val function minValidator(modelValue, viewValue) { return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal; }; @@ -1576,7 +1615,7 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { 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; } : - // ngMax doesn't set the max attr, so the browser doesn't adjust the input value as setting max would + // non-support browsers validate the max val function maxValidator(modelValue, viewValue) { return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal; }; @@ -1584,20 +1623,32 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { 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) { - if (isDefined(val) && !isNumber(val)) { - val = parseFloat(val); - } - minVal = isNumber(val) && !isNaN(val) ? val : undefined; + minVal = parseNumberAttrVal(val); // ignore changes before model is initialized if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { return; @@ -1618,10 +1669,7 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { } function maxChange(val) { - if (isDefined(val) && !isNumber(val)) { - val = parseFloat(val); - } - maxVal = isNumber(val) && !isNaN(val) ? val : undefined; + maxVal = parseNumberAttrVal(val); // ignore changes before model is initialized if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { return; @@ -1642,6 +1690,21 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { } } + function stepChange(val) { + stepVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumber(ctrl.$modelValue) && isNaN(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) { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index db804043a1e5..d61860df0a8d 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -2621,6 +2621,157 @@ describe('input', function() { }); }); + describe('step', function() { + it('should validate', function() { + $rootScope.step = 10; + $rootScope.value = 20; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('20'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(20); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('18'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('18'); + expect($rootScope.value).toBeUndefined(); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + $rootScope.$apply('value = 12'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('12'); + expect($rootScope.value).toBe(12); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + }); + + it('should validate even if the step value changes on-the-fly', function() { + $rootScope.step = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + + // Step changes, but value matches + $rootScope.$apply('step = 5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + // Step changes, value does not match + $rootScope.$apply('step = 6'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + // null = valid + $rootScope.$apply('step = null'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + // Step val as string + $rootScope.$apply('step = "7"'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + // unparsable string is ignored + $rootScope.$apply('step = "abc"'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + }); + }); + + + describe('ngStep', function() { + it('should validate', function() { + $rootScope.step = 10; + $rootScope.value = 20; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('20'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(20); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('18'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('18'); + expect($rootScope.value).toBeUndefined(); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + $rootScope.$apply('value = 12'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('12'); + expect($rootScope.value).toBe(12); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + }); + + it('should validate even if the step value changes on-the-fly', function() { + $rootScope.step = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + + // Step changes, but value matches + $rootScope.$apply('step = 5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + // Step changes, value does not match + $rootScope.$apply('step = 6'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + // null = valid + $rootScope.$apply('step = null'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + // Step val as string + $rootScope.$apply('step = "7"'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + // unparsable string is ignored + $rootScope.$apply('step = "abc"'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + }); + }); + describe('required', function() { @@ -3290,6 +3441,151 @@ describe('input', function() { } + + 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() {