Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 9a8b8aa

Browse files
committed
feat(input[range]): support step
Step support works like min / max, but with the following caveat. Currently, only Firefox fully implements the spec. Other browsers (Chrome, Safari, Edge) have issues when the step value changes after the input has been changed. They do not adjust the input value to a valid value, but instead set the stepMismatch validity state. Angular will take this validity state, and forward it as the ngModel "step" error. Adjusting the error ourselves would add too much code, as the logic is quite involved.
1 parent 88f3517 commit 9a8b8aa

File tree

2 files changed

+212
-27
lines changed

2 files changed

+212
-27
lines changed

Diff for: src/ng/directive/input.js

+67-27
Original file line numberDiff line numberDiff line change
@@ -1037,13 +1037,19 @@ var inputType = {
10371037
* The model for the range input must always be a `Number`.
10381038
*
10391039
* IE9 and other browsers that do not support the `range` type fall back
1040-
* to a text input. Model binding, validation and number parsing are nevertheless supported.
1040+
* to a text input without any default values for `min`, `max` and `step`. Model binding,
1041+
* validation and number parsing are nevertheless supported.
10411042
*
10421043
* Browsers that support range (latest Chrome, Safari, Firefox, Edge) treat `input[range]`
10431044
* in a way that never allows the input to hold an invalid value. That means:
10441045
* - any non-numerical value is set to `(max + min) / 2`.
10451046
* - any numerical value that is less than the current min val, or greater than the current max val
10461047
* is set to the min / max val respectively.
1048+
* - additionally, the current `step` is respected, so the nearest value that satisfies a step
1049+
* is used.
1050+
*
1051+
* See the [HTML Spec on input[type=range]](https://www.w3.org/TR/html5/forms.html#range-state-(type=range))
1052+
* for more info.
10471053
*
10481054
* This has the following consequences for Angular:
10491055
*
@@ -1056,23 +1062,30 @@ var inputType = {
10561062
* That means the model for range will immediately be set to `50` after `ngModel` has been
10571063
* initialized. It also means a range input can never have the required error.
10581064
*
1059-
* This does not only affect changes to the model value, but also to the values of the `min` and
1060-
* `max` attributes. When these change in a way that will cause the browser to modify the input value,
1061-
* Angular will also update the model value.
1065+
* This does not only affect changes to the model value, but also to the values of the `min`,
1066+
* `max`, and `step` attributes. When these change in a way that will cause the browser to modify
1067+
* the input value, Angular will also update the model value.
10621068
*
10631069
* Automatic value adjustment also means that a range input element can never have the `required`,
10641070
* `min`, or `max` errors.
10651071
*
1066-
* Note that `input[range]` is not compatible with`ngMax` and `ngMin`, because they do not set the
1067-
* `min` and `max` attributes, which means that the browser won't automatically adjust the input
1068-
* value based on their values, and will always assume min = 0 and max = 100.
1072+
* However, `step` is currently only fully implemented by Firefox. Other browsers have problems
1073+
* when the step value changes dynamically - they do not adjust the element value correctly, but
1074+
* instead may set the `stepMismatch` error. If that's the case, the Angular will set the `step`
1075+
* error on the input, and set the model to `undefined`.
1076+
*
1077+
* Note that `input[range]` is not compatible with`ngMax`, `ngMin`, and `ngStep`, because they do
1078+
* not set the `min` and `max` attributes, which means that the browser won't automatically adjust
1079+
* the input value based on their values, and will always assume min = 0, max = 100, and step = 1.
10691080
*
10701081
* @param {string} ngModel Assignable angular expression to data-bind to.
10711082
* @param {string=} name Property name of the form under which the control is published.
10721083
* @param {string=} min Sets the `min` validation to ensure that the value entered is greater
10731084
* than `min`. Can be interpolated.
10741085
* @param {string=} max Sets the `max` validation to ensure that the value entered is less than `max`.
10751086
* Can be interpolated.
1087+
* @param {string=} step Sets the `step` validation to ensure that the value entered matches the `step`
1088+
* Can be interpolated.
10761089
* @param {string=} ngChange Angular expression to be executed when the ngModel value changes due
10771090
* to user interaction with the input element.
10781091
*
@@ -1499,6 +1512,13 @@ function numberFormatterParser(ctrl) {
14991512
});
15001513
}
15011514

1515+
function parseNumberAttrVal(val) {
1516+
if (isDefined(val) && !isNumber(val)) {
1517+
val = parseFloat(val);
1518+
}
1519+
return isNumber(val) && !isNaN(val) ? val : undefined;
1520+
}
1521+
15021522
function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
15031523
badInputChecker(scope, element, attr, ctrl);
15041524
numberFormatterParser(ctrl);
@@ -1511,10 +1531,7 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
15111531
};
15121532

15131533
attr.$observe('min', function(val) {
1514-
if (isDefined(val) && !isNumber(val)) {
1515-
val = parseFloat(val);
1516-
}
1517-
minVal = isNumber(val) && !isNaN(val) ? val : undefined;
1534+
minVal = parseNumberAttrVal(val);
15181535
// TODO(matsko): implement validateLater to reduce number of validations
15191536
ctrl.$validate();
15201537
});
@@ -1527,10 +1544,7 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
15271544
};
15281545

15291546
attr.$observe('max', function(val) {
1530-
if (isDefined(val) && !isNumber(val)) {
1531-
val = parseFloat(val);
1532-
}
1533-
maxVal = isNumber(val) && !isNaN(val) ? val : undefined;
1547+
maxVal = parseNumberAttrVal(val);
15341548
// TODO(matsko): implement validateLater to reduce number of validations
15351549
ctrl.$validate();
15361550
});
@@ -1545,9 +1559,11 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
15451559
var supportsRange = ctrl.$$hasNativeValidators && element[0].type === 'range',
15461560
minVal = supportsRange ? 0 : undefined,
15471561
maxVal = supportsRange ? 100 : undefined,
1562+
stepVal = supportsRange ? 1 : undefined,
15481563
validity = element[0].validity,
15491564
hasMinAttr = isDefined(attr.min),
1550-
hasMaxAttr = isDefined(attr.max);
1565+
hasMaxAttr = isDefined(attr.max),
1566+
hasStepAttr = isDefined(attr.step);
15511567

15521568
var originalRender = ctrl.$render;
15531569

@@ -1564,7 +1580,7 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
15641580
ctrl.$validators.min = supportsRange ?
15651581
// Since all browsers set the input to a valid value, we don't need to check validity
15661582
function noopMinValidator() { return true; } :
1567-
// non-support browsers validate the range
1583+
// non-support browsers validate the min val
15681584
function minValidator(modelValue, viewValue) {
15691585
return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal;
15701586
};
@@ -1576,28 +1592,40 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
15761592
ctrl.$validators.max = supportsRange ?
15771593
// Since all browsers set the input to a valid value, we don't need to check validity
15781594
function noopMaxValidator() { return true; } :
1579-
// ngMax doesn't set the max attr, so the browser doesn't adjust the input value as setting max would
1595+
// non-support browsers validate the max val
15801596
function maxValidator(modelValue, viewValue) {
15811597
return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal;
15821598
};
15831599

15841600
setInitialValueAndObserver('max', maxChange);
15851601
}
15861602

1603+
if (hasStepAttr) {
1604+
ctrl.$validators.step = supportsRange ?
1605+
function nativeStepValidator() {
1606+
// Currently, only FF implements the spec on step change correctly (i.e. adjusting the
1607+
// input element value to a valid value). It's possible that other browsers set the stepMismatch
1608+
// validity error instead, so we can at least report an error in that case.
1609+
return !validity.stepMismatch;
1610+
} :
1611+
// ngStep doesn't set the setp attr, so the browser doesn't adjust the input value as setting step would
1612+
function stepValidator(modelValue, viewValue) {
1613+
return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || viewValue % stepVal === 0;
1614+
};
1615+
1616+
setInitialValueAndObserver('step', stepChange);
1617+
}
1618+
15871619
function setInitialValueAndObserver(htmlAttrName, changeFn) {
15881620
// interpolated attributes set the attribute value only after a digest, but we need the
15891621
// attribute value when the input is first rendered, so that the browser can adjust the
15901622
// input value based on the min/max value
15911623
element.attr(htmlAttrName, attr[htmlAttrName]);
1592-
15931624
attr.$observe(htmlAttrName, changeFn);
15941625
}
15951626

15961627
function minChange(val) {
1597-
if (isDefined(val) && !isNumber(val)) {
1598-
val = parseFloat(val);
1599-
}
1600-
minVal = isNumber(val) && !isNaN(val) ? val : undefined;
1628+
minVal = parseNumberAttrVal(val);
16011629
// ignore changes before model is initialized
16021630
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
16031631
return;
@@ -1618,10 +1646,7 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
16181646
}
16191647

16201648
function maxChange(val) {
1621-
if (isDefined(val) && !isNumber(val)) {
1622-
val = parseFloat(val);
1623-
}
1624-
maxVal = isNumber(val) && !isNaN(val) ? val : undefined;
1649+
maxVal = parseNumberAttrVal(val);
16251650
// ignore changes before model is initialized
16261651
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
16271652
return;
@@ -1642,6 +1667,21 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
16421667
}
16431668
}
16441669

1670+
function stepChange(val) {
1671+
stepVal = parseNumberAttrVal(val);
1672+
// ignore changes before model is initialized
1673+
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
1674+
return;
1675+
}
1676+
1677+
// Some browsers don't adjust the input value correctly, but set the stepMismatch error
1678+
if (supportsRange && ctrl.$viewValue !== element.val()) {
1679+
ctrl.$setViewValue(element.val());
1680+
} else {
1681+
// TODO(matsko): implement validateLater to reduce number of validations
1682+
ctrl.$validate();
1683+
}
1684+
}
16451685
}
16461686

16471687
function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) {

Diff for: test/ng/directive/inputSpec.js

+145
Original file line numberDiff line numberDiff line change
@@ -3290,6 +3290,151 @@ describe('input', function() {
32903290

32913291
}
32923292

3293+
3294+
describe('step', function() {
3295+
3296+
if (supportsRange) {
3297+
// Browsers that implement range will never allow you to set a value that doesn't match the step value
3298+
// However, currently only Firefox fully inplements the spec when setting the value after the step value changes.
3299+
// Other browsers fail in various edge cases, which is why they are not tested here.
3300+
it('should round the input value to the nearest step on user input', function() {
3301+
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" step="5" />');
3302+
3303+
helper.changeInputValueTo('5');
3304+
expect(inputElm).toBeValid();
3305+
expect(scope.value).toBe(5);
3306+
expect(scope.form.alias.$error.step).toBeFalsy();
3307+
3308+
helper.changeInputValueTo('10');
3309+
expect(inputElm).toBeValid();
3310+
expect(scope.value).toBe(10);
3311+
expect(scope.form.alias.$error.step).toBeFalsy();
3312+
3313+
helper.changeInputValueTo('9');
3314+
expect(inputElm).toBeValid();
3315+
expect(scope.value).toBe(10);
3316+
expect(scope.form.alias.$error.step).toBeFalsy();
3317+
3318+
helper.changeInputValueTo('7');
3319+
expect(inputElm).toBeValid();
3320+
expect(scope.value).toBe(5);
3321+
expect(scope.form.alias.$error.step).toBeFalsy();
3322+
3323+
helper.changeInputValueTo('7.5');
3324+
expect(inputElm).toBeValid();
3325+
expect(scope.value).toBe(10);
3326+
expect(scope.form.alias.$error.step).toBeFalsy();
3327+
});
3328+
3329+
it('should round the input value to the nearest step when setting the model', function() {
3330+
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" step="5" />');
3331+
3332+
scope.$apply('value = 10');
3333+
expect(inputElm.val()).toBe('10');
3334+
expect(inputElm).toBeValid();
3335+
expect(scope.value).toBe(10);
3336+
expect(scope.form.alias.$error.step).toBeFalsy();
3337+
3338+
scope.$apply('value = 5');
3339+
expect(inputElm.val()).toBe('5');
3340+
expect(inputElm).toBeValid();
3341+
expect(scope.value).toBe(5);
3342+
expect(scope.form.alias.$error.step).toBeFalsy();
3343+
3344+
scope.$apply('value = 7.5');
3345+
expect(inputElm.val()).toBe('10');
3346+
expect(inputElm).toBeValid();
3347+
expect(scope.value).toBe(10);
3348+
expect(scope.form.alias.$error.step).toBeFalsy();
3349+
3350+
scope.$apply('value = 7');
3351+
expect(inputElm.val()).toBe('5');
3352+
expect(inputElm).toBeValid();
3353+
expect(scope.value).toBe(5);
3354+
expect(scope.form.alias.$error.step).toBeFalsy();
3355+
3356+
scope.$apply('value = 9');
3357+
expect(inputElm.val()).toBe('10');
3358+
expect(inputElm).toBeValid();
3359+
expect(scope.value).toBe(10);
3360+
expect(scope.form.alias.$error.step).toBeFalsy();
3361+
});
3362+
3363+
} else {
3364+
it('should validate if "range" is not implemented', function() {
3365+
scope.step = 10;
3366+
scope.value = 20;
3367+
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" step="{{step}}" />');
3368+
3369+
expect(inputElm.val()).toBe('20');
3370+
expect(inputElm).toBeValid();
3371+
expect(scope.value).toBe(20);
3372+
expect(scope.form.alias.$error.step).toBeFalsy();
3373+
3374+
helper.changeInputValueTo('18');
3375+
expect(inputElm).toBeInvalid();
3376+
expect(inputElm.val()).toBe('18');
3377+
expect(scope.value).toBeUndefined();
3378+
expect(scope.form.alias.$error.step).toBeTruthy();
3379+
3380+
helper.changeInputValueTo('10');
3381+
expect(inputElm).toBeValid();
3382+
expect(inputElm.val()).toBe('10');
3383+
expect(scope.value).toBe(10);
3384+
expect(scope.form.alias.$error.step).toBeFalsy();
3385+
3386+
scope.$apply('value = 12');
3387+
expect(inputElm).toBeInvalid();
3388+
expect(inputElm.val()).toBe('12');
3389+
expect(scope.value).toBe(12);
3390+
expect(scope.form.alias.$error.step).toBeTruthy();
3391+
});
3392+
3393+
it('should validate even if the step value changes on-the-fly', function() {
3394+
scope.step = 10;
3395+
var inputElm = helper.compileInput('<input type="range" ng-model="value" name="alias" step="{{step}}" />');
3396+
3397+
helper.changeInputValueTo('10');
3398+
expect(inputElm).toBeValid();
3399+
expect(scope.value).toBe(10);
3400+
3401+
// Step changes, but value matches
3402+
scope.$apply('step = 5');
3403+
expect(inputElm.val()).toBe('10');
3404+
expect(inputElm).toBeValid();
3405+
expect(scope.value).toBe(10);
3406+
expect(scope.form.alias.$error.step).toBeFalsy();
3407+
3408+
// Step changes, value does not match
3409+
scope.$apply('step = 6');
3410+
expect(inputElm).toBeInvalid();
3411+
expect(scope.value).toBeUndefined();
3412+
expect(inputElm.val()).toBe('10');
3413+
expect(scope.form.alias.$error.step).toBeTruthy();
3414+
3415+
// null = valid
3416+
scope.$apply('step = null');
3417+
expect(inputElm).toBeValid();
3418+
expect(scope.value).toBe(10);
3419+
expect(inputElm.val()).toBe('10');
3420+
expect(scope.form.alias.$error.step).toBeFalsy();
3421+
3422+
// Step val as string
3423+
scope.$apply('step = "7"');
3424+
expect(inputElm).toBeInvalid();
3425+
expect(scope.value).toBeUndefined();
3426+
expect(inputElm.val()).toBe('10');
3427+
expect(scope.form.alias.$error.step).toBeTruthy();
3428+
3429+
// unparsable string is ignored
3430+
scope.$apply('step = "abc"');
3431+
expect(inputElm).toBeValid();
3432+
expect(scope.value).toBe(10);
3433+
expect(inputElm.val()).toBe('10');
3434+
expect(scope.form.alias.$error.step).toBeFalsy();
3435+
});
3436+
}
3437+
});
32933438
});
32943439

32953440
describe('email', function() {

0 commit comments

Comments
 (0)