Skip to content

Commit f768954

Browse files
committed
fix(ng:options): add support for option groups
Closes# 450
1 parent 3237f8b commit f768954

File tree

3 files changed

+214
-100
lines changed

3 files changed

+214
-100
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Issue #464: [ng:options] incorrectly re-grew options on datasource change
77
- Issue #448: [ng:options] should support iterating over objects
88
- Issue #463: [ng:options] should support firing ng:change event
9+
- Issue #450: [ng:options] should support group by (select option groups)
910

1011
### Breaking changes
1112
- no longer support MMMMM in filter.date as we need to follow UNICODE LOCALE DATA formats.

src/widgets.js

Lines changed: 172 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -598,21 +598,27 @@ angularWidget('button', inputWidgetSelector);
598598
* @element select
599599
* @param {comprehension_expression} comprehension in following form
600600
*
601-
* * _select_ `for` _value_ `in` _array_
601+
* * _label_ `for` _value_ `in` _array_
602602
* * _select_ `as` _label_ `for` _value_ `in` _array_
603-
* * _select_ `for` `(`_key_`,` _value_`)` `in` _object_
603+
* * _select_ `as` _label_ `group by` _group_ `for` _value_ `in` _array_
604+
* * _select_ `group by` _group_ `for` _value_ `in` _array_
605+
* * _label_ `for` `(`_key_`,` _value_`)` `in` _object_
604606
* * _select_ `as` _label_ `for` `(`_key_`,` _value_`)` `in` _object_
607+
* * _select_ `as` _label_ `group by` _group_ `for` `(`_key_`,` _value_`)` `in` _object_
608+
* * _select_ `group by` _group_ `for` `(`_key_`,` _value_`)` `in` _object_
605609
*
606610
* Where:
607611
*
608612
* * _array_ / _object_: an expression which evaluates to an array / object to iterate over.
609-
* * _value_: local variable which will reffer to the item in the _array_ or _object_ during
610-
* iteration
611-
* * _key_: local variable which will refer to the key in the _object_ during the iteration
612-
* * _select_: The result of this expression will be assigned to the scope.
613-
* The _select_ can be ommited, in which case the _item_ itself will be assigned.
613+
* * _value_: local variable which will refer to each item in the _array_ or each value of
614+
* _object_ during itteration.
615+
* * _key_: local variable which will refer to the key in the _object_ during the iteration.
614616
* * _label_: The result of this expression will be the `option` label. The
615-
* `expression` most likely refers to the _item_ variable. (optional)
617+
* `expression` will most likely refer to the _value_ variable.
618+
* * _select_: The result of this expression will be bound to the scope. If not specified,
619+
* _select_ expression will default to _value_.
620+
* * _group_: The result of this expression will be used to group options using the `optgroup`
621+
* DOM element.
616622
*
617623
* @example
618624
<doc:example>
@@ -667,8 +673,8 @@ angularWidget('button', inputWidgetSelector);
667673
</doc:scenario>
668674
</doc:example>
669675
*/
670-
// 000012222111111111133330000000004555555555555555554666666777777777777777776666666888888888888888888888864000000009999
671-
var NG_OPTIONS_REGEXP = /^\s*((.*)\s+as\s+)?(.*)\s+for\s+(([\$\w][\$\w\d]*)|(\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/;
676+
// 00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777
677+
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+(.*)$/;
672678
angularWidget('select', function(element){
673679
this.descend(true);
674680
this.directives(true);
@@ -684,53 +690,71 @@ angularWidget('select', function(element){
684690
"Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_' but got '" +
685691
expression + "'.");
686692
}
687-
var displayFn = expressionCompile(match[3]).fnSelf;
688-
var valueName = match[5] || match[8];
689-
var keyName = match[7];
690-
var valueFn = expressionCompile(match[2] || valueName).fnSelf;
691-
var valuesFn = expressionCompile(match[9]).fnSelf;
693+
var displayFn = expressionCompile(match[2] || match[1]).fnSelf;
694+
var valueName = match[4] || match[6];
695+
var keyName = match[5];
696+
var groupByFn = expressionCompile(match[3] || '').fnSelf;
697+
var valueFn = expressionCompile(match[2] ? match[1] : valueName).fnSelf;
698+
var valuesFn = expressionCompile(match[7]).fnSelf;
692699
// we can't just jqLite('<option>') since jqLite is not smart enough
693700
// to create it in <select> and IE barfs otherwise.
694-
var option = jqLite(document.createElement('option'));
695-
return function(select){
701+
var optionTemplate = jqLite(document.createElement('option'));
702+
var optGroupTemplate = jqLite(document.createElement('optgroup'));
703+
var nullOption = false; // if false then user will not be able to select it
704+
return function(selectElement){
696705
var scope = this;
697-
var optionElements = [];
698-
var optionTexts = [];
699-
var lastSelectValue = isMultiselect ? {} : false;
700-
var nullOption = option.clone().val('');
701-
var missingOption = option.clone().val('?');
706+
707+
// This is an array of array of existing option groups in DOM. We try to reuse these if possible
708+
// optionGroupsCache[0] is the options with no option group
709+
// optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element
710+
var optionGroupsCache = [[{element: selectElement, label:''}]];
702711
var model = modelAccessor(scope, element);
703712

704713
// find existing special options
705-
forEach(select.children(), function(option){
706-
if (option.value == '') nullOption = false;
714+
forEach(selectElement.children(), function(option){
715+
if (option.value == '')
716+
// User is allowed to select the null.
717+
nullOption = {label:jqLite(option).text(), id:''};
707718
});
719+
selectElement.html(''); // clear contents
708720

709-
select.bind('change', function(){
721+
selectElement.bind('change', function(){
722+
var optionGroup;
710723
var collection = valuesFn(scope) || [];
711-
var value = select.val();
712-
var index, length;
724+
var key = selectElement.val();
725+
var value;
726+
var optionElement;
727+
var index, groupIndex, length, groupLength;
713728
var tempScope = scope.$new();
714729
try {
715730
if (isMultiselect) {
716731
value = [];
717-
for (index = 0, length = optionElements.length; index < length; index++) {
718-
if (optionElements[index][0].selected) {
719-
tempScope[valueName] = collection[index];
720-
value.push(valueFn(tempScope));
732+
for (groupIndex = 0, groupLength = optionGroupsCache.length;
733+
groupIndex < groupLength;
734+
groupIndex++) {
735+
// list of options for that group. (first item has the parent)
736+
optionGroup = optionGroupsCache[groupIndex];
737+
738+
for(index = 1, length = optionGroup.length; index < length; index++) {
739+
if ((optionElement = optionGroup[index].element)[0].selected) {
740+
if (keyName) tempScope[keyName] = key;
741+
tempScope[valueName] = collection[optionElement.val()];
742+
value.push(valueFn(tempScope));
743+
}
721744
}
722745
}
723746
} else {
724-
if (value == '?') {
747+
if (key == '?') {
725748
value = undefined;
726-
} else if (value == ''){
749+
} else if (key == ''){
727750
value = null;
728751
} else {
729-
tempScope[valueName] = collection[value];
752+
tempScope[valueName] = collection[key];
753+
if (keyName) tempScope[keyName] = key;
730754
value = valueFn(tempScope);
731755
}
732756
}
733-
if (!isUndefined(value) && model.get() !== value) {
757+
if (isDefined(value) && model.get() !== value) {
734758
onChange(scope);
735759
model.set(value);
736760
}
@@ -744,32 +768,46 @@ angularWidget('select', function(element){
744768

745769
scope.$onEval(function(){
746770
var scope = this;
771+
772+
// Temporary location for the option groups before we render them
773+
var optionGroups = {
774+
'':[]
775+
};
776+
var optionGroupNames = [''];
777+
var optionGroupName;
778+
var optionGroup;
779+
var option;
780+
var existingParent, existingOptions, existingOption;
747781
var values = valuesFn(scope) || [];
748782
var keys = values;
749783
var key;
750-
var value;
751-
var length;
784+
var groupLength, length;
752785
var fragment;
753-
var index;
754-
var optionText;
786+
var groupIndex, index;
755787
var optionElement;
756788
var optionScope = scope.$new();
757789
var modelValue = model.get();
758-
var currentItem;
759-
var selectValue = '';
790+
var selected;
791+
var selectedSet = false; // nothing is selected yet
760792
var isMulti = isMultiselect;
793+
var lastElement;
794+
var element;
761795

762796
try {
763797
if (isMulti) {
764-
selectValue = new HashMap();
798+
selectedSet = new HashMap();
765799
if (modelValue && isNumber(length = modelValue.length)) {
766800
for (index = 0; index < length; index++) {
767-
selectValue.put(modelValue[index], true);
801+
selectedSet.put(modelValue[index], true);
768802
}
769803
}
804+
} else if (modelValue === null || nullOption) {
805+
// if we are not multiselect, and we are null then we have to add the nullOption
806+
optionGroups[''].push(extend({selected:modelValue === null, id:'', label:''}, nullOption));
807+
selectedSet = true;
770808
}
771809

772-
// If we have a keyName then we are itterating over on object. We
810+
// If we have a keyName then we are iterating over on object. We
773811
// grab the keys and sort them.
774812
if(keyName) {
775813
keys = [];
@@ -780,68 +818,111 @@ angularWidget('select', function(element){
780818
keys.sort();
781819
}
782820

821+
// We now build up the list of options we need (we merge later)
783822
for (index = 0; length = keys.length, index < length; index++) {
784823
optionScope[valueName] = values[keyName ? optionScope[keyName]=keys[index]:index];
785-
currentItem = valueFn(optionScope);
786-
optionText = displayFn(optionScope);
787-
if (optionTexts.length > index) {
788-
// reuse
789-
optionElement = optionElements[index];
790-
if (optionText != optionTexts[index]) {
791-
(optionElement).text(optionTexts[index] = optionText);
792-
}
793-
} else {
794-
// grow
795-
if (!fragment) {
796-
fragment = document.createDocumentFragment();
797-
}
798-
optionTexts.push(optionText);
799-
optionElements.push(optionElement = option.clone());
800-
optionElement.attr('value', index).text(optionText);
801-
fragment.appendChild(optionElement[0]);
824+
optionGroupName = groupByFn(optionScope) || '';
825+
if (!(optionGroup = optionGroups[optionGroupName])) {
826+
optionGroup = optionGroups[optionGroupName] = [];
827+
optionGroupNames.push(optionGroupName);
802828
}
803829
if (isMulti) {
804-
if (lastSelectValue[index] != (value = selectValue.remove(currentItem))) {
805-
optionElement[0].selected = !!(lastSelectValue[index] = value);
806-
}
830+
selected = !!selectedSet.remove(valueFn(optionScope));
807831
} else {
808-
if (modelValue == currentItem) {
809-
selectValue = index;
810-
}
832+
selected = modelValue === valueFn(optionScope);
833+
selectedSet = selectedSet || selected; // see if at least one item is selected
811834
}
835+
optionGroup.push({
836+
id: keyName ? keys[index] : index, // either the index into array or key from object
837+
label: displayFn(optionScope) || '', // what will be seen by the user
838+
selected: selected // determine if we should be selected
839+
});
812840
}
813-
if (fragment) {
814-
select.append(jqLite(fragment));
815-
}
816-
// shrink children
817-
while(optionElements.length > index) {
818-
optionElements.pop().remove();
819-
optionTexts.pop();
820-
delete lastSelectValue[optionElements.length];
841+
optionGroupNames.sort();
842+
if (!isMulti && !selectedSet) {
843+
// nothing was selected, we have to insert the undefined item
844+
optionGroups[''].unshift({id:'?', label:'', selected:true});
821845
}
822846

823-
if (!isMulti) {
824-
if (selectValue === '' && modelValue) {
825-
// We could not find a match
826-
selectValue = '?';
827-
}
847+
// Now we need to update the list of DOM nodes to match the optionGroups we computed above
848+
for (groupIndex = 0, groupLength = optionGroupNames.length;
849+
groupIndex < groupLength;
850+
groupIndex++) {
851+
// current option group name or '' if no group
852+
optionGroupName = optionGroupNames[groupIndex];
853+
854+
// list of options for that group. (first item has the parent)
855+
optionGroup = optionGroups[optionGroupName];
856+
857+
if (optionGroupsCache.length <= groupIndex) {
858+
// we need to grow the optionGroups
859+
optionGroupsCache.push(
860+
existingOptions = [
861+
existingParent = {
862+
element: optGroupTemplate.clone().attr('label', optionGroupName),
863+
label: optionGroup.label
864+
}
865+
]
866+
);
867+
selectElement.append(existingParent.element);
868+
} else {
869+
existingOptions = optionGroupsCache[groupIndex];
870+
existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element
828871

829-
// update the selected item
830-
if (lastSelectValue !== selectValue) {
831-
if (nullOption) {
832-
if (lastSelectValue == '') nullOption.remove();
833-
if (selectValue === '') select.prepend(nullOption);
872+
// update the OPTGROUP label if not the same.
873+
if (existingParent.label != optionGroupName) {
874+
existingParent.element.attr('label', existingParent.label = optionGroupName);
834875
}
876+
}
835877

836-
if (missingOption) {
837-
if (lastSelectValue == '?') missingOption.remove();
838-
if (selectValue === '?') select.prepend(missingOption);
878+
lastElement = null; // start at the begining
879+
for(index = 0, length = optionGroup.length; index < length; index++) {
880+
option = optionGroup[index];
881+
if (existingOption = existingOptions[index+1]) {
882+
// reuse elements
883+
lastElement = existingOption.element;
884+
if (existingOption.label !== option.label) {
885+
lastElement.text(existingOption.label = option.label);
886+
}
887+
if (existingOption.id !== option.id) {
888+
lastElement.val(existingOption.id = option.id);
889+
}
890+
if (existingOption.selected !== option.selected) {
891+
lastElement.attr('selected', option.selected);
892+
}
893+
} else {
894+
// grow elements
895+
// jQuery(v1.4.2) Bug: We should be able to chain the method calls, but
896+
// in this version of jQuery on some browser the .text() returns a string
897+
// rather then the element.
898+
(element = optionTemplate.clone())
899+
.val(option.id)
900+
.attr('selected', option.selected)
901+
.text(option.label);
902+
existingOptions.push(existingOption = {
903+
element: element,
904+
label: option.label,
905+
id: option.id,
906+
checked: option.selected
907+
});
908+
if (lastElement) {
909+
lastElement.after(element);
910+
} else {
911+
existingParent.element.append(element);
912+
}
913+
lastElement = element;
839914
}
840-
841-
select.val(lastSelectValue = selectValue);
915+
}
916+
// remove any excessive OPTIONs in a group
917+
index++; // increment since the existingOptions[0] is parent element not OPTION
918+
while(existingOptions.length > index) {
919+
existingOptions.pop().element.remove();
842920
}
843921
}
844-
922+
// remove any excessive OPTGROUPs from select
923+
while(optionGroupsCache.length > groupIndex) {
924+
optionGroupsCache.pop()[0].element.remove();
925+
}
845926
} finally {
846927
optionScope = null; // TODO(misko): needs to be $destroy()
847928
}

0 commit comments

Comments
 (0)