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

Commit 0b962d4

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 #13172 Closes #10127
1 parent 71b4daa commit 0b962d4

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) {
@@ -172,6 +282,49 @@ var SelectController =
172282
return !!optionsMap.get(value);
173283
};
174284

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