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

Commit 904b69c

Browse files
committed
fix(select): properly handle empty & unknown options without ngOptions
Previously only when ngOptions was used, we correctly handled situations when model was set to an unknown value. With this change, we'll add/remove extra unknown option or reuse an existing empty option (option with value set to "") when model is undefined.
1 parent c65c34e commit 904b69c

File tree

2 files changed

+425
-74
lines changed

2 files changed

+425
-74
lines changed

src/ng/directive/select.js

+150-54
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,10 @@
2222
* be nested into the `<select>` element. This element will then represent `null` or "not selected"
2323
* option. See example below for demonstration.
2424
*
25-
* Note: `ngOptions` provides iterator facility for `<option>` element which must be used instead
26-
* of {@link angular.module.ng.$compileProvider.directive.ngRepeat ngRepeat}. `ngRepeat` is not
27-
* suitable for use with `<option>` element because of the following reasons:
28-
*
29-
* * value attribute of the option element that we need to bind to requires a string, but the
30-
* source of data for the iteration might be in a form of array containing objects instead of
31-
* strings
32-
* * {@link angular.module.ng.$compileProvider.directive.ngRepeat ngRepeat} unrolls after the
33-
* select binds causing incorect rendering on most browsers.
34-
* * binding to a value not in list confuses most browsers.
25+
* Note: `ngOptions` provides iterator facility for `<option>` element which should be used instead
26+
* of {@link angular.module.ng.$compileProvider.directive.ngRepeat ngRepeat} when you want the
27+
* `select` model to be bound to a non-string value. This is because an option element can currently
28+
* be bound to string values only.
3529
*
3630
* @param {string} name assignable expression to data-bind to.
3731
* @param {string=} required The control is considered valid only if value is entered.
@@ -92,11 +86,11 @@
9286
<select ng-model="color" ng-options="c.name for c in colors"></select><br>
9387
9488
Color (null allowed):
95-
<div class="nullable">
89+
<span class="nullable">
9690
<select ng-model="color" ng-options="c.name for c in colors">
9791
<option value="">-- chose color --</option>
9892
</select>
99-
</div><br/>
93+
</span><br/>
10094
10195
Color grouped by shade:
10296
<select ng-model="color" ng-options="c.name group by c.shade for c in colors">
@@ -126,49 +120,136 @@
126120
var ngOptionsDirective = valueFn({ terminal: true });
127121
var selectDirective = ['$compile', '$parse', function($compile, $parse) {
128122
//00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777
129-
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/;
123+
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/,
124+
nullModelCtrl = {$setViewValue: noop};
130125

131126
return {
132127
restrict: 'E',
133-
require: '?ngModel',
134-
link: function(scope, element, attr, ctrl) {
135-
if (!ctrl) return;
128+
require: ['select', '?ngModel'],
129+
controller: ['$element', '$scope', function($element, $scope) {
130+
var self = this,
131+
optionsMap = {},
132+
ngModelCtrl = nullModelCtrl,
133+
nullOption,
134+
unknownOption;
135+
136+
self.init = function(ngModelCtrl_, nullOption_, unknownOption_) {
137+
ngModelCtrl = ngModelCtrl_;
138+
nullOption = nullOption_;
139+
unknownOption = unknownOption_;
140+
}
141+
142+
143+
self.addOption = function(value) {
144+
optionsMap[value] = true;
145+
146+
if (ngModelCtrl.$viewValue == value) {
147+
$element.val(value);
148+
if (unknownOption.parent()) unknownOption.remove();
149+
}
150+
};
136151

137-
var multiple = attr.multiple,
138-
optionsExp = attr.ngOptions;
152+
153+
self.removeOption = function(value) {
154+
if (this.hasOption(value)) {
155+
delete optionsMap[value];
156+
if (ngModelCtrl.$viewValue == value) {
157+
this.renderUnknownOption(value);
158+
}
159+
}
160+
};
161+
162+
163+
self.renderUnknownOption = function(val) {
164+
var unknownVal = '? ' + hashKey(val) + ' ?';
165+
unknownOption.val(unknownVal);
166+
$element.prepend(unknownOption);
167+
$element.val(unknownVal);
168+
unknownOption.prop('selected', true); // needed for IE
169+
}
170+
171+
172+
self.hasOption = function(value) {
173+
return optionsMap.hasOwnProperty(value);
174+
}
175+
176+
$scope.$on('$destroy', function() {
177+
// disable unknown option so that we don't do work when the whole select is being destroyed
178+
self.renderUnknownOption = noop;
179+
});
180+
}],
181+
182+
link: function(scope, element, attr, ctrls) {
183+
// if ngModel is not defined, we don't need to do anything
184+
if (!ctrls[1]) return;
185+
186+
var selectCtrl = ctrls[0],
187+
ngModelCtrl = ctrls[1],
188+
multiple = attr.multiple,
189+
optionsExp = attr.ngOptions,
190+
nullOption = false, // if false, user will not be able to select it (used by ngOptions)
191+
emptyOption,
192+
// we can't just jqLite('<option>') since jqLite is not smart enough
193+
// to create it in <select> and IE barfs otherwise.
194+
optionTemplate = jqLite(document.createElement('option')),
195+
optGroupTemplate =jqLite(document.createElement('optgroup')),
196+
unknownOption = optionTemplate.clone();
197+
198+
// find "null" option
199+
for(var i = 0, children = element.children(), ii = children.length; i < ii; i++) {
200+
if (children[i].value == '') {
201+
emptyOption = nullOption = children.eq(i);
202+
break;
203+
}
204+
}
205+
206+
selectCtrl.init(ngModelCtrl, nullOption, unknownOption);
139207

140208
// required validator
141209
if (multiple && (attr.required || attr.ngRequired)) {
142210
var requiredValidator = function(value) {
143-
ctrl.$setValidity('required', !attr.required || (value && value.length));
211+
ngModelCtrl.$setValidity('required', !attr.required || (value && value.length));
144212
return value;
145213
};
146214

147-
ctrl.$parsers.push(requiredValidator);
148-
ctrl.$formatters.unshift(requiredValidator);
215+
ngModelCtrl.$parsers.push(requiredValidator);
216+
ngModelCtrl.$formatters.unshift(requiredValidator);
149217

150218
attr.$observe('required', function() {
151-
requiredValidator(ctrl.$viewValue);
219+
requiredValidator(ngModelCtrl.$viewValue);
152220
});
153221
}
154222

155-
if (optionsExp) Options(scope, element, ctrl);
156-
else if (multiple) Multiple(scope, element, ctrl);
157-
else Single(scope, element, ctrl);
223+
if (optionsExp) Options(scope, element, ngModelCtrl);
224+
else if (multiple) Multiple(scope, element, ngModelCtrl);
225+
else Single(scope, element, ngModelCtrl, selectCtrl);
158226

159227

160228
////////////////////////////
161229

162230

163231

164-
function Single(scope, selectElement, ctrl) {
165-
ctrl.$render = function() {
166-
selectElement.val(ctrl.$viewValue);
232+
function Single(scope, selectElement, ngModelCtrl, selectCtrl) {
233+
ngModelCtrl.$render = function() {
234+
var viewValue = ngModelCtrl.$viewValue;
235+
236+
if (selectCtrl.hasOption(viewValue)) {
237+
if (unknownOption.parent()) unknownOption.remove();
238+
selectElement.val(viewValue);
239+
if (viewValue === '') emptyOption.prop('selected', true); // to make IE9 happy
240+
} else {
241+
if (isUndefined(viewValue) && emptyOption) {
242+
selectElement.val('');
243+
} else {
244+
selectCtrl.renderUnknownOption(viewValue);
245+
}
246+
}
167247
};
168248

169249
selectElement.bind('change', function() {
170250
scope.$apply(function() {
171-
ctrl.$setViewValue(selectElement.val());
251+
if (unknownOption.parent()) unknownOption.remove();
252+
ngModelCtrl.$setViewValue(selectElement.val());
172253
});
173254
});
174255
}
@@ -219,26 +300,26 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
219300
groupByFn = $parse(match[3] || ''),
220301
valueFn = $parse(match[2] ? match[1] : valueName),
221302
valuesFn = $parse(match[7]),
222-
// we can't just jqLite('<option>') since jqLite is not smart enough
223-
// to create it in <select> and IE barfs otherwise.
224-
optionTemplate = jqLite(document.createElement('option')),
225-
optGroupTemplate = jqLite(document.createElement('optgroup')),
226-
nullOption = false, // if false then user will not be able to select it
227303
// This is an array of array of existing option groups in DOM. We try to reuse these if possible
228304
// optionGroupsCache[0] is the options with no option group
229305
// optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element
230306
optionGroupsCache = [[{element: selectElement, label:''}]];
231307

232-
// find existing special options
233-
forEach(selectElement.children(), function(option) {
234-
if (option.value == '') {
235-
// developer declared null option, so user should be able to select it
236-
nullOption = jqLite(option).remove();
237-
// compile the element since there might be bindings in it
238-
$compile(nullOption)(scope);
239-
}
240-
});
241-
selectElement.html(''); // clear contents
308+
if (nullOption) {
309+
// compile the element since there might be bindings in it
310+
$compile(nullOption)(scope);
311+
312+
// remove the class, which is added automatically because we recompile the element and it
313+
// becomes the compilation root
314+
nullOption.removeClass('ng-scope');
315+
316+
// we need to remove it before calling selectElement.html('') because otherwise IE will
317+
// remove the label from the element. wtf?
318+
nullOption.remove();
319+
}
320+
321+
// clear contents, we'll add what's needed based on the model
322+
selectElement.html('');
242323

243324
selectElement.bind('change', function() {
244325
scope.$apply(function() {
@@ -250,8 +331,8 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
250331
if (multiple) {
251332
value = [];
252333
for (groupIndex = 0, groupLength = optionGroupsCache.length;
253-
groupIndex < groupLength;
254-
groupIndex++) {
334+
groupIndex < groupLength;
335+
groupIndex++) {
255336
// list of options for that group. (first item has the parent)
256337
optionGroup = optionGroupsCache[groupIndex];
257338

@@ -365,7 +446,7 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
365446
}
366447
}
367448

368-
lastElement = null; // start at the begining
449+
lastElement = null; // start at the beginning
369450
for(index = 0, length = optionGroup.length; index < length; index++) {
370451
option = optionGroup[index];
371452
if ((existingOption = existingOptions[index+1])) {
@@ -431,19 +512,34 @@ var optionDirective = ['$interpolate', function($interpolate) {
431512
return {
432513
restrict: 'E',
433514
priority: 100,
515+
require: '^select',
434516
compile: function(element, attr) {
435517
if (isUndefined(attr.value)) {
436518
var interpolateFn = $interpolate(element.text(), true);
437-
if (interpolateFn) {
438-
return function (scope, element, attr) {
439-
scope.$watch(interpolateFn, function(value) {
440-
attr.$set('value', value);
441-
});
442-
}
443-
} else {
519+
if (!interpolateFn) {
444520
attr.$set('value', element.text());
445521
}
446522
}
523+
524+
// For some reason Opera defaults to true and if not overridden this messes up the repeater.
525+
// We don't want the view to drive the initialization of the model anyway.
526+
element.prop('selected', false);
527+
528+
return function (scope, element, attr, selectCtrl) {
529+
if (interpolateFn) {
530+
scope.$watch(interpolateFn, function(newVal, oldVal) {
531+
attr.$set('value', newVal);
532+
if (newVal !== oldVal) selectCtrl.removeOption(oldVal);
533+
selectCtrl.addOption(newVal);
534+
});
535+
} else {
536+
selectCtrl.addOption(attr.value);
537+
}
538+
539+
element.bind('$destroy', function() {
540+
selectCtrl.removeOption(attr.value);
541+
});
542+
};
447543
}
448544
}
449545
}];

0 commit comments

Comments
 (0)