mirror of
https://github.com/Hopiu/angular.js.git
synced 2026-04-01 22:10:24 +00:00
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.
545 lines
21 KiB
JavaScript
545 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* @ngdoc directive
|
|
* @name angular.module.ng.$compileProvider.directive.select
|
|
* @restrict E
|
|
*
|
|
* @description
|
|
* HTML `SELECT` element with angular data-binding.
|
|
*
|
|
* # `ngOptions`
|
|
*
|
|
* Optionally `ngOptions` attribute can be used to dynamically generate a list of `<option>`
|
|
* elements for a `<select>` element using an array or an object obtained by evaluating the
|
|
* `ngOptions` expression.
|
|
*˝˝
|
|
* When an item in the select menu is select, the value of array element or object property
|
|
* represented by the selected option will be bound to the model identified by the `ngModel`
|
|
* directive of the parent select element.
|
|
*
|
|
* Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can
|
|
* 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 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.
|
|
* @param {comprehension_expression=} ngOptions in one of the following forms:
|
|
*
|
|
* * for array data sources:
|
|
* * `label` **`for`** `value` **`in`** `array`
|
|
* * `select` **`as`** `label` **`for`** `value` **`in`** `array`
|
|
* * `label` **`group by`** `group` **`for`** `value` **`in`** `array`
|
|
* * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array`
|
|
* * for object data sources:
|
|
* * `label` **`for (`**`key` **`,`** `value`**`) in`** `object`
|
|
* * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object`
|
|
* * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object`
|
|
* * `select` **`as`** `label` **`group by`** `group`
|
|
* **`for` `(`**`key`**`,`** `value`**`) in`** `object`
|
|
*
|
|
* Where:
|
|
*
|
|
* * `array` / `object`: an expression which evaluates to an array / object to iterate over.
|
|
* * `value`: local variable which will refer to each item in the `array` or each property value
|
|
* of `object` during iteration.
|
|
* * `key`: local variable which will refer to a property name in `object` during iteration.
|
|
* * `label`: The result of this expression will be the label for `<option>` element. The
|
|
* `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`).
|
|
* * `select`: The result of this expression will be bound to the model of the parent `<select>`
|
|
* element. If not specified, `select` expression will default to `value`.
|
|
* * `group`: The result of this expression will be used to group options using the `<optgroup>`
|
|
* DOM element.
|
|
*
|
|
* @example
|
|
<doc:example>
|
|
<doc:source>
|
|
<script>
|
|
function MyCntrl($scope) {
|
|
$scope.colors = [
|
|
{name:'black', shade:'dark'},
|
|
{name:'white', shade:'light'},
|
|
{name:'red', shade:'dark'},
|
|
{name:'blue', shade:'dark'},
|
|
{name:'yellow', shade:'light'}
|
|
];
|
|
$scope.color = $scope.colors[2]; // red
|
|
}
|
|
</script>
|
|
<div ng-controller="MyCntrl">
|
|
<ul>
|
|
<li ng-repeat="color in colors">
|
|
Name: <input ng-model="color.name">
|
|
[<a href ng-click="colors.$remove(color)">X</a>]
|
|
</li>
|
|
<li>
|
|
[<a href ng-click="colors.push({})">add</a>]
|
|
</li>
|
|
</ul>
|
|
<hr/>
|
|
Color (null not allowed):
|
|
<select ng-model="color" ng-options="c.name for c in colors"></select><br>
|
|
|
|
Color (null allowed):
|
|
<span class="nullable">
|
|
<select ng-model="color" ng-options="c.name for c in colors">
|
|
<option value="">-- chose color --</option>
|
|
</select>
|
|
</span><br/>
|
|
|
|
Color grouped by shade:
|
|
<select ng-model="color" ng-options="c.name group by c.shade for c in colors">
|
|
</select><br/>
|
|
|
|
|
|
Select <a href ng-click="color={name:'not in list'}">bogus</a>.<br>
|
|
<hr/>
|
|
Currently selected: {{ {selected_color:color} }}
|
|
<div style="border:solid 1px black; height:20px"
|
|
ng-style="{'background-color':color.name}">
|
|
</div>
|
|
</div>
|
|
</doc:source>
|
|
<doc:scenario>
|
|
it('should check ng-options', function() {
|
|
expect(binding('{selected_color:color}')).toMatch('red');
|
|
select('color').option('0');
|
|
expect(binding('{selected_color:color}')).toMatch('black');
|
|
using('.nullable').select('color').option('');
|
|
expect(binding('{selected_color:color}')).toMatch('null');
|
|
});
|
|
</doc:scenario>
|
|
</doc:example>
|
|
*/
|
|
|
|
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+(.*)$/,
|
|
nullModelCtrl = {$setViewValue: noop};
|
|
|
|
return {
|
|
restrict: 'E',
|
|
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();
|
|
}
|
|
};
|
|
|
|
|
|
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) {
|
|
ngModelCtrl.$setValidity('required', !attr.required || (value && value.length));
|
|
return value;
|
|
};
|
|
|
|
ngModelCtrl.$parsers.push(requiredValidator);
|
|
ngModelCtrl.$formatters.unshift(requiredValidator);
|
|
|
|
attr.$observe('required', function() {
|
|
requiredValidator(ngModelCtrl.$viewValue);
|
|
});
|
|
}
|
|
|
|
if (optionsExp) Options(scope, element, ngModelCtrl);
|
|
else if (multiple) Multiple(scope, element, ngModelCtrl);
|
|
else Single(scope, element, ngModelCtrl, selectCtrl);
|
|
|
|
|
|
////////////////////////////
|
|
|
|
|
|
|
|
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() {
|
|
if (unknownOption.parent()) unknownOption.remove();
|
|
ngModelCtrl.$setViewValue(selectElement.val());
|
|
});
|
|
});
|
|
}
|
|
|
|
function Multiple(scope, selectElement, ctrl) {
|
|
var lastView;
|
|
ctrl.$render = function() {
|
|
var items = new HashMap(ctrl.$viewValue);
|
|
forEach(selectElement.children(), function(option) {
|
|
option.selected = isDefined(items.get(option.value));
|
|
});
|
|
};
|
|
|
|
// we have to do it on each watch since ngModel watches reference, but
|
|
// we need to work of an array, so we need to see if anything was inserted/removed
|
|
scope.$watch(function() {
|
|
if (!equals(lastView, ctrl.$viewValue)) {
|
|
lastView = copy(ctrl.$viewValue);
|
|
ctrl.$render();
|
|
}
|
|
});
|
|
|
|
selectElement.bind('change', function() {
|
|
scope.$apply(function() {
|
|
var array = [];
|
|
forEach(selectElement.children(), function(option) {
|
|
if (option.selected) {
|
|
array.push(option.value);
|
|
}
|
|
});
|
|
ctrl.$setViewValue(array);
|
|
});
|
|
});
|
|
}
|
|
|
|
function Options(scope, selectElement, ctrl) {
|
|
var match;
|
|
|
|
if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) {
|
|
throw Error(
|
|
"Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" +
|
|
" but got '" + optionsExp + "'.");
|
|
}
|
|
|
|
var displayFn = $parse(match[2] || match[1]),
|
|
valueName = match[4] || match[6],
|
|
keyName = match[5],
|
|
groupByFn = $parse(match[3] || ''),
|
|
valueFn = $parse(match[2] ? match[1] : valueName),
|
|
valuesFn = $parse(match[7]),
|
|
// 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:''}]];
|
|
|
|
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() {
|
|
var optionGroup,
|
|
collection = valuesFn(scope) || [],
|
|
locals = {},
|
|
key, value, optionElement, index, groupIndex, length, groupLength;
|
|
|
|
if (multiple) {
|
|
value = [];
|
|
for (groupIndex = 0, groupLength = optionGroupsCache.length;
|
|
groupIndex < groupLength;
|
|
groupIndex++) {
|
|
// list of options for that group. (first item has the parent)
|
|
optionGroup = optionGroupsCache[groupIndex];
|
|
|
|
for(index = 1, length = optionGroup.length; index < length; index++) {
|
|
if ((optionElement = optionGroup[index].element)[0].selected) {
|
|
key = optionElement.val();
|
|
if (keyName) locals[keyName] = key;
|
|
locals[valueName] = collection[key];
|
|
value.push(valueFn(scope, locals));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
key = selectElement.val();
|
|
if (key == '?') {
|
|
value = undefined;
|
|
} else if (key == ''){
|
|
value = null;
|
|
} else {
|
|
locals[valueName] = collection[key];
|
|
if (keyName) locals[keyName] = key;
|
|
value = valueFn(scope, locals);
|
|
}
|
|
}
|
|
ctrl.$setViewValue(value);
|
|
});
|
|
});
|
|
|
|
ctrl.$render = render;
|
|
|
|
// TODO(vojta): can't we optimize this ?
|
|
scope.$watch(render);
|
|
|
|
function render() {
|
|
var optionGroups = {'':[]}, // Temporary location for the option groups before we render them
|
|
optionGroupNames = [''],
|
|
optionGroupName,
|
|
optionGroup,
|
|
option,
|
|
existingParent, existingOptions, existingOption,
|
|
modelValue = ctrl.$modelValue,
|
|
values = valuesFn(scope) || [],
|
|
keys = keyName ? sortedKeys(values) : values,
|
|
groupLength, length,
|
|
groupIndex, index,
|
|
locals = {},
|
|
selected,
|
|
selectedSet = false, // nothing is selected yet
|
|
lastElement,
|
|
element;
|
|
|
|
if (multiple) {
|
|
selectedSet = new HashMap(modelValue);
|
|
} else if (modelValue === null || nullOption) {
|
|
// if we are not multiselect, and we are null then we have to add the nullOption
|
|
optionGroups[''].push({selected:modelValue === null, id:'', label:''});
|
|
selectedSet = true;
|
|
}
|
|
|
|
// We now build up the list of options we need (we merge later)
|
|
for (index = 0; length = keys.length, index < length; index++) {
|
|
locals[valueName] = values[keyName ? locals[keyName]=keys[index]:index];
|
|
optionGroupName = groupByFn(scope, locals) || '';
|
|
if (!(optionGroup = optionGroups[optionGroupName])) {
|
|
optionGroup = optionGroups[optionGroupName] = [];
|
|
optionGroupNames.push(optionGroupName);
|
|
}
|
|
if (multiple) {
|
|
selected = selectedSet.remove(valueFn(scope, locals)) != undefined;
|
|
} else {
|
|
selected = modelValue === valueFn(scope, locals);
|
|
selectedSet = selectedSet || selected; // see if at least one item is selected
|
|
}
|
|
optionGroup.push({
|
|
id: keyName ? keys[index] : index, // either the index into array or key from object
|
|
label: displayFn(scope, locals) || '', // what will be seen by the user
|
|
selected: selected // determine if we should be selected
|
|
});
|
|
}
|
|
if (!multiple && !selectedSet) {
|
|
// nothing was selected, we have to insert the undefined item
|
|
optionGroups[''].unshift({id:'?', label:'', selected:true});
|
|
}
|
|
|
|
// Now we need to update the list of DOM nodes to match the optionGroups we computed above
|
|
for (groupIndex = 0, groupLength = optionGroupNames.length;
|
|
groupIndex < groupLength;
|
|
groupIndex++) {
|
|
// current option group name or '' if no group
|
|
optionGroupName = optionGroupNames[groupIndex];
|
|
|
|
// list of options for that group. (first item has the parent)
|
|
optionGroup = optionGroups[optionGroupName];
|
|
|
|
if (optionGroupsCache.length <= groupIndex) {
|
|
// we need to grow the optionGroups
|
|
existingParent = {
|
|
element: optGroupTemplate.clone().attr('label', optionGroupName),
|
|
label: optionGroup.label
|
|
};
|
|
existingOptions = [existingParent];
|
|
optionGroupsCache.push(existingOptions);
|
|
selectElement.append(existingParent.element);
|
|
} else {
|
|
existingOptions = optionGroupsCache[groupIndex];
|
|
existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element
|
|
|
|
// update the OPTGROUP label if not the same.
|
|
if (existingParent.label != optionGroupName) {
|
|
existingParent.element.attr('label', existingParent.label = optionGroupName);
|
|
}
|
|
}
|
|
|
|
lastElement = null; // start at the beginning
|
|
for(index = 0, length = optionGroup.length; index < length; index++) {
|
|
option = optionGroup[index];
|
|
if ((existingOption = existingOptions[index+1])) {
|
|
// reuse elements
|
|
lastElement = existingOption.element;
|
|
if (existingOption.label !== option.label) {
|
|
lastElement.text(existingOption.label = option.label);
|
|
}
|
|
if (existingOption.id !== option.id) {
|
|
lastElement.val(existingOption.id = option.id);
|
|
}
|
|
if (existingOption.element.selected !== option.selected) {
|
|
lastElement.prop('selected', (existingOption.selected = option.selected));
|
|
}
|
|
} else {
|
|
// grow elements
|
|
|
|
// if it's a null option
|
|
if (option.id === '' && nullOption) {
|
|
// put back the pre-compiled element
|
|
element = nullOption;
|
|
} else {
|
|
// jQuery(v1.4.2) Bug: We should be able to chain the method calls, but
|
|
// in this version of jQuery on some browser the .text() returns a string
|
|
// rather then the element.
|
|
(element = optionTemplate.clone())
|
|
.val(option.id)
|
|
.attr('selected', option.selected)
|
|
.text(option.label);
|
|
}
|
|
|
|
existingOptions.push(existingOption = {
|
|
element: element,
|
|
label: option.label,
|
|
id: option.id,
|
|
selected: option.selected
|
|
});
|
|
if (lastElement) {
|
|
lastElement.after(element);
|
|
} else {
|
|
existingParent.element.append(element);
|
|
}
|
|
lastElement = element;
|
|
}
|
|
}
|
|
// remove any excessive OPTIONs in a group
|
|
index++; // increment since the existingOptions[0] is parent element not OPTION
|
|
while(existingOptions.length > index) {
|
|
existingOptions.pop().element.remove();
|
|
}
|
|
}
|
|
// remove any excessive OPTGROUPs from select
|
|
while(optionGroupsCache.length > groupIndex) {
|
|
optionGroupsCache.pop()[0].element.remove();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}];
|
|
|
|
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) {
|
|
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);
|
|
});
|
|
};
|
|
}
|
|
}
|
|
}];
|