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

Commit 2785ad7

Browse files
committed
fix(select, ngOptions): make the handling of unknown / empty options consistent
- when the model does not match any option, the select generates an unknown option with the value '? hashedModelVal ?' (select) or simply '?' (ngOptions) and inserts it into the select. This option is then auto-selected by the browser. Once the user selects an option / the application sets a matching model, the unknown option is removed from the select. It is therefore not user-selectable. Alternatively, the application can provide an option with the value='', the so-called empty option. The empty option takes over the role of the unknown option, in that it is selected when there's no matching model, but it is not removed when a option and model match, and it can also be selected by the user. The empty option has the value='' and sets the model to if selected. Previously, these concepts where a bit mushy, especially in ngOptions. This fix now delegates the bulk of the work to the selectCtrl, as the concept of unknown and empty option are the same for both directives. Only the generation of the empty option is different. The commit also adjusts a test for an officially unsupported use-case: multiple empty options. The new behavior is that the empty option that is registered last is used. The other one is still present. Otherwise, this behavior is unspecified, and the docs are clear that only a single empty option is allowed. We support dynamically generated empty options, but not multiple ones. I've left the test to highlight that this might be someone's use case, and the we are aware of it.
1 parent ad9a99d commit 2785ad7

File tree

5 files changed

+134
-107
lines changed

5 files changed

+134
-107
lines changed

src/ng/directive/ngOptions.js

+19-45
Original file line numberDiff line numberDiff line change
@@ -418,15 +418,14 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
418418

419419
// The emptyOption allows the application developer to provide their own custom "empty"
420420
// option when the viewValue does not match any of the option values.
421-
var emptyOption;
422421
for (var i = 0, children = selectElement.children(), ii = children.length; i < ii; i++) {
423422
if (children[i].value === '') {
424-
emptyOption = children.eq(i);
423+
selectCtrl.emptyOption = children.eq(i);
425424
break;
426425
}
427426
}
428427

429-
var providedEmptyOption = !!emptyOption;
428+
var providedEmptyOption = !!selectCtrl.emptyOption;
430429

431430
var unknownOption = jqLite(optionTemplate.cloneNode(false));
432431
unknownOption.val('?');
@@ -438,32 +437,9 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
438437
// we only need to create it once.
439438
var listFragment = $document[0].createDocumentFragment();
440439

441-
var renderEmptyOption = function() {
442-
if (!providedEmptyOption) {
443-
selectElement.prepend(emptyOption);
444-
}
445-
selectElement.val('');
446-
emptyOption.prop('selected', true); // needed for IE
447-
emptyOption.attr('selected', true);
448-
};
449-
450-
var removeEmptyOption = function() {
451-
if (!providedEmptyOption) {
452-
emptyOption.remove();
453-
} else {
454-
emptyOption.removeAttr('selected');
455-
}
456-
};
457-
458-
var renderUnknownOption = function() {
459-
selectElement.prepend(unknownOption);
460-
selectElement.val('?');
461-
unknownOption.prop('selected', true); // needed for IE
462-
unknownOption.attr('selected', true);
463-
};
464-
465-
var removeUnknownOption = function() {
466-
unknownOption.remove();
440+
// Overwrite the implementation. ngOptions doesn't use hashes
441+
selectCtrl.generateUnknownOptionValue = function(val) {
442+
return '?';
467443
};
468444

469445
// Update the controller methods for multiple selectable options
@@ -484,21 +460,22 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
484460
// set always
485461

486462
if (selectElement[0].value !== option.selectValue) {
487-
removeUnknownOption();
488-
removeEmptyOption();
463+
selectCtrl.removeUnknownOption();
464+
selectCtrl.unselectEmptyOption();
489465

490466
selectElement[0].value = option.selectValue;
491467
option.element.selected = true;
492468
}
493469

494470
option.element.setAttribute('selected', 'selected');
495471
} else {
496-
if (value === null || providedEmptyOption) {
497-
removeUnknownOption();
498-
renderEmptyOption();
472+
473+
if (providedEmptyOption) {
474+
selectCtrl.selectEmptyOption();
475+
} else if (selectCtrl.unknownOption.parent().length) {
476+
selectCtrl.updateUnknownOption(value);
499477
} else {
500-
removeEmptyOption();
501-
renderUnknownOption();
478+
selectCtrl.renderUnknownOption(value);
502479
}
503480
}
504481
};
@@ -508,8 +485,8 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
508485
var selectedOption = options.selectValueMap[selectElement.val()];
509486

510487
if (selectedOption && !selectedOption.disabled) {
511-
removeEmptyOption();
512-
removeUnknownOption();
488+
selectCtrl.unselectEmptyOption();
489+
selectCtrl.removeUnknownOption();
513490
return options.getViewValueFromOption(selectedOption);
514491
}
515492
return null;
@@ -575,21 +552,18 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
575552
}
576553
}
577554

578-
579555
if (providedEmptyOption) {
580556

581557
// we need to remove it before calling selectElement.empty() because otherwise IE will
582558
// remove the label from the element. wtf?
583-
emptyOption.remove();
559+
selectCtrl.emptyOption.remove();
584560

585561
// compile the element since there might be bindings in it
586-
$compile(emptyOption)(scope);
562+
$compile(selectCtrl.emptyOption)(scope);
587563

588564
// remove the class, which is added automatically because we recompile the element and it
589565
// becomes the compilation root
590-
emptyOption.removeClass('ng-scope');
591-
} else {
592-
emptyOption = jqLite(optionTemplate.cloneNode(false));
566+
selectCtrl.emptyOption.removeClass('ng-scope');
593567
}
594568

595569
selectElement.empty();
@@ -651,7 +625,7 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
651625

652626
// Ensure that the empty option is always there if it was explicitly provided
653627
if (providedEmptyOption) {
654-
selectElement.prepend(emptyOption);
628+
selectElement.prepend(selectCtrl.emptyOption);
655629
}
656630

657631
options.items.forEach(function addOption(option) {

src/ng/directive/select.js

+37-7
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,57 @@ var SelectController =
3131
// We can't just jqLite('<option>') since jqLite is not smart enough
3232
// to create it in <select> and IE barfs otherwise.
3333
self.unknownOption = jqLite(window.document.createElement('option'));
34+
3435
self.renderUnknownOption = function(val) {
35-
var unknownVal = '? ' + hashKey(val) + ' ?';
36+
var unknownVal = self.generateUnknownOptionValue(val);
3637
self.unknownOption.val(unknownVal);
3738
$element.prepend(self.unknownOption);
39+
self.unknownOption.prop('selected', true); // needed for IE
40+
self.unknownOption.attr('selected', true);
3841
$element.val(unknownVal);
3942
};
4043

4144
self.updateUnknownOption = function(val) {
42-
var unknownVal = '? ' + hashKey(val) + ' ?';
45+
var unknownVal = self.generateUnknownOptionValue(val);
4346
self.unknownOption.val(unknownVal);
47+
self.unknownOption.prop('selected', true); // needed for IE
48+
self.unknownOption.attr('selected', true);
4449
$element.val(unknownVal);
4550
};
4651

47-
$scope.$on('$destroy', function() {
48-
// disable unknown option so that we don't do work when the whole select is being destroyed
49-
self.renderUnknownOption = noop;
50-
});
52+
self.generateUnknownOptionValue = function(val) {
53+
return '? ' + hashKey(val) + ' ?';
54+
};
5155

5256
self.removeUnknownOption = function() {
5357
if (self.unknownOption.parent()) self.unknownOption.remove();
5458
};
5559

60+
// The empty option is an option with the value '' that te application developer can
61+
// provide inside the select. When the model changes to a value that doesn't match an option,
62+
// it is selected - so if an empty option is provided, no unknown option is generated.
63+
// However, the empty option is not removed when the model matches an option. It is always selectable
64+
// and indicates that a "null" selection has been made.
65+
self.emptyOption = undefined;
66+
67+
self.selectEmptyOption = function() {
68+
if (self.emptyOption) {
69+
$element.val('');
70+
self.emptyOption.prop('selected', true); // needed for IE
71+
self.emptyOption.attr('selected', true);
72+
}
73+
};
74+
75+
self.unselectEmptyOption = function() {
76+
if (self.emptyOption) {
77+
self.emptyOption.removeAttr('selected');
78+
}
79+
};
80+
81+
$scope.$on('$destroy', function() {
82+
// disable unknown option so that we don't do work when the whole select is being destroyed
83+
self.renderUnknownOption = noop;
84+
});
5685

5786
// Read the value of the select control, the implementation of this changes depending
5887
// upon whether the select can have multiple values and whether ngOptions is at work.
@@ -74,14 +103,15 @@ var SelectController =
74103
self.writeValue = function writeSingleValue(value) {
75104
if (self.hasOption(value)) {
76105
self.removeUnknownOption();
106+
77107
var hashedVal = hashKey(value);
78108
$element.val(hashedVal in self.selectValueMap ? hashedVal : value);
79109

80110
if (value === '') self.emptyOption.prop('selected', true); // to make IE9 happy
81111
} else {
82112
if (value == null && self.emptyOption) {
83113
self.removeUnknownOption();
84-
$element.val('');
114+
self.selectEmptyOption();
85115
} else if (self.unknownOption.parent().length) {
86116
self.updateUnknownOption(value);
87117
} else {

test/helpers/matchers.js

+41
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,47 @@ beforeEach(function() {
319319
}
320320
};
321321
}
322+
},
323+
toBeMarkedAsSelected: function() {
324+
// Selected is special because the element property and attribute reflect each other's state.
325+
// IE9 will wrongly report hasAttribute('selected') === true when the property is
326+
// undefined or null, and the dev tools show that no attribute is set
327+
return {
328+
compare: function(actual) {
329+
var errors = [];
330+
if (actual.selected === null || typeof actual.selected === 'undefined' || actual.selected === false) {
331+
errors.push('Expected option property "selected" to be truthy');
332+
}
333+
334+
if (msie !== 9 && actual.hasAttribute('selected') === false) {
335+
errors.push('Expected option to have attribute "selected"');
336+
}
337+
338+
var result = {
339+
pass: errors.length === 0,
340+
message: errors.join('\n')
341+
};
342+
343+
return result;
344+
},
345+
negativeCompare: function(actual) {
346+
var errors = [];
347+
if (actual.selected) {
348+
errors.push('Expected option property "selected" to be falsy');
349+
}
350+
351+
if (msie !== 9 && actual.hasAttribute('selected')) {
352+
errors.push('Expected option not to have attribute "selected"');
353+
}
354+
355+
var result = {
356+
pass: errors.length === 0,
357+
message: errors.join('\n')
358+
};
359+
360+
return result;
361+
}
362+
};
322363
}
323364
});
324365
});

test/ng/directive/ngOptionsSpec.js

+26-46
Original file line numberDiff line numberDiff line change
@@ -120,47 +120,6 @@ describe('ngOptions', function() {
120120
return { pass: errors.length === 0, message: message };
121121
}
122122
};
123-
},
124-
toBeMarkedAsSelected: function() {
125-
// Selected is special because the element property and attribute reflect each other's state.
126-
// IE9 will wrongly report hasAttribute('selected') === true when the property is
127-
// undefined or null, and the dev tools show that no attribute is set
128-
return {
129-
compare: function(actual) {
130-
var errors = [];
131-
if (actual.selected === null || typeof actual.selected === 'undefined' || actual.selected === false) {
132-
errors.push('Expected option property "selected" to be truthy');
133-
}
134-
135-
if (msie !== 9 && actual.hasAttribute('selected') === false) {
136-
errors.push('Expected option to have attribute "selected"');
137-
}
138-
139-
var result = {
140-
pass: errors.length === 0,
141-
message: errors.join('\n')
142-
};
143-
144-
return result;
145-
},
146-
negativeCompare: function(actual) {
147-
var errors = [];
148-
if (actual.selected) {
149-
errors.push('Expected option property "selected" to be falsy');
150-
}
151-
152-
if (msie !== 9 && actual.hasAttribute('selected')) {
153-
errors.push('Expected option not to have attribute "selected"');
154-
}
155-
156-
var result = {
157-
pass: errors.length === 0,
158-
message: errors.join('\n')
159-
};
160-
161-
return result;
162-
}
163-
};
164123
}
165124
});
166125
});
@@ -2129,14 +2088,34 @@ describe('ngOptions', function() {
21292088
expect(options.eq(1).prop('disabled')).toEqual(false);
21302089
});
21312090

2132-
it('should insert a blank option if bound to null', function() {
2091+
it('should insert the unknown option if bound to null', function() {
21332092
createSingleSelect();
21342093

21352094
scope.$apply(function() {
21362095
scope.values = [{name: 'A'}];
21372096
scope.selected = null;
21382097
});
21392098

2099+
expect(element.find('option').length).toEqual(2);
2100+
expect(element.val()).toEqual('?');
2101+
expect(jqLite(element.find('option')[0]).val()).toEqual('?');
2102+
2103+
scope.$apply(function() {
2104+
scope.selected = scope.values[0];
2105+
});
2106+
2107+
expect(element).toEqualSelectValue(scope.selected);
2108+
expect(element.find('option').length).toEqual(1);
2109+
});
2110+
2111+
it('should select the provided empty option if bound to null', function() {
2112+
createSingleSelect(true);
2113+
2114+
scope.$apply(function() {
2115+
scope.values = [{name: 'A'}];
2116+
scope.selected = null;
2117+
});
2118+
21402119
expect(element.find('option').length).toEqual(2);
21412120
expect(element.val()).toEqual('');
21422121
expect(jqLite(element.find('option')[0]).val()).toEqual('');
@@ -2146,7 +2125,8 @@ describe('ngOptions', function() {
21462125
});
21472126

21482127
expect(element).toEqualSelectValue(scope.selected);
2149-
expect(element.find('option').length).toEqual(1);
2128+
expect(jqLite(element.find('option')[0]).val()).toEqual('');
2129+
expect(element.find('option').length).toEqual(2);
21502130
});
21512131

21522132

@@ -2314,7 +2294,7 @@ describe('ngOptions', function() {
23142294
scope.values.pop();
23152295
});
23162296

2317-
expect(element.val()).toEqual('');
2297+
expect(element.val()).toEqual('?');
23182298
expect(scope.selected).toEqual(null);
23192299

23202300
// Check after model change
@@ -2328,7 +2308,7 @@ describe('ngOptions', function() {
23282308
scope.values.pop();
23292309
});
23302310

2331-
expect(element.val()).toEqual('');
2311+
expect(element.val()).toEqual('?');
23322312
expect(scope.selected).toEqual(null);
23332313
});
23342314

@@ -2377,7 +2357,7 @@ describe('ngOptions', function() {
23772357
});
23782358

23792359

2380-
describe('blank option', function() {
2360+
describe('empty option', function() {
23812361

23822362
it('should be compiled as template, be watched and updated', function() {
23832363
var option;

0 commit comments

Comments
 (0)