Skip to content

Commit bf6d3d4

Browse files
committed
feat(select): expose info about selection state in controller
This allows custom directives to manipulate the select's and ngModel's behavior based on the state of the unknown and the empty option. Closes angular#13172 Closes angular#10127
1 parent c9b77eb commit bf6d3d4

File tree

3 files changed

+354
-9
lines changed

3 files changed

+354
-9
lines changed

src/ng/directive/select.js

+159-3
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,120 @@ function setOptionSelectedStatus(optionEl, value) {
1919
/**
2020
* @ngdoc type
2121
* @name select.SelectController
22+
*
2223
* @description
23-
* The controller for the `<select>` directive. This provides support for reading
24-
* and writing the selected value(s) of the control and also coordinates dynamically
25-
* added `<option>` elements, perhaps by an `ngRepeat` directive.
24+
* The controller for the {@link ng.select select} directive. The controller exposes
25+
* a few utility methods that can be used to augment the behavior of a regular or an
26+
* {@link ng.ngOptions ngOptions} select element.
27+
*
28+
* @example
29+
* ### Set a custom error when the unknown option is selected
30+
*
31+
* This example sets a custom error "unknownValue" on the ngModelController
32+
* when the select element's unknown option is selected, i.e. when the model is set to a value
33+
* that is not matched by any option.
34+
*
35+
* <example name="select-unknown-value-error" module="staticSelect">
36+
* <file name="index.html">
37+
* <div ng-controller="ExampleController">
38+
* <form name="myForm">
39+
* <label for="testSelect"> Single select: </label><br>
40+
* <select name="testSelect" ng-model="selected" unknown-value-error>
41+
* <option value="option-1">Option 1</option>
42+
* <option value="option-2">Option 2</option>
43+
* </select><br>
44+
* <span ng-if="myForm.testSelect.$error.unknownValue">Error: The current model doesn't match any option</span>
45+
*
46+
* <button ng-click="forceUnknownOption()">Force unknown option</button><br>
47+
* </form>
48+
* </div>
49+
* </file>
50+
* <file name="app.js">
51+
* angular.module('staticSelect', [])
52+
* .controller('ExampleController', ['$scope', function($scope) {
53+
* $scope.selected = null;
54+
*
55+
* $scope.forceUnknownOption = function() {
56+
* $scope.selected = 'nonsense';
57+
* };
58+
* }])
59+
* .directive('unknownValueError', function() {
60+
* return {
61+
* require: ['ngModel', 'select'],
62+
* link: function(scope, element, attrs, ctrls) {
63+
* var ngModelCtrl = ctrls[0];
64+
* var selectCtrl = ctrls[1];
65+
*
66+
* ngModelCtrl.$validators.unknownValue = function(modelValue, viewValue) {
67+
* if (selectCtrl.$isUnknownOptionSelected()) {
68+
* return false;
69+
* }
70+
*
71+
* return true;
72+
* };
73+
* }
74+
*
75+
* };
76+
* });
77+
* </file>
78+
*</example>
79+
*
80+
*
81+
* @example
82+
* ### Set the "required" error when the unknown option is selected.
83+
*
84+
* By default, the "required" error on the ngModelController is only set on a required select
85+
* when the empty option is selected. This example adds a custom directive that also sets the
86+
* error when the unknown option is selected.
87+
*
88+
* <example name="select-unknown-value-required" module="staticSelect">
89+
* <file name="index.html">
90+
* <div ng-controller="ExampleController">
91+
* <form name="myForm">
92+
* <label for="testSelect"> Select: </label><br>
93+
* <select name="testSelect" ng-model="selected" unknown-value-required>
94+
* <option value="option-1">Option 1</option>
95+
* <option value="option-2">Option 2</option>
96+
* </select><br>
97+
* <span ng-if="myForm.testSelect.$error.required">Error: Please select a value</span><br>
98+
*
99+
* <button ng-click="forceUnknownOption()">Force unknown option</button><br>
100+
* </form>
101+
* </div>
102+
* </file>
103+
* <file name="app.js">
104+
* angular.module('staticSelect', [])
105+
* .controller('ExampleController', ['$scope', function($scope) {
106+
* $scope.selected = null;
107+
*
108+
* $scope.forceUnknownOption = function() {
109+
* $scope.selected = 'nonsense';
110+
* };
111+
* }])
112+
* .directive('unknownValueRequired', function() {
113+
* return {
114+
* priority: 1, // This directive must run after the required directive has added its validator
115+
* require: ['ngModel', 'select'],
116+
* link: function(scope, element, attrs, ctrls) {
117+
* var ngModelCtrl = ctrls[0];
118+
* var selectCtrl = ctrls[1];
119+
*
120+
* var originalRequiredValidator = ngModelCtrl.$validators.required;
121+
*
122+
* ngModelCtrl.$validators.required = function() {
123+
* if (attrs.required && selectCtrl.$isUnknownOptionSelected()) {
124+
* return false;
125+
* }
126+
*
127+
* return originalRequiredValidator.apply(this, arguments);
128+
* };
129+
* }
130+
* };
131+
* });
132+
* </file>
133+
*</example>
134+
*
135+
*
26136
*/
27137
var SelectController =
28138
['$element', '$scope', /** @this */ function($element, $scope) {
@@ -171,6 +281,49 @@ var SelectController =
171281
return !!optionsMap.get(value);
172282
};
173283

284+
/**
285+
* @ngdoc method
286+
* @name select.SelectController#$hasEmptyOption
287+
*
288+
* @description
289+
*
290+
* Returns `true` if the select element currently has an empty option
291+
* element, i.e. an option that signifies that the select is empty / the selection is null.
292+
*
293+
*/
294+
self.$hasEmptyOption = function() {
295+
return self.hasEmptyOption;
296+
};
297+
298+
/**
299+
* @ngdoc method
300+
* @name select.SelectController#$isUnknownOptionSelected
301+
*
302+
* @description
303+
*
304+
* Returns `true` if the select element's unknown option is selected. The unknown option is added
305+
* and automatically selected whenever the select model doesn't match any option.
306+
*
307+
*/
308+
self.$isUnknownOptionSelected = function() {
309+
// Presence of the unknown option means it is selected
310+
return $element[0].options[0] === self.unknownOption[0];
311+
};
312+
313+
/**
314+
* @ngdoc method
315+
* @name select.SelectController#$isEmptyOptionSelected
316+
*
317+
* @description
318+
*
319+
* Returns `true` if the select element has an empty option and this empty option is currently
320+
* selected. Returns `false` if the select element has no empty option or it is not selected.
321+
*
322+
*/
323+
self.$isEmptyOptionSelected = function() {
324+
return self.hasEmptyOption && $element[0].options[$element[0].selectedIndex] === self.emptyOption[0];
325+
};
326+
174327
self.selectUnknownOrEmptyOption = function(value) {
175328
if (value == null && self.emptyOption) {
176329
self.removeUnknownOption();
@@ -329,6 +482,9 @@ var SelectController =
329482
* the content of the `value` attribute or the textContent of the `<option>`, if the value attribute is missing.
330483
* Value and textContent can be interpolated.
331484
*
485+
* The {@link select.selectController select controller} exposes utility functions that can be used
486+
* to manipulate the select's behavior.
487+
*
332488
* ## Matching model and option values
333489
*
334490
* In general, the match between the model and an option is evaluated by strictly comparing the model

test/ng/directive/ngOptionsSpec.js

+90
Original file line numberDiff line numberDiff line change
@@ -3305,4 +3305,94 @@ describe('ngOptions', function() {
33053305
expect(scope.form.select.$pristine).toBe(true);
33063306
});
33073307
});
3308+
3309+
describe('selectCtrl api', function() {
3310+
3311+
it('should reflect the status of empty and unknown option', function() {
3312+
createSingleSelect('<option ng-if="isBlank" value="">blank</option>');
3313+
3314+
var selectCtrl = element.controller('select');
3315+
3316+
scope.$apply(function() {
3317+
scope.values = [{name: 'A'}, {name: 'B'}];
3318+
scope.isBlank = true;
3319+
});
3320+
3321+
expect(element).toEqualSelect([''], 'object:4', 'object:5');
3322+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3323+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
3324+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
3325+
3326+
// empty -> selection
3327+
scope.$apply(function() {
3328+
scope.selected = scope.values[0];
3329+
});
3330+
3331+
expect(element).toEqualSelect('', ['object:4'], 'object:5');
3332+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3333+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
3334+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
3335+
3336+
// remove empty
3337+
scope.$apply('isBlank = false');
3338+
3339+
expect(element).toEqualSelect(['object:4'], 'object:5');
3340+
expect(selectCtrl.$hasEmptyOption()).toBe(false);
3341+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
3342+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
3343+
3344+
// selection -> unknown
3345+
scope.$apply('selected = "unmatched"');
3346+
3347+
expect(element).toEqualSelect(['?'], 'object:4', 'object:5');
3348+
expect(selectCtrl.$hasEmptyOption()).toBe(false);
3349+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
3350+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
3351+
3352+
// add empty
3353+
scope.$apply('isBlank = true');
3354+
3355+
expect(element).toEqualSelect(['?'], '', 'object:4', 'object:5');
3356+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3357+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
3358+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
3359+
3360+
// unknown -> empty
3361+
scope.$apply(function() {
3362+
scope.selected = null;
3363+
});
3364+
3365+
expect(element).toEqualSelect([''], 'object:4', 'object:5');
3366+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3367+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
3368+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
3369+
3370+
// empty -> unknown
3371+
scope.$apply('selected = "unmatched"');
3372+
3373+
expect(element).toEqualSelect(['?'], '', 'object:4', 'object:5');
3374+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3375+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
3376+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);
3377+
3378+
// unknown -> selection
3379+
scope.$apply(function() {
3380+
scope.selected = scope.values[1];
3381+
});
3382+
3383+
expect(element).toEqualSelect('', 'object:4', ['object:5']);
3384+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3385+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
3386+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
3387+
3388+
// selection -> empty
3389+
scope.$apply('selected = null');
3390+
3391+
expect(element).toEqualSelect([''], 'object:4', 'object:5');
3392+
expect(selectCtrl.$hasEmptyOption()).toBe(true);
3393+
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
3394+
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
3395+
});
3396+
});
3397+
33083398
});

0 commit comments

Comments
 (0)