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

feat(input): add opt-in support for input[range] #15229

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/content/error/ngModel/numfmt.ngdoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
@fullName Model is not of type `number`
@description

The number input directive `<input type="number">` 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.

Expand All @@ -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.


<example module="numfmt-error-module" name="number-format-error">
Expand Down
306 changes: 293 additions & 13 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,145 @@ var inputType = {
*/
'radio': radioInputType,

/**
* @ngdoc input
* @name input[range]
*
* @description
* Native range input with validation and transformation.
*
* <div class="alert alert-warning">
* <p>
* 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 type="range" ng-input-range ... />`
* </p><br />
* <p>
* 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).
* </p><br />
* <p>
* **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.
* </p><br />
* <p>
* This documentation page refers to elements which have the built-in support enabled; i.e.
* elements _with_ the `ng-input-range` attribute.
* </p>
* </div>
*
* 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 `<input type="range" ng-input-range ng-model="model.value">`,
* 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
<example name="range-input-directive" module="rangeExample">
<file name="index.html">
<script>
angular.module('rangeExample', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.value = 75;
$scope.min = 10;
$scope.max = 90;
}]);
</script>
<form name="myForm" ng-controller="ExampleController">

Model as range: <input type="range" ng-input-range name="range" ng-model="value" min="{{min}}" max="{{max}}">
<hr>
Model as number: <input type="number" ng-model="value"><br>
Min: <input type="number" ng-model="min"><br>
Max: <input type="number" ng-model="max"><br>
value = <code>{{value}}</code><br/>
myForm.range.$valid = <code>{{myForm.range.$valid}}</code><br/>
myForm.range.$error = <code>{{myForm.range.$error}}</code>
</form>
</file>
</example>

* ## Range Input with ngMin & ngMax attributes

* @example
<example name="range-input-directive-ng" module="rangeExample">
<file name="index.html">
<script>
angular.module('rangeExample', [])
.controller('ExampleController', ['$scope', function($scope) {
$scope.value = 75;
$scope.min = 10;
$scope.max = 90;
}]);
</script>
<form name="myForm" ng-controller="ExampleController">
Model as range: <input type="range" ng-input-range name="range" ng-model="value" ng-min="min" ng-max="max">
<hr>
Model as number: <input type="number" ng-model="value"><br>
Min: <input type="number" ng-model="min"><br>
Max: <input type="number" ng-model="max"><br>
value = <code>{{value}}</code><br/>
myForm.range.$valid = <code>{{myForm.range.$valid}}</code><br/>
myForm.range.$error = <code>{{myForm.range.$error}}</code>
</form>
</file>
</example>

*/
'range': rangeInputType,

/**
* @ngdoc input
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It probably doesn't make any difference but we should probably switch this line with the line above to ensure the order of the code is the same as previously.

numberFormatterParser(ctrl);

if (isDefined(attr.min) || attr.ngMin) {
var minVal;
Expand All @@ -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();
});
Expand All @@ -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!
Expand Down Expand Up @@ -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);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/ng/directive/ngModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is not a breaking change because if $render didn't change the value then it will be the same as before, right? I think it is only the range input that is likely to do this...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Among the built-in $render function, yes. But the truth is it could be a breaking change for user defined $render functions (theoretically).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although even in that case, it could be considered a fix 😁

}
}

Expand Down
Loading