Skip to content

Commit fbf1d1e

Browse files
committed
fix(ngOptions): remove selected attribute from unselected options
When the select model changes, we add the "selected" attribute to the selected option, so that screen readers know which option is selected. Previously, we failed to remove the attribute from the selected / empty option when the model changed to match a different option, or the unknown / empty option. When using "track by", the behavior would also show when a user selected an option, and then the model was changed, because track by watches the tracked expression, and calls the $render function on change. This fix reads the current select value, finds the matching option and removes the "selected" attribute. IE9 had to be special cased, as it will report option.hasAttribute('selected') === true even if the option's property and attribute have been unset (even the dev tools show not selected attribute). I've added a custom matcher that accounts for this behavior. In all other browsers, property and attribute should always be in the same state. Since few people will use screen readers with IE9, I hope this is a satisfactory solution to the problem. Fixes angular#14892 Fixes angular#14419 Related angular#12731
1 parent 5fd42b6 commit fbf1d1e

File tree

2 files changed

+112
-1
lines changed

2 files changed

+112
-1
lines changed

src/ng/directive/ngOptions.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,11 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
448448
var removeEmptyOption = function() {
449449
if (!providedEmptyOption) {
450450
emptyOption.remove();
451+
} else {
452+
emptyOption.removeAttr('selected');
451453
}
452454
};
453455

454-
455456
var renderUnknownOption = function() {
456457
selectElement.prepend(unknownOption);
457458
selectElement.val('?');
@@ -467,8 +468,13 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
467468
if (!multiple) {
468469

469470
selectCtrl.writeValue = function writeNgOptionsValue(value) {
471+
var selectedOption = options.selectValueMap[selectElement.val()];
470472
var option = options.getOptionFromViewValue(value);
471473

474+
// Make sure to remove the selected attribute from the previously selected option
475+
// Otherwise, screen readers might get confused
476+
if (selectedOption) selectedOption.element.removeAttribute('selected');
477+
472478
if (option) {
473479
// Don't update the option when it is already selected.
474480
// For example, the browser will select the first option by default. In that case,
@@ -509,6 +515,7 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
509515

510516
// If we are using `track by` then we must watch the tracked value on the model
511517
// since ngModel only watches for object identity change
518+
// FIXME: When a user selects an option, this watch will fire needlessly
512519
if (ngOptions.trackBy) {
513520
scope.$watch(
514521
function() { return ngOptions.getTrackByValue(ngModelCtrl.$viewValue); },

test/ng/directive/ngOptionsSpec.js

+104
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,55 @@ 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 message = function() {
140+
return errors.join('\n');
141+
};
142+
143+
var result = {
144+
pass: errors.length === 0,
145+
message: message()
146+
};
147+
148+
return result;
149+
},
150+
negativeCompare: function(actual) {
151+
var errors = [];
152+
if (actual.selected) {
153+
errors.push('Expected option property "selected" to be falsy');
154+
}
155+
156+
if (msie !== 9 && actual.hasAttribute('selected')) {
157+
errors.push('Expected option not to have attribute "selected"');
158+
}
159+
160+
var message = function() {
161+
return errors.join('\n');
162+
};
163+
164+
var result = {
165+
pass: errors.length === 0,
166+
message: message()
167+
};
168+
169+
return result;
170+
}
171+
};
123172
}
124173
});
125174
});
@@ -744,6 +793,41 @@ describe('ngOptions', function() {
744793
});
745794

746795

796+
it('should remove the "selected" attribute from the previous option when the model changes', function() {
797+
scope.values = [{id: 10, label: 'ten'}, {id:20, label: 'twenty'}];
798+
799+
createSelect({
800+
'ng-model': 'selected',
801+
'ng-options': 'item.label for item in values'
802+
}, true);
803+
804+
var options = element.find('option');
805+
expect(options[0]).toBeMarkedAsSelected();
806+
expect(options[1]).not.toBeMarkedAsSelected();
807+
expect(options[2]).not.toBeMarkedAsSelected();
808+
809+
scope.selected = scope.values[0];
810+
scope.$digest();
811+
812+
expect(options[0]).not.toBeMarkedAsSelected();
813+
expect(options[1]).toBeMarkedAsSelected();
814+
expect(options[2]).not.toBeMarkedAsSelected();
815+
816+
scope.selected = scope.values[1];
817+
scope.$digest();
818+
819+
expect(options[0]).not.toBeMarkedAsSelected();
820+
expect(options[1]).not.toBeMarkedAsSelected();
821+
expect(options[2]).toBeMarkedAsSelected();
822+
823+
scope.selected = 'no match';
824+
scope.$digest();
825+
826+
expect(options[0]).toBeMarkedAsSelected();
827+
expect(options[1]).not.toBeMarkedAsSelected();
828+
expect(options[2]).not.toBeMarkedAsSelected();
829+
});
830+
747831
describe('disableWhen expression', function() {
748832

749833
describe('on single select', function() {
@@ -1395,6 +1479,26 @@ describe('ngOptions', function() {
13951479
});
13961480
}).not.toThrow();
13971481
});
1482+
1483+
it('should remove the "selected" attribute when the model changes', function() {
1484+
createSelect({
1485+
'ng-model': 'selected',
1486+
'ng-options': 'item.label for item in arr track by item.id'
1487+
});
1488+
1489+
var options = element.find('option');
1490+
browserTrigger(options[2], 'click');
1491+
1492+
expect(scope.selected).toEqual(scope.arr[1]);
1493+
1494+
scope.selected = {};
1495+
scope.$digest();
1496+
1497+
expect(options[0]).toBeMarkedAsSelected();
1498+
expect(options[2]).not.toBeMarkedAsSelected();
1499+
expect(options[2]).not.toBeMarkedAsSelected();
1500+
});
1501+
13981502
});
13991503

14001504

0 commit comments

Comments
 (0)