angular.js/src/widget/select.js

448 lines
18 KiB
JavaScript
Raw Normal View History

2011-09-08 20:56:29 +00:00
'use strict';
/**
* @ngdoc widget
* @name angular.module.ng.$compileProvider.directive.select
2011-09-08 20:56:29 +00:00
*
* @description
* HTML `SELECT` element with angular data-binding.
*
* # `ng:options`
*
* Optionally `ng:options` 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
* `ng:options` expression.
*˝˝
2011-09-08 20:56:29 +00:00
* 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 `ng:model` attribute
2011-09-08 20:56:29 +00:00
* 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: `ng:options` provides iterator facility for `<option>` element which must be used instead
* of {@link angular.module.ng.$compileProvider.directive.ng:repeat ng:repeat}. `ng:repeat` is not suitable for use with
2011-09-08 20:56:29 +00:00
* `<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.ng:repeat ng:repeat} unrolls after the select binds causing
2011-09-08 20:56:29 +00:00
* incorect rendering on most browsers.
* * binding to a value not in list confuses most browsers.
*
* @param {string} name assignable expression to data-bind to.
* @param {string=} required The widget is considered valid only if value is entered.
* @param {comprehension_expression=} ng:options 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 = [
2011-09-08 20:56:29 +00:00
{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
2011-09-08 20:56:29 +00:00
}
</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):
<div class="nullable">
<select ng:model="color" ng:options="c.name for c in colors">
<option value="">-- chose color --</option>
</select>
</div><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');
2011-09-08 20:56:29 +00:00
select('color').option('0');
expect(binding('{selected_color:color}')).toMatch('black');
2011-09-08 20:56:29 +00:00
using('.nullable').select('color').option('');
expect(binding('{selected_color:color}')).toMatch('null');
2011-09-08 20:56:29 +00:00
});
</doc:scenario>
</doc:example>
*/
var ngOptionsDirective = valueFn({ terminal: true });
var selectDirective = ['$formFactory', '$compile', '$parse',
function($formFactory, $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+(.*)$/;
return {
restrict: 'E',
link: function(modelScope, selectElement, attr) {
if (!attr.ngModel) return;
var form = $formFactory.forElement(selectElement),
multiple = attr.multiple,
optionsExp = attr.ngOptions,
modelExp = attr.ngModel,
widget = form.$createWidget({
scope: modelScope,
model: modelExp,
onChange: attr.ngChange,
alias: attr.name,
controller: ['$scope', optionsExp ? Options : (multiple ? Multiple : Single)]});
selectElement.bind('$destroy', function() { widget.$destroy(); });
widget.$pristine = !(widget.$dirty = false);
widget.$on('$validate', function() {
var valid = !attr.required || !!widget.$modelValue;
if (valid && multiple && attr.required) valid = !!widget.$modelValue.length;
if (valid !== !widget.$error.REQUIRED) {
widget.$emit(valid ? '$valid' : '$invalid', 'REQUIRED');
}
2011-09-08 20:56:29 +00:00
});
widget.$on('$viewChange', function() {
widget.$pristine = !(widget.$dirty = true);
});
2011-09-08 20:56:29 +00:00
forEach(['valid', 'invalid', 'pristine', 'dirty'], function(name) {
widget.$watch('$' + name, function(value) {
selectElement[value ? 'addClass' : 'removeClass']('ng-' + name);
2011-09-08 20:56:29 +00:00
});
});
////////////////////////////
2011-09-08 20:56:29 +00:00
function Multiple(widget) {
widget.$render = function() {
var items = new HashMap(this.$viewValue);
2011-09-08 20:56:29 +00:00
forEach(selectElement.children(), function(option){
option.selected = isDefined(items.get(option.value));
});
};
selectElement.bind('change', function() {
widget.$apply(function() {
var array = [];
forEach(selectElement.children(), function(option){
if (option.selected) {
array.push(option.value);
}
});
widget.$emit('$viewChange', array);
2011-09-08 20:56:29 +00:00
});
});
}
2011-09-08 20:56:29 +00:00
function Single(widget) {
widget.$render = function() {
selectElement.val(widget.$viewValue);
};
2011-09-08 20:56:29 +00:00
selectElement.bind('change', function() {
widget.$apply(function() {
widget.$emit('$viewChange', selectElement.val());
});
2011-09-08 20:56:29 +00:00
});
widget.$viewValue = selectElement.val();
2011-09-08 20:56:29 +00:00
}
function Options(widget) {
var match;
2011-09-08 20:56:29 +00:00
if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) {
throw Error(
"Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" +
" but got '" + optionsExp + "'.");
}
2011-09-08 20:56:29 +00:00
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]),
// 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)(modelScope);
}
});
selectElement.html(''); // clear contents
selectElement.bind('change', function() {
widget.$apply(function() {
var optionGroup,
collection = valuesFn(modelScope) || [],
tempScope = inherit(modelScope),
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) tempScope[keyName] = key;
tempScope[valueName] = collection[key];
value.push(valueFn(tempScope));
}
2011-09-08 20:56:29 +00:00
}
}
} else {
key = selectElement.val();
if (key == '?') {
value = undefined;
} else if (key == ''){
value = null;
} else {
tempScope[valueName] = collection[key];
if (keyName) tempScope[keyName] = key;
value = valueFn(tempScope);
}
2011-09-08 20:56:29 +00:00
}
if (isDefined(value) && modelScope.$viewVal !== value) {
widget.$emit('$viewChange', value);
}
});
2011-09-08 20:56:29 +00:00
});
widget.$watch(render);
widget.$render = render;
function render() {
var optionGroups = {'':[]}, // Temporary location for the option groups before we render them
optionGroupNames = [''],
optionGroupName,
optionGroup,
option,
existingParent, existingOptions, existingOption,
modelValue = widget.$modelValue,
values = valuesFn(modelScope) || [],
keys = keyName ? sortedKeys(values) : values,
groupLength, length,
groupIndex, index,
optionScope = inherit(modelScope),
selected,
selectedSet = false, // nothing is selected yet
lastElement,
element;
2011-09-08 20:56:29 +00:00
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;
2011-09-08 20:56:29 +00:00
}
// We now build up the list of options we need (we merge later)
for (index = 0; length = keys.length, index < length; index++) {
optionScope[valueName] = values[keyName ? optionScope[keyName]=keys[index]:index];
optionGroupName = groupByFn(optionScope) || '';
if (!(optionGroup = optionGroups[optionGroupName])) {
optionGroup = optionGroups[optionGroupName] = [];
optionGroupNames.push(optionGroupName);
}
if (multiple) {
selected = selectedSet.remove(valueFn(optionScope)) != undefined;
} else {
selected = modelValue === valueFn(optionScope);
selectedSet = selectedSet || selected; // see if at least one item is selected
2011-09-08 20:56:29 +00:00
}
optionGroup.push({
id: keyName ? keys[index] : index, // either the index into array or key from object
label: displayFn(optionScope) || '', // 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});
2011-09-08 20:56:29 +00:00
}
// 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);
2011-09-08 20:56:29 +00:00
} 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 begining
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));
}
2011-09-08 20:56:29 +00:00
} 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;
2011-09-08 20:56:29 +00:00
}
}
// 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();
2011-09-08 20:56:29 +00:00
}
}
// remove any excessive OPTGROUPs from select
while(optionGroupsCache.length > groupIndex) {
optionGroupsCache.pop()[0].element.remove();
2011-09-08 20:56:29 +00:00
}
};
}
}
}
}];
var optionDirective = ['$interpolate', function($interpolate) {
return {
priority: 100,
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 {
attr.$set('value', element.text());
2011-09-08 20:56:29 +00:00
}
}
2011-09-08 20:56:29 +00:00
}
}
}];