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

Commit f02b707

Browse files
committed
feat(select): support values of any type added with ngValue
select elements with ngModel will now set ngModel to option values added by ngValue. This allows setting values of any type (not only strings) without the use of ngOptions. Interpolations inside attributes can only be strings, but the ngValue directive uses attrs.$set, which does not have any type restriction. Any $observe on the value attribute will therefore receive the original value (result of ngValue expression). However, when a user selects an option, the browser sets the select value to the actual option's value attribute, which is still always a string. For that reason, when options are added by ngValue, we set the hashed value of the original value in the value attribute and store the actual value in an extra map. When the select value changes, we read access the actual value via the hashed select value. Since we only use a hashed value for ngValue, we will have extra checks for the hashed values: - when options are read, for both single and multiple select - when options are written, for multiple select I don't expect this to have a performance impact, but it should be kept in mind. Closes #9842 Closes #6297 BREAKING CHANGE: `<option>` elements added to `<select ng-model>` via `ngValue` now add their values in hash form, i.e. `<option ng-value="myString">` becomes `<option ng-value="myString" value="string:myString">`. This is done to support binding options with values of any type to selects. This should rarely affect applications, as the values of options are usually not relevant to the application logic, but it's possible that option values are checked in tests.
1 parent 47fbbab commit f02b707

File tree

4 files changed

+374
-41
lines changed

4 files changed

+374
-41
lines changed

src/ng/directive/input.js

+2-4
Original file line numberDiff line numberDiff line change
@@ -1743,10 +1743,8 @@ var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/;
17431743
* `ngValue` is useful when dynamically generating lists of radio buttons using
17441744
* {@link ngRepeat `ngRepeat`}, as shown below.
17451745
*
1746-
* Likewise, `ngValue` can be used to generate `<option>` elements for
1747-
* the {@link select `select`} element. In that case however, only strings are supported
1748-
* for the `value `attribute, so the resulting `ngModel` will always be a string.
1749-
* Support for `select` models with non-string values is available via `ngOptions`.
1746+
* Likewise, `ngValue` can be used to set the value of `<option>` elements for
1747+
* the {@link select `select`} element.
17501748
*
17511749
* @element input
17521750
* @param {string=} ngValue angular expression, whose value will be bound to the `value` attribute

src/ng/directive/ngOptions.js

+6-7
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ var ngOptionsMinErr = minErr('ngOptions');
1515
* elements for the `<select>` element using the array or object obtained by evaluating the
1616
* `ngOptions` comprehension expression.
1717
*
18-
* In many cases, `ngRepeat` can be used on `<option>` elements instead of `ngOptions` to achieve a
19-
* similar result. However, `ngOptions` provides some benefits such as reducing memory and
20-
* increasing speed by not creating a new scope for each repeated instance, as well as providing
21-
* more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the
22-
* comprehension expression. `ngOptions` should be used when the `<select>` model needs to be bound
23-
* to a non-string value. This is because an option element can only be bound to string values at
24-
* present.
18+
* In many cases, `ngRepeat` can be used on `<option>` elements instead of {@link ng.directive:ngOptions
19+
* ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits:
20+
* - more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the
21+
* comprehension expression
22+
* - reduced memory consumption by not creating a new scope for each repeated instance
23+
* - increased render speed by creating the options in a documentFragment instead of individually
2524
*
2625
* When an item in the `<select>` menu is selected, the array element or object property
2726
* represented by the selected option will be bound to the model identified by the `ngModel`

src/ng/directive/select.js

+133-28
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ var SelectController =
1616
var self = this,
1717
optionsMap = new HashMap();
1818

19+
self.selectValueMap = {}; // Keys are the hashed values, values the original values
20+
1921
// If the ngModel doesn't get provided then provide a dummy noop version to prevent errors
2022
self.ngModelCtrl = noopNgModelController;
2123

@@ -46,8 +48,15 @@ var SelectController =
4648
// Read the value of the select control, the implementation of this changes depending
4749
// upon whether the select can have multiple values and whether ngOptions is at work.
4850
self.readValue = function readSingleValue() {
49-
self.removeUnknownOption();
50-
return $element.val();
51+
var val = $element.val();
52+
// ngValue added option values are stored in the selectValueMap, normal interpolations are not
53+
var realVal = val in self.selectValueMap ? self.selectValueMap[val] : val;
54+
55+
if (self.hasOption(realVal)) {
56+
return realVal;
57+
}
58+
59+
return null;
5160
};
5261

5362

@@ -56,7 +65,9 @@ var SelectController =
5665
self.writeValue = function writeSingleValue(value) {
5766
if (self.hasOption(value)) {
5867
self.removeUnknownOption();
59-
$element.val(value);
68+
var hashedVal = hashKey(value);
69+
$element.val(hashedVal in self.selectValueMap ? hashedVal : value);
70+
6071
if (value === '') self.emptyOption.prop('selected', true); // to make IE9 happy
6172
} else {
6273
if (value == null && self.emptyOption) {
@@ -104,11 +115,53 @@ var SelectController =
104115
};
105116

106117

118+
119+
var updateScheduled = false;
120+
function scheduleViewValueUpdate(renderAfter) {
121+
if (updateScheduled) return;
122+
123+
updateScheduled = true;
124+
125+
$scope.$$postDigest(function() {
126+
updateScheduled = false;
127+
self.ngModelCtrl.$setViewValue(self.readValue());
128+
if (renderAfter) self.ngModelCtrl.$render();
129+
});
130+
}
131+
132+
107133
self.registerOption = function(optionScope, optionElement, optionAttrs, interpolateValueFn, interpolateTextFn) {
108134

109-
if (interpolateValueFn) {
135+
if (optionAttrs.$attr.ngValue) {
136+
// The value attribute is set by ngValue
137+
var oldVal, hashedVal = NaN;
138+
optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
139+
140+
var removal;
141+
var previouslySelected = optionElement.prop('selected');
142+
143+
if (isDefined(hashedVal)) {
144+
self.removeOption(oldVal);
145+
delete self.selectValueMap[hashedVal];
146+
removal = true;
147+
}
148+
149+
hashedVal = hashKey(newVal);
150+
oldVal = newVal;
151+
self.selectValueMap[hashedVal] = newVal;
152+
self.addOption(newVal, optionElement);
153+
// Set the attribute directly instead of using optionAttrs.$set - this stops the observer
154+
// from firing a second time. Other $observers on value will also get the result of the
155+
// ngValue expression, not the hashed value
156+
optionElement.attr('value', hashedVal);
157+
158+
if (removal && previouslySelected) {
159+
scheduleViewValueUpdate();
160+
}
161+
162+
});
163+
} else if (interpolateValueFn) {
110164
// The value attribute is interpolated
111-
var oldVal;
112165
optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
113166
if (isDefined(oldVal)) {
114167
self.removeOption(oldVal);
@@ -143,7 +196,7 @@ var SelectController =
143196
* @restrict E
144197
*
145198
* @description
146-
* HTML `SELECT` element with angular data-binding.
199+
* HTML `select` element with angular data-binding.
147200
*
148201
* The `select` directive is used together with {@link ngModel `ngModel`} to provide data-binding
149202
* between the scope and the `<select>` control (including setting default values).
@@ -153,14 +206,24 @@ var SelectController =
153206
* When an item in the `<select>` menu is selected, the value of the selected option will be bound
154207
* to the model identified by the `ngModel` directive. With static or repeated options, this is
155208
* the content of the `value` attribute or the textContent of the `<option>`, if the value attribute is missing.
156-
* If you want dynamic value attributes, you can use interpolation inside the value attribute.
209+
* Value and textContent can be interpolated.
157210
*
158-
* <div class="alert alert-warning">
159-
* Note that the value of a `select` directive used without `ngOptions` is always a string.
160-
* When the model needs to be bound to a non-string value, you must either explicitly convert it
161-
* using a directive (see example below) or use `ngOptions` to specify the set of options.
162-
* This is because an option element can only be bound to string values at present.
163-
* </div>
211+
* ## Matching model and option values
212+
*
213+
* In general, the match between the model and an option is evaluated by strictly comparing the model
214+
* value against the value of the available options.
215+
*
216+
* If you are setting the option value with the option's `value` attribute, or textContent, the
217+
* value will always be a `string` which means that the model value must also be a string.
218+
* Otherwise the `select` directive cannot match them correctly.
219+
*
220+
* To bind the model to a non-string value, you can use one of the following strategies:
221+
* - the {@link ng.ngOptions `ngOptions`} directive
222+
* ({@link ng.select#using-select-with-ngoptions-and-setting-a-default-value})
223+
* - the {@link ng.ngValue `ngValue`} directive, which allows arbitrary expressions to be
224+
* option values ({@link ng.select#using-ngvalue-to-bind-the-model-to-an-array-of-objects Example})
225+
* - model $parsers / $formatters to convert the string value
226+
* ({@link ng.select#binding-select-to-a-non-string-value-via-ngmodel-parsing-formatting Example})
164227
*
165228
* If the viewValue of `ngModel` does not match any of the options, then the control
166229
* will automatically add an "unknown" option, which it then removes when the mismatch is resolved.
@@ -169,13 +232,17 @@ var SelectController =
169232
* be nested into the `<select>` element. This element will then represent the `null` or "not selected"
170233
* option. See example below for demonstration.
171234
*
172-
* <div class="alert alert-info">
235+
* ## Choosing between `ngRepeat` and `ngOptions`
236+
*
173237
* In many cases, `ngRepeat` can be used on `<option>` elements instead of {@link ng.directive:ngOptions
174-
* ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits, such as
175-
* more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the
176-
* comprehension expression, and additionally in reducing memory and increasing speed by not creating
177-
* a new scope for each repeated instance.
178-
* </div>
238+
* ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits:
239+
* - more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the
240+
* comprehension expression
241+
* - reduced memory consumption by not creating a new scope for each repeated instance
242+
* - increased render speed by creating the options in a documentFragment instead of individually
243+
*
244+
* Specifically, select with repeated options slows down significantly starting at 2000 options in
245+
* Chrome and Internet Explorer / Edge.
179246
*
180247
*
181248
* @param {string} ngModel Assignable angular expression to data-bind to.
@@ -241,24 +308,24 @@ var SelectController =
241308
*</example>
242309
*
243310
* ### Using `ngRepeat` to generate `select` options
244-
* <example name="ngrepeat-select" module="ngrepeatSelect">
311+
* <example name="select-ngrepeat" module="ngrepeatSelect">
245312
* <file name="index.html">
246313
* <div ng-controller="ExampleController">
247314
* <form name="myForm">
248315
* <label for="repeatSelect"> Repeat select: </label>
249-
* <select name="repeatSelect" id="repeatSelect" ng-model="data.repeatSelect">
316+
* <select name="repeatSelect" id="repeatSelect" ng-model="data.model">
250317
* <option ng-repeat="option in data.availableOptions" value="{{option.id}}">{{option.name}}</option>
251318
* </select>
252319
* </form>
253320
* <hr>
254-
* <tt>repeatSelect = {{data.repeatSelect}}</tt><br/>
321+
* <tt>model = {{data.model}}</tt><br/>
255322
* </div>
256323
* </file>
257324
* <file name="app.js">
258325
* angular.module('ngrepeatSelect', [])
259326
* .controller('ExampleController', ['$scope', function($scope) {
260327
* $scope.data = {
261-
* repeatSelect: null,
328+
* model: null,
262329
* availableOptions: [
263330
* {id: '1', name: 'Option A'},
264331
* {id: '2', name: 'Option B'},
@@ -269,6 +336,37 @@ var SelectController =
269336
* </file>
270337
*</example>
271338
*
339+
* ### Using `ngValue` to bind the model to an array of objects
340+
* <example name="select-ngvalue" module="ngvalueSelect">
341+
* <file name="index.html">
342+
* <div ng-controller="ExampleController">
343+
* <form name="myForm">
344+
* <label for="ngvalueselect"> ngvalue select: </label>
345+
* <select size="6" name="ngvalueselect" ng-model="data.model" multiple>
346+
* <option ng-repeat="option in data.availableOptions" ng-value="option.value">{{option.name}}</option>
347+
* </select>
348+
* </form>
349+
* <hr>
350+
* <pre>model = {{data.model | json}}</pre><br/>
351+
* </div>
352+
* </file>
353+
* <file name="app.js">
354+
* angular.module('ngvalueSelect', [])
355+
* .controller('ExampleController', ['$scope', function($scope) {
356+
* $scope.data = {
357+
* model: null,
358+
* availableOptions: [
359+
{value: 'myString', name: 'string'},
360+
{value: 1, name: 'integer'},
361+
{value: true, name: 'boolean'},
362+
{value: null, name: 'null'},
363+
{value: {prop: 'value'}, name: 'object'},
364+
{value: ['a'], name: 'array'}
365+
* ]
366+
* };
367+
* }]);
368+
* </file>
369+
*</example>
272370
*
273371
* ### Using `select` with `ngOptions` and setting a default value
274372
* See the {@link ngOptions ngOptions documentation} for more `ngOptions` usage examples.
@@ -368,6 +466,7 @@ var selectDirective = function() {
368466
// to the `readValue` method, which can be changed if the select can have multiple
369467
// selected values or if the options are being generated by `ngOptions`
370468
element.on('change', function() {
469+
selectCtrl.removeUnknownOption();
371470
scope.$apply(function() {
372471
ngModelCtrl.$setViewValue(selectCtrl.readValue());
373472
});
@@ -384,7 +483,8 @@ var selectDirective = function() {
384483
var array = [];
385484
forEach(element.find('option'), function(option) {
386485
if (option.selected) {
387-
array.push(option.value);
486+
var val = option.value;
487+
array.push(val in selectCtrl.selectValueMap ? selectCtrl.selectValueMap[val] : val);
388488
}
389489
});
390490
return array;
@@ -394,7 +494,7 @@ var selectDirective = function() {
394494
selectCtrl.writeValue = function writeMultipleValue(value) {
395495
var items = new HashMap(value);
396496
forEach(element.find('option'), function(option) {
397-
option.selected = isDefined(items.get(option.value));
497+
option.selected = isDefined(items.get(option.value)) || isDefined(items.get(selectCtrl.selectValueMap[option.value]));
398498
});
399499
};
400500

@@ -445,13 +545,18 @@ var optionDirective = ['$interpolate', function($interpolate) {
445545
restrict: 'E',
446546
priority: 100,
447547
compile: function(element, attr) {
448-
if (isDefined(attr.value)) {
548+
var interpolateValueFn, interpolateTextFn;
549+
550+
if (isDefined(attr.ngValue)) {
551+
// jshint noempty: false
552+
// Will be handled by registerOption
553+
} else if (isDefined(attr.value)) {
449554
// If the value attribute is defined, check if it contains an interpolation
450-
var interpolateValueFn = $interpolate(attr.value, true);
555+
interpolateValueFn = $interpolate(attr.value, true);
451556
} else {
452557
// If the value attribute is not defined then we fall back to the
453558
// text content of the option element, which may be interpolated
454-
var interpolateTextFn = $interpolate(element.text(), true);
559+
interpolateTextFn = $interpolate(element.text(), true);
455560
if (!interpolateTextFn) {
456561
attr.$set('value', element.text());
457562
}

0 commit comments

Comments
 (0)