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

Commit

Permalink
fix(select): properly handle empty & unknown options without ngOptions
Browse files Browse the repository at this point in the history
Previously only when ngOptions was used, we correctly handled situations
when model was set to an unknown value. With this change, we'll add/remove
extra unknown option or reuse an existing empty option (option with value
set to "") when model is undefined.
  • Loading branch information
IgorMinar committed Apr 20, 2012
1 parent c65c34e commit 904b69c
Show file tree
Hide file tree
Showing 2 changed files with 425 additions and 74 deletions.
204 changes: 150 additions & 54 deletions src/ng/directive/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,10 @@
* be nested into the `<select>` element. This element will then represent `null` or "not selected"
* option. See example below for demonstration.
*
* Note: `ngOptions` provides iterator facility for `<option>` element which must be used instead
* of {@link angular.module.ng.$compileProvider.directive.ngRepeat ngRepeat}. `ngRepeat` is not
* suitable for use with `<option>` element because of the following reasons:
*
* * value attribute of the option element that we need to bind to requires a string, but the
* source of data for the iteration might be in a form of array containing objects instead of
* strings
* * {@link angular.module.ng.$compileProvider.directive.ngRepeat ngRepeat} unrolls after the
* select binds causing incorect rendering on most browsers.
* * binding to a value not in list confuses most browsers.
* Note: `ngOptions` provides iterator facility for `<option>` element which should be used instead
* of {@link angular.module.ng.$compileProvider.directive.ngRepeat ngRepeat} when you want the
* `select` model to be bound to a non-string value. This is because an option element can currently
* be bound to string values only.
*
* @param {string} name assignable expression to data-bind to.
* @param {string=} required The control is considered valid only if value is entered.
Expand Down Expand Up @@ -92,11 +86,11 @@
<select ng-model="color" ng-options="c.name for c in colors"></select><br>
Color (null allowed):
<div class="nullable">
<span class="nullable">
<select ng-model="color" ng-options="c.name for c in colors">
<option value="">-- chose color --</option>
</select>
</div><br/>
</span><br/>
Color grouped by shade:
<select ng-model="color" ng-options="c.name group by c.shade for c in colors">
Expand Down Expand Up @@ -126,49 +120,136 @@
var ngOptionsDirective = valueFn({ terminal: true });
var selectDirective = ['$compile', '$parse', function($compile, $parse) {
//00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/;
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/,
nullModelCtrl = {$setViewValue: noop};

return {
restrict: 'E',
require: '?ngModel',
link: function(scope, element, attr, ctrl) {
if (!ctrl) return;
require: ['select', '?ngModel'],
controller: ['$element', '$scope', function($element, $scope) {
var self = this,
optionsMap = {},
ngModelCtrl = nullModelCtrl,
nullOption,
unknownOption;

self.init = function(ngModelCtrl_, nullOption_, unknownOption_) {
ngModelCtrl = ngModelCtrl_;
nullOption = nullOption_;
unknownOption = unknownOption_;
}


self.addOption = function(value) {
optionsMap[value] = true;

if (ngModelCtrl.$viewValue == value) {
$element.val(value);
if (unknownOption.parent()) unknownOption.remove();
}
};

var multiple = attr.multiple,
optionsExp = attr.ngOptions;

self.removeOption = function(value) {
if (this.hasOption(value)) {
delete optionsMap[value];
if (ngModelCtrl.$viewValue == value) {
this.renderUnknownOption(value);
}
}
};


self.renderUnknownOption = function(val) {
var unknownVal = '? ' + hashKey(val) + ' ?';
unknownOption.val(unknownVal);
$element.prepend(unknownOption);
$element.val(unknownVal);
unknownOption.prop('selected', true); // needed for IE
}


self.hasOption = function(value) {
return optionsMap.hasOwnProperty(value);
}

$scope.$on('$destroy', function() {
// disable unknown option so that we don't do work when the whole select is being destroyed
self.renderUnknownOption = noop;
});
}],

link: function(scope, element, attr, ctrls) {
// if ngModel is not defined, we don't need to do anything
if (!ctrls[1]) return;

var selectCtrl = ctrls[0],
ngModelCtrl = ctrls[1],
multiple = attr.multiple,
optionsExp = attr.ngOptions,
nullOption = false, // if false, user will not be able to select it (used by ngOptions)
emptyOption,
// we can't just jqLite('<option>') since jqLite is not smart enough
// to create it in <select> and IE barfs otherwise.
optionTemplate = jqLite(document.createElement('option')),
optGroupTemplate =jqLite(document.createElement('optgroup')),
unknownOption = optionTemplate.clone();

// find "null" option
for(var i = 0, children = element.children(), ii = children.length; i < ii; i++) {
if (children[i].value == '') {
emptyOption = nullOption = children.eq(i);
break;
}
}

selectCtrl.init(ngModelCtrl, nullOption, unknownOption);

// required validator
if (multiple && (attr.required || attr.ngRequired)) {
var requiredValidator = function(value) {
ctrl.$setValidity('required', !attr.required || (value && value.length));
ngModelCtrl.$setValidity('required', !attr.required || (value && value.length));
return value;
};

ctrl.$parsers.push(requiredValidator);
ctrl.$formatters.unshift(requiredValidator);
ngModelCtrl.$parsers.push(requiredValidator);
ngModelCtrl.$formatters.unshift(requiredValidator);

attr.$observe('required', function() {
requiredValidator(ctrl.$viewValue);
requiredValidator(ngModelCtrl.$viewValue);
});
}

if (optionsExp) Options(scope, element, ctrl);
else if (multiple) Multiple(scope, element, ctrl);
else Single(scope, element, ctrl);
if (optionsExp) Options(scope, element, ngModelCtrl);
else if (multiple) Multiple(scope, element, ngModelCtrl);
else Single(scope, element, ngModelCtrl, selectCtrl);


////////////////////////////



function Single(scope, selectElement, ctrl) {
ctrl.$render = function() {
selectElement.val(ctrl.$viewValue);
function Single(scope, selectElement, ngModelCtrl, selectCtrl) {
ngModelCtrl.$render = function() {
var viewValue = ngModelCtrl.$viewValue;

if (selectCtrl.hasOption(viewValue)) {
if (unknownOption.parent()) unknownOption.remove();
selectElement.val(viewValue);
if (viewValue === '') emptyOption.prop('selected', true); // to make IE9 happy
} else {
if (isUndefined(viewValue) && emptyOption) {
selectElement.val('');
} else {
selectCtrl.renderUnknownOption(viewValue);
}
}
};

selectElement.bind('change', function() {
scope.$apply(function() {
ctrl.$setViewValue(selectElement.val());
if (unknownOption.parent()) unknownOption.remove();
ngModelCtrl.$setViewValue(selectElement.val());
});
});
}
Expand Down Expand Up @@ -219,26 +300,26 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
groupByFn = $parse(match[3] || ''),
valueFn = $parse(match[2] ? match[1] : valueName),
valuesFn = $parse(match[7]),
// we can't just jqLite('<option>') since jqLite is not smart enough
// to create it in <select> and IE barfs otherwise.
optionTemplate = jqLite(document.createElement('option')),
optGroupTemplate = jqLite(document.createElement('optgroup')),
nullOption = false, // if false then user will not be able to select it
// This is an array of array of existing option groups in DOM. We try to reuse these if possible
// optionGroupsCache[0] is the options with no option group
// optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element
optionGroupsCache = [[{element: selectElement, label:''}]];

// find existing special options
forEach(selectElement.children(), function(option) {
if (option.value == '') {
// developer declared null option, so user should be able to select it
nullOption = jqLite(option).remove();
// compile the element since there might be bindings in it
$compile(nullOption)(scope);
}
});
selectElement.html(''); // clear contents
if (nullOption) {
// compile the element since there might be bindings in it
$compile(nullOption)(scope);

// remove the class, which is added automatically because we recompile the element and it
// becomes the compilation root
nullOption.removeClass('ng-scope');

// we need to remove it before calling selectElement.html('') because otherwise IE will
// remove the label from the element. wtf?
nullOption.remove();
}

// clear contents, we'll add what's needed based on the model
selectElement.html('');

selectElement.bind('change', function() {
scope.$apply(function() {
Expand All @@ -250,8 +331,8 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
if (multiple) {
value = [];
for (groupIndex = 0, groupLength = optionGroupsCache.length;
groupIndex < groupLength;
groupIndex++) {
groupIndex < groupLength;
groupIndex++) {
// list of options for that group. (first item has the parent)
optionGroup = optionGroupsCache[groupIndex];

Expand Down Expand Up @@ -365,7 +446,7 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) {
}
}

lastElement = null; // start at the begining
lastElement = null; // start at the beginning
for(index = 0, length = optionGroup.length; index < length; index++) {
option = optionGroup[index];
if ((existingOption = existingOptions[index+1])) {
Expand Down Expand Up @@ -431,19 +512,34 @@ var optionDirective = ['$interpolate', function($interpolate) {
return {
restrict: 'E',
priority: 100,
require: '^select',
compile: function(element, attr) {
if (isUndefined(attr.value)) {
var interpolateFn = $interpolate(element.text(), true);
if (interpolateFn) {
return function (scope, element, attr) {
scope.$watch(interpolateFn, function(value) {
attr.$set('value', value);
});
}
} else {
if (!interpolateFn) {
attr.$set('value', element.text());
}
}

// For some reason Opera defaults to true and if not overridden this messes up the repeater.
// We don't want the view to drive the initialization of the model anyway.
element.prop('selected', false);

return function (scope, element, attr, selectCtrl) {
if (interpolateFn) {
scope.$watch(interpolateFn, function(newVal, oldVal) {
attr.$set('value', newVal);
if (newVal !== oldVal) selectCtrl.removeOption(oldVal);
selectCtrl.addOption(newVal);
});
} else {
selectCtrl.addOption(attr.value);
}

element.bind('$destroy', function() {
selectCtrl.removeOption(attr.value);
});
};
}
}
}];
Loading

0 comments on commit 904b69c

Please sign in to comment.