diff --git a/karma.conf.js b/karma.conf.js
index af8c3539a..9a8c9fe82 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -15,6 +15,7 @@ module.exports = function(config) {
'bower_components/angular-mocks/angular-mocks.js',
'dist/select.js',
+ 'test/helpers.js',
'test/**/*.spec.js'
],
diff --git a/src/bootstrap/choices.tpl.html b/src/bootstrap/choices.tpl.html
index 472dd8549..db6ff026c 100644
--- a/src/bootstrap/choices.tpl.html
+++ b/src/bootstrap/choices.tpl.html
@@ -1,7 +1,11 @@
diff --git a/src/select.css b/src/select.css
index 5fec0482a..04c3991d6 100644
--- a/src/select.css
+++ b/src/select.css
@@ -95,6 +95,29 @@
overflow-x: hidden;
}
+.ui-select-bootstrap .ui-select-choices-row>a {
+ display: block;
+ padding: 3px 20px;
+ clear: both;
+ font-weight: 400;
+ line-height: 1.42857143;
+ color: #333;
+ white-space: nowrap;
+}
+
+.ui-select-bootstrap .ui-select-choices-row>a:hover, .ui-select-bootstrap .ui-select-choices-row>a:focus {
+ text-decoration: none;
+ color: #262626;
+ background-color: #f5f5f5;
+}
+
+.ui-select-bootstrap .ui-select-choices-row.active>a {
+ color: #fff;
+ text-decoration: none;
+ outline: 0;
+ background-color: #428bca;
+}
+
/* fix hide/show angular animation */
.ui-select-match.ng-hide-add,
.ui-select-search.ng-hide-add {
diff --git a/src/select.js b/src/select.js
index 6e666f904..da141bd59 100644
--- a/src/select.js
+++ b/src/select.js
@@ -88,8 +88,12 @@
};
};
- self.getNgRepeatExpression = function(lhs, rhs, trackByExp) {
- var expression = lhs + ' in ' + rhs;
+ self.getGroupNgRepeatExpression = function() {
+ return '($group, $items) in $select.groups';
+ };
+
+ self.getNgRepeatExpression = function(lhs, rhs, trackByExp, grouped) {
+ var expression = lhs + ' in ' + (grouped ? '$items' : rhs);
if (trackByExp) {
expression += ' track by ' + trackByExp;
}
@@ -153,8 +157,34 @@
}
};
- ctrl.parseRepeatAttr = function(repeatAttr) {
- var repeat = RepeatParser.parse(repeatAttr);
+ ctrl.parseRepeatAttr = function(repeatAttr, groupByExp) {
+ function updateGroups(items) {
+ ctrl.groups = {};
+ angular.forEach(items, function(item) {
+ var groupFn = $scope.$eval(groupByExp);
+ var groupValue = angular.isFunction(groupFn) ? groupFn(item) : item[groupFn];
+ if(!ctrl.groups[groupValue]) {
+ ctrl.groups[groupValue] = [item];
+ }
+ else {
+ ctrl.groups[groupValue].push(item);
+ }
+ });
+ ctrl.items = [];
+ angular.forEach(Object.keys(ctrl.groups).sort(), function(group) {
+ ctrl.items = ctrl.items.concat(ctrl.groups[group]);
+ });
+ }
+
+ function setPlainItems(items) {
+ ctrl.items = items;
+ }
+
+ var repeat = RepeatParser.parse(repeatAttr),
+ setItemsFn = groupByExp ? updateGroups : setPlainItems;
+
+ ctrl.isGrouped = !!groupByExp;
+ ctrl.itemProperty = repeat.lhs;
// See https://github.com/angular/angular.js/blob/v1.2.15/src/ng/directive/ngRepeat.js#L259
$scope.$watchCollection(repeat.rhs, function(items) {
@@ -169,7 +199,7 @@
throw uiSelectMinErr('items', "Expected an array but got '{0}'.", items);
} else {
// Regular case
- ctrl.items = items;
+ setItemsFn(items);
}
}
@@ -198,6 +228,14 @@
}
};
+ ctrl.setActiveItem = function(item) {
+ ctrl.activeIndex = ctrl.items.indexOf(item);
+ };
+
+ ctrl.isActive = function(itemScope) {
+ return ctrl.items.indexOf(itemScope[ctrl.itemProperty]) === ctrl.activeIndex;
+ };
+
// When the user clicks on an item inside the dropdown
ctrl.select = function(item) {
ctrl.selected = item;
@@ -271,19 +309,22 @@
// See https://github.com/ivaynberg/select2/blob/3.4.6/select2.js#L1431
function _ensureHighlightVisible() {
var container = $element.querySelectorAll('.ui-select-choices-content');
- var rows = container.querySelectorAll('.ui-select-choices-row');
- if (rows.length < 1) {
- throw uiSelectMinErr('rows', "Expected multiple .ui-select-choices-row but got '{0}'.", rows.length);
+ var choices = container.querySelectorAll('.ui-select-choices-row');
+ if (choices.length < 1) {
+ throw uiSelectMinErr('choices', "Expected multiple .ui-select-choices-row but got '{0}'.", choices.length);
}
- var highlighted = rows[ctrl.activeIndex];
+ var highlighted = choices[ctrl.activeIndex];
var posY = highlighted.offsetTop + highlighted.clientHeight - container[0].scrollTop;
var height = container[0].offsetHeight;
if (posY > height) {
container[0].scrollTop += posY - height;
} else if (posY < highlighted.clientHeight) {
- container[0].scrollTop -= highlighted.clientHeight - posY;
+ if (ctrl.isGrouped && ctrl.activeIndex === 0)
+ container[0].scrollTop = 0; //To make group header visible when going all the way up
+ else
+ container[0].scrollTop -= highlighted.clientHeight - posY;
}
}
@@ -493,28 +534,53 @@
compile: function(tElement, tAttrs) {
var repeat = RepeatParser.parse(tAttrs.repeat);
+ var groupByExp = tAttrs.groupBy;
return function link(scope, element, attrs, $select, transcludeFn) {
-
- var rows = element.querySelectorAll('.ui-select-choices-row');
- if (rows.length !== 1) {
- throw uiSelectMinErr('rows', "Expected 1 .ui-select-choices-row but got '{0}'.", rows.length);
+
+ var choices;
+
+ if(groupByExp) {
+ var group = element.querySelectorAll('.ui-select-choices-group');
+ if (group.length !== 1) {
+ throw uiSelectMinErr('rows', "Expected 1 .ui-select-choices-group but got '{0}'.", group.length);
+ }
+ group.attr('ng-repeat', RepeatParser.getGroupNgRepeatExpression());
+
+ choices = group.querySelectorAll('.ui-select-choices-row');
+ if (choices.length !== 1) {
+ throw uiSelectMinErr('rows', "Expected 1 .ui-select-choices-row but got '{0}'.", choices.length);
+ }
+
+ }else{
+
+ choices = element.querySelectorAll('.ui-select-choices-row');
+ if (choices.length !== 2) {
+ throw uiSelectMinErr('rows', "Expected 2 .ui-select-choices-row but got '{0}'.", choices.length);
+ }
+
}
- rows.attr('ng-repeat', RepeatParser.getNgRepeatExpression(repeat.lhs, '$select.items', repeat.trackByExp))
- .attr('ng-mouseenter', '$select.activeIndex = $index')
+ choices.attr('ng-repeat', RepeatParser.getNgRepeatExpression(repeat.lhs, '$select.items', repeat.trackByExp, groupByExp))
+ .attr('ng-mouseenter', '$select.setActiveItem('+repeat.lhs+')')
.attr('ng-click', '$select.select(' + repeat.lhs + ')');
+ //Remove unused .ui-select-choices-row element (simple/group) to avoid problems when _ensureHighlightVisible()
+ //We aren't using ngIf since content at element won't be ready when getting at current link fn
+ var otherRow = element.querySelectorAll('.ui-select-choices-row');
+ angular.forEach(otherRow, function(row){
+ if (choices[0] !== row) row.remove();
+ });
transcludeFn(function(clone) {
- var rowsInner = element.querySelectorAll('.ui-select-choices-row-inner');
+ var rowsInner = choices.querySelectorAll('.ui-select-choices-row-inner');
if (rowsInner.length !== 1)
throw uiSelectMinErr('rows', "Expected 1 .ui-select-choices-row-inner but got '{0}'.", rowsInner.length);
-
+
rowsInner.append(clone);
$compile(element)(scope);
});
- $select.parseRepeatAttr(attrs.repeat);
+ $select.parseRepeatAttr(attrs.repeat, groupByExp);
scope.$watch('$select.search', function() {
$select.activeIndex = 0;
@@ -565,4 +631,4 @@
return query && matchItem ? matchItem.replace(new RegExp(escapeRegexp(query), 'gi'), '
$&') : matchItem;
};
});
-}());
\ No newline at end of file
+}());
diff --git a/src/select2/choices.tpl.html b/src/select2/choices.tpl.html
index 82ddea791..ebf8913e5 100644
--- a/src/select2/choices.tpl.html
+++ b/src/select2/choices.tpl.html
@@ -1,5 +1,18 @@
- -
+
+
+
-
+
+
+ -
+
{{$group}}
+
+
+
diff --git a/src/selectize/choices.tpl.html b/src/selectize/choices.tpl.html
index 58a254f4c..244c16009 100644
--- a/src/selectize/choices.tpl.html
+++ b/src/selectize/choices.tpl.html
@@ -1,8 +1,10 @@
diff --git a/test/helpers.js b/test/helpers.js
new file mode 100644
index 000000000..9544675a7
--- /dev/null
+++ b/test/helpers.js
@@ -0,0 +1,15 @@
+beforeEach(function() {
+ jasmine.addMatchers({
+ toHaveClass: function(util, customEqualityTesters) {
+ return {
+ compare: function(actual, cls) {
+ var pass = actual.hasClass(cls);
+ return {
+ pass: pass,
+ message: "Expected '" + actual + "'" + (pass ? ' not ' : ' ') + "to have class '" + cls + "'."
+ }
+ }
+ }
+ }
+ });
+});
diff --git a/test/select.spec.js b/test/select.spec.js
index c502e9de7..a9709ab0c 100644
--- a/test/select.spec.js
+++ b/test/select.spec.js
@@ -9,15 +9,19 @@ describe('ui-select tests', function() {
scope = $rootScope.$new();
$compile = _$compile_;
+ scope.getGroupLabel = function(person) {
+ return person.age % 2 ? 'even' : 'odd';
+ };
+
scope.people = [
- { name: 'Adam', email: 'adam@email.com', age: 10 },
- { name: 'Amalie', email: 'amalie@email.com', age: 12 },
- { name: 'Wladimir', email: 'wladimir@email.com', age: 30 },
- { name: 'Samantha', email: 'samantha@email.com', age: 31 },
- { name: 'Estefanía', email: 'estefanía@email.com', age: 16 },
- { name: 'Natasha', email: 'natasha@email.com', age: 54 },
- { name: 'Nicole', email: 'nicole@email.com', age: 43 },
- { name: 'Adrian', email: 'adrian@email.com', age: 21 }
+ { name: 'Adam', email: 'adam@email.com', group: 'Foo', age: 12 },
+ { name: 'Amalie', email: 'amalie@email.com', group: 'Foo', age: 12 },
+ { name: 'Estefanía', email: 'estefanía@email.com', group: 'Foo', age: 21 },
+ { name: 'Adrian', email: 'adrian@email.com', group: 'Foo', age: 21 },
+ { name: 'Wladimir', email: 'wladimir@email.com', group: 'Foo', age: 30 },
+ { name: 'Samantha', email: 'samantha@email.com', group: 'bar', age: 30 },
+ { name: 'Nicole', email: 'nicole@email.com', group: 'bar', age: 43 },
+ { name: 'Natasha', email: 'natasha@email.com', group: 'Baz', age: 54 }
];
}));
@@ -69,6 +73,12 @@ describe('ui-select tests', function() {
return el.scope().$select.open && el.hasClass('open');
}
+ function triggerKeydown(element, keyCode) {
+ var e = jQuery.Event("keydown");
+ e.which = keyCode;
+ e.keyCode = keyCode;
+ element.trigger(e);
+ }
// Tests
@@ -183,6 +193,78 @@ describe('ui-select tests', function() {
expect(getMatchLabel(el)).toEqual('false');
});
+ describe('choices group', function() {
+ function getGroupLabel(item) {
+ return item.parent('.ui-select-choices-group').find('.ui-select-choices-group-label');
+ }
+ function createUiSelect() {
+ return compileTemplate(
+ '
\
+ {{$select.selected.name}} \
+ \
+ \
+ \
+ \
+ '
+ );
+ }
+
+ it('should create items group', function() {
+ var el = createUiSelect();
+ expect(el.find('.ui-select-choices-group').length).toBe(3);
+ });
+
+ it('should show label before each group', function() {
+ var el = createUiSelect();
+ expect(el.find('.ui-select-choices-group .ui-select-choices-group-label').map(function() {
+ return this.textContent;
+ }).toArray()).toEqual(['Baz', 'Foo', 'bar']);
+ });
+
+ it('should hide empty groups', function() {
+ var el = createUiSelect();
+ el.scope().$select.search = 'd';
+ scope.$digest();
+
+ expect(el.find('.ui-select-choices-group .ui-select-choices-group-label').map(function() {
+ return this.textContent;
+ }).toArray()).toEqual(['Foo']);
+ });
+
+ it('should change activeItem through groups', function() {
+ var el = createUiSelect();
+ el.scope().$select.search = 'n';
+ scope.$digest();
+ var choices = el.find('.ui-select-choices-row');
+ expect(choices.eq(0)).toHaveClass('active');
+ expect(getGroupLabel(choices.eq(0)).text()).toBe('Baz');
+
+ triggerKeydown(el.find('input'), 40 /*Down*/);
+ scope.$digest();
+ expect(choices.eq(1)).toHaveClass('active');
+ expect(getGroupLabel(choices.eq(1)).text()).toBe('Foo');
+ });
+ });
+
+ describe('choices group by function', function() {
+ function createUiSelect() {
+ return compileTemplate(
+ '
\
+ {{$select.selected.name}} \
+ \
+ \
+ \
+ '
+ );
+ }
+ it("should extract group value through function", function () {
+ var el = createUiSelect();
+ expect(el.find('.ui-select-choices-group .ui-select-choices-group-label').map(function() {
+ return this.textContent;
+ }).toArray()).toEqual(['even', 'odd']);
+ });
+ });
+
it('should throw when no ui-select-choices found', function() {
expect(function() {
compileTemplate(