Skip to content

Commit

Permalink
feat(select): expose info about selection state
Browse files Browse the repository at this point in the history
  • Loading branch information
Narretz committed Apr 26, 2017
1 parent 0b874c0 commit 121773d
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 10 deletions.
2 changes: 1 addition & 1 deletion src/ng/directive/ngOptions.js
Expand Up @@ -569,7 +569,7 @@ var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile,
ngModelCtrl.$render();

optionEl.on('$destroy', function() {
var needsRerender = selectCtrl.isEmptyOptionSelected();
var needsRerender = selectCtrl.$isEmptyOptionSelected();

selectCtrl.hasEmptyOption = false;
selectCtrl.emptyOption = undefined;
Expand Down
160 changes: 157 additions & 3 deletions src/ng/directive/select.js
Expand Up @@ -19,10 +19,120 @@ function setOptionSelectedStatus(optionEl, value) {
/**
* @ngdoc type
* @name select.SelectController
*
* @description
* The controller for the `<select>` directive. This provides support for reading
* and writing the selected value(s) of the control and also coordinates dynamically
* added `<option>` elements, perhaps by an `ngRepeat` directive.
* The controller for the {@link ng.select select directive}. The controller exposes
* a few utility methods that can be used to augment the behavior of a regular or an
* {@link ng.ngOptions ngOptions} select element.
*
* @example
* ### Set a custom error when the unknown option is selected
*
* This example sets a custom error "unknown_value" on the ngModelController
* when the select element's unknown option is selected, i.e. when the model is set to value
* that is not matched by any option.
*
* <example name="select-unknown-value-error" module="staticSelect">
* <file name="index.html">
* <div ng-controller="ExampleController">
* <form name="myForm">
* <label for="testSelect"> Single select: </label><br>
* <select name="testSelect" ng-model="selected" unknown-value-error>
* <option value="option-1">Option 1</option>
* <option value="option-2">Option 2</option>
* </select><br>
* <span ng-if="myForm.testSelect.$error.unknown_value">Error: The current model doesn't match any option</span>
*
* <button ng-click="forceUnknownOption()">Force unknown option</button><br>
* </form>
* </div>
* </file>
* <file name="app.js">
* angular.module('staticSelect', [])
* .controller('ExampleController', ['$scope', function($scope) {
* $scope.selected = null;
*
* $scope.forceUnknownOption = function() {
* $scope.selected = 'nonsense';
* };
* }])
* .directive('unknownValueError', function() {
* return {
* require: ['ngModel', 'select'],
* link: function(scope, element, attrs, ctrls) {
* var ngModelCtrl = ctrls[0];
* var selectCtrl = ctrls[1];
*
* ngModelCtrl.$validators.unknown_value = function(modelValue, viewValue) {
* if (selectCtrl.$isUnknownOptionSelected()) {
* return false;
* }
*
* return true;
* };
* }
*
* };
* });
* </file>
*</example>
*
*
* @example
* ### Set the "required" error when the unknown option is selected.
*
* By default, the "required" error on the ngModelController is only set on a required select
* when the empty option is selected. This example adds a custom directive that also sets the
* error when the unknown option is selected.
*
* <example name="select-unknown-value-required" module="staticSelect">
* <file name="index.html">
* <div ng-controller="ExampleController">
* <form name="myForm">
* <label for="testSelect"> Select: </label><br>
* <select name="testSelect" ng-model="selected" unknown-value-required>
* <option value="option-1">Option 1</option>
* <option value="option-2">Option 2</option>
* </select><br>
* <span ng-if="myForm.testSelect.$error.required">Error: Please select a value</span><br>
*
* <button ng-click="forceUnknownOption()">Force unknown option</button><br>
* </form>
* </div>
* </file>
* <file name="app.js">
* angular.module('staticSelect', [])
* .controller('ExampleController', ['$scope', function($scope) {
* $scope.selected = null;
*
* $scope.forceUnknownOption = function() {
* $scope.selected = 'nonsense';
* };
* }])
* .directive('unknownValueRequired', function() {
* return {
* priority: 1, // This directive must run after the required directive has added its validator
* require: ['ngModel', 'select'],
* link: function(scope, element, attrs, ctrls) {
* var ngModelCtrl = ctrls[0];
* var selectCtrl = ctrls[1];
*
* var originalRequiredValidator = ngModelCtrl.$validators.required;
*
* ngModelCtrl.$validators.required = function() {
* if (attrs.required && selectCtrl.$isUnknownOptionSelected()) {
* return false;
* }
*
* return originalRequiredValidator.apply(this, arguments);
* };
* }
* };
* });
* </file>
*</example>
*
*
*/
var SelectController =
['$element', '$scope', /** @this */ function($element, $scope) {
Expand Down Expand Up @@ -171,6 +281,50 @@ var SelectController =
return !!optionsMap.get(value);
};

/**
* @ngdoc method
* @name select.SelectController#$hasEmptyOption
*
* @description
*
* Returns `true` if the select element currently has an empty option
* element, i.e. an option that signifies that the select is empty / the selection is null.
*
*/
self.$hasEmptyOption = function() {
// Presence of the unknown option means it is selected
return self.hasEmptyOption;
};

/**
* @ngdoc method
* @name select.SelectController#$isUnknownOptionSelected
*
* @description
*
* Returns `true` if the select element's unknown option is selected. The unknown option is added
* and automatically selected whenever the select model doesn't match any option.
*
*/
self.$isUnknownOptionSelected = function() {
// Presence of the unknown option means it is selected
return $element[0].options[0] === self.unknownOption[0];
};

/**
* @ngdoc method
* @name select.SelectController#$isEmptyOptionSelected
*
* @description
*
* Returns `true` if the select element has an empty option and this empty option is currently
* selected. Returns `false` if the select element has no empty option or it is not selected.
*
*/
self.$isEmptyOptionSelected = function() {
return self.hasEmptyOption && $element[0].options[$element[0].selectedIndex] === self.emptyOption[0];
};

self.selectUnknownOrEmptyOption = function(value) {
if (value == null && self.emptyOption) {
self.removeUnknownOption();
Expand Down
90 changes: 90 additions & 0 deletions test/ng/directive/ngOptionsSpec.js
Expand Up @@ -3296,4 +3296,94 @@ describe('ngOptions', function() {
expect(scope.form.select.$pristine).toBe(true);
});
});

describe('selectCtrl api', function() {

it('should reflect the status of empty and unknown option', function() {
createSingleSelect('<option ng-if="isBlank" value="">blank</option>');

var selectCtrl = element.controller('select');

scope.$apply(function() {
scope.values = [{name: 'A'}, {name: 'B'}];
scope.isBlank = true;
});

expect(element).toEqualSelect([''], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);

// empty -> selection
scope.$apply(function() {
scope.selected = scope.values[0];
});

expect(element).toEqualSelect('', ['object:4'], 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);

// remove empty
scope.$apply('isBlank = false');

expect(element).toEqualSelect(['object:4'], 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);

// selection -> unknown
scope.$apply('selected = "unmatched"');

expect(element).toEqualSelect(['?'], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(false);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);

// add empty
scope.$apply('isBlank = true');

expect(element).toEqualSelect(['?'], '', 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);

// unknown -> empty
scope.$apply(function() {
scope.selected = null;
});

expect(element).toEqualSelect([''], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);

// empty -> unknown
scope.$apply('selected = "unmatched"');

expect(element).toEqualSelect(['?'], '', 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(true);

// unknown -> selection
scope.$apply(function() {
scope.selected = scope.values[1];
});

expect(element).toEqualSelect('', 'object:4', ['object:5']);
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(false);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);

// selection -> empty
scope.$apply('selected = null');

expect(element).toEqualSelect([''], 'object:4', 'object:5');
expect(selectCtrl.$hasEmptyOption()).toBe(true);
expect(selectCtrl.$isEmptyOptionSelected()).toBe(true);
expect(selectCtrl.$isUnknownOptionSelected()).toBe(false);
});
});

});

0 comments on commit 121773d

Please sign in to comment.