diff --git a/examples/demo-multi-select.html b/examples/demo-multi-select.html new file mode 100644 index 000000000..9481b4d04 --- /dev/null +++ b/examples/demo-multi-select.html @@ -0,0 +1,168 @@ + + + + + AngularJS ui-select + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Multi Selection Demos

+ +

Array of strings

+ + {{$item}} + + {{color}} + + +

Selected: {{multipleDemo.colors}}

+
+

Array of objects

+ + {{$item.name}} <{{$item.email}}> + +
+ + email: {{person.email}} + age: + +
+
+

Selected: {{multipleDemo.selectedPeople}}

+ +
+

Array of objects with single property binding

+ + {{$item.name}} <{{$item.email}}> + +
+ + email: {{person.email}} + age: + +
+
+

Selected: {{multipleDemo.selectedPeopleSimple}}

+ +
+

Array of objects (with groupBy)

+ + {{$item.name}} <{{$item.email}}> + +
+ + email: {{person.email}} + age: + +
+
+

Selected: {{multipleDemo.selectedPeopleWithGroupBy}}

+ +
+ + + \ No newline at end of file diff --git a/examples/demo.js b/examples/demo.js index 3827a27f3..a66599d60 100644 --- a/examples/demo.js +++ b/examples/demo.js @@ -110,9 +110,18 @@ app.controller('DemoCtrl', function($scope, $http, $timeout) { { name: 'Nicole', email: 'nicole@email.com', age: 43, country: 'Colombia' }, { name: 'Natasha', email: 'natasha@email.com', age: 54, country: 'Ecuador' }, { name: 'Michael', email: 'michael@email.com', age: 15, country: 'Colombia' }, - { name: 'Nicolás', email: 'nicole@email.com', age: 43, country: 'Colombia' } + { name: 'Nicolás', email: 'nicolas@email.com', age: 43, country: 'Colombia' } ]; + $scope.availableColors = ['Red','Green','Blue','Yellow','Magenta','Maroon','Umbra','Turquoise']; + + $scope.multipleDemo = {}; + $scope.multipleDemo.colors = ['Blue','Red']; + $scope.multipleDemo.selectedPeople = [$scope.people[5], $scope.people[4]]; + $scope.multipleDemo.selectedPeopleWithGroupBy = [$scope.people[8], $scope.people[6]]; + $scope.multipleDemo.selectedPeopleSimple = ['samantha@email.com','wladimir@email.com']; + + $scope.address = {}; $scope.refreshAddresses = function(address) { var params = {address: address, sensor: false}; diff --git a/src/bootstrap/match-multiple.tpl.html b/src/bootstrap/match-multiple.tpl.html new file mode 100644 index 000000000..e2dd7a0f5 --- /dev/null +++ b/src/bootstrap/match-multiple.tpl.html @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/src/bootstrap/select-multiple.tpl.html b/src/bootstrap/select-multiple.tpl.html new file mode 100644 index 000000000..a7c3e1cf1 --- /dev/null +++ b/src/bootstrap/select-multiple.tpl.html @@ -0,0 +1,17 @@ + diff --git a/src/select.css b/src/select.css index 415f5deca..616a0c1c4 100644 --- a/src/select.css +++ b/src/select.css @@ -88,17 +88,31 @@ right: 15px; } -.ui-select-bootstrap > .ui-select-choices { - width: 100%; -} - /* See Scrollable Menu with Bootstrap 3 http://stackoverflow.com/questions/19227496 */ .ui-select-bootstrap > .ui-select-choices { + width: 100%; height: auto; max-height: 200px; overflow-x: hidden; } +.ui-select-multiple.ui-select-bootstrap { + height: auto; + padding: .3em; +} + +.ui-select-multiple.ui-select-bootstrap input.ui-select-search { + background-color: transparent !important; /* To prevent double background when disabled */ + border: none; + outline: none; + height: 1.666666em; +} + +.ui-select-multiple.ui-select-bootstrap .ui-select-match .close { + font-size: 1.6em; + line-height: 0.75; +} + .ui-select-bootstrap .ui-select-choices-row>a { display: block; padding: 3px 20px; diff --git a/src/select.js b/src/select.js index dd1c489c4..1c532b599 100644 --- a/src/select.js +++ b/src/select.js @@ -1,6 +1,49 @@ (function () { "use strict"; + var KEY = { + TAB: 9, + ENTER: 13, + ESC: 27, + SPACE: 32, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + SHIFT: 16, + CTRL: 17, + ALT: 18, + PAGE_UP: 33, + PAGE_DOWN: 34, + HOME: 36, + END: 35, + BACKSPACE: 8, + DELETE: 46, + isControl: function (e) { + var k = e.which; + switch (k) { + case KEY.SHIFT: + case KEY.CTRL: + case KEY.ALT: + return true; + } + + if (e.metaKey) return true; + + return false; + }, + isFunctionKey: function (k) { + k = k.which ? k.which : k; + return k >= 112 && k <= 123; + }, + isVerticalMovement: function (k){ + return ~[KEY.UP, KEY.DOWN].indexOf(k); + }, + isHorizontalMovement: function (k){ + return ~[KEY.LEFT,KEY.RIGHT,KEY.BACKSPACE,KEY.DELETE].indexOf(k); + } + }; + /** * Add querySelectorAll() to jqLite. * @@ -102,6 +145,7 @@ ctrl.placeholder = undefined; ctrl.search = EMPTY_SEARCH; ctrl.activeIndex = 0; + ctrl.activeMatchIndex = -1; ctrl.items = []; ctrl.selected = undefined; ctrl.open = false; @@ -111,6 +155,7 @@ ctrl.searchEnabled = undefined; // Initialized inside uiSelect directive link function ctrl.resetSearchInput = undefined; // Initialized inside uiSelect directive link function ctrl.refreshDelay = undefined; // Initialized inside uiSelectChoices directive link function + ctrl.multiple = false; // Initialized inside uiSelect directive link function ctrl.isEmpty = function() { return angular.isUndefined(ctrl.selected) || ctrl.selected === null || ctrl.selected === ''; @@ -126,17 +171,20 @@ if (ctrl.resetSearchInput) { ctrl.search = EMPTY_SEARCH; //reset activeIndex - if (ctrl.selected && ctrl.items.length) { + if (ctrl.selected && ctrl.items.length && !ctrl.multiple) { ctrl.activeIndex = ctrl.items.indexOf(ctrl.selected); } } } // When the user clicks on ui-select, displays the dropdown list - ctrl.activate = function(initSearchValue) { - if (!ctrl.disabled) { - _resetSearchInput(); + ctrl.activate = function(initSearchValue, avoidReset) { + if (!ctrl.disabled && !ctrl.open) { + if(!avoidReset) _resetSearchInput(); ctrl.open = true; + ctrl.activeMatchIndex = -1; + + ctrl.activeIndex = ctrl.activeIndex >= ctrl.items.length ? 0 : ctrl.activeIndex; // Give it time to appear before focus $timeout(function() { @@ -188,8 +236,13 @@ if (!angular.isArray(items)) { throw uiSelectMinErr('items', "Expected an array but got '{0}'.", items); } else { - // Regular case - setItemsFn(items); + if (ctrl.multiple){ + //Remove already selected items (ex: while searching) + var filteredItems = items.filter(function(i) {return ctrl.selected.indexOf(i) < 0;}); + setItemsFn(filteredItems); + }else{ + setItemsFn(items); + } ctrl.ngModel.$modelValue = null; //Force scope model value and ngModel value to be out of sync to re-run formatters } @@ -197,6 +250,16 @@ }); + if (ctrl.multiple){ + //Remove already selected items + $scope.$watchCollection('$select.selected', function(selectedItems){ + if (!selectedItems) return; + var data = ctrl.parserResult.source($scope); + var filteredItems = data.filter(function(i) {return selectedItems.indexOf(i) < 0;}); + setItemsFn(filteredItems); + }); + } + }; var _refreshDelayPromise; @@ -238,11 +301,15 @@ ctrl.onSelectCallback($scope, { $item: item, $model: ctrl.parserResult.modelMapper($scope, locals) - }); + }); - ctrl.selected = item; + if(ctrl.multiple){ + ctrl.selected.push(item); + ctrl.sizeSearchInput(); + } else { + ctrl.selected = item; + } ctrl.close(); - // Using a watch instead of $scope.ngModel.$setViewValue(item) }; // Closes the dropdown @@ -256,28 +323,53 @@ } }; - var Key = { - Enter: 13, - Tab: 9, - Up: 38, - Down: 40, - Escape: 27 + // Remove item from multiple select + ctrl.removeChoice = function(index){ + ctrl.selected.splice(index, 1); + ctrl.activeMatchIndex = -1; + ctrl.sizeSearchInput(); }; - function _onKeydown(key) { + ctrl.getPlaceholder = function(){ + //Refactor single? + if(ctrl.multiple && ctrl.selected.length) return; + return ctrl.placeholder; + }; + + ctrl.sizeSearchInput = function(){ + var input = _searchInput[0], + container = _searchInput.parent().parent()[0]; + _searchInput.css('width','10px'); + $timeout(function(){ + var newWidth = container.clientWidth - input.offsetLeft - 10; + if(newWidth < 50) newWidth = container.clientWidth; + _searchInput.css('width',newWidth+'px'); + }, 0, false); + }; + + function _handleDropDownSelection(key) { var processed = true; switch (key) { - case Key.Down: - if (ctrl.activeIndex < ctrl.items.length - 1) { ctrl.activeIndex++; } + case KEY.DOWN: + if (!ctrl.open && ctrl.multiple) ctrl.activate(false, true); //In case its the search input in 'multiple' mode + else if (ctrl.activeIndex < ctrl.items.length - 1) { ctrl.activeIndex++; } + break; + case KEY.UP: + if (!ctrl.open && ctrl.multiple) ctrl.activate(false, true); //In case its the search input in 'multiple' mode + else if (ctrl.activeIndex > 0) { ctrl.activeIndex--; } break; - case Key.Up: - if (ctrl.activeIndex > 0) { ctrl.activeIndex--; } + case KEY.TAB: + //TODO: Que hacemos en modo multiple? + if (!ctrl.multiple) ctrl.select(ctrl.items[ctrl.activeIndex]); break; - case Key.Tab: - case Key.Enter: - ctrl.select(ctrl.items[ctrl.activeIndex]); + case KEY.ENTER: + if(ctrl.open){ + ctrl.select(ctrl.items[ctrl.activeIndex]); + } else { + ctrl.activate(false, true); //In case its the search input in 'multiple' mode + } break; - case Key.Escape: + case KEY.ESC: ctrl.close(); break; default: @@ -286,30 +378,114 @@ return processed; } + // Handles selected options in "multiple" mode + function _handleMatchSelection(key){ + var caretPosition = _getCaretPosition(_searchInput[0]), + length = ctrl.selected.length, + // none = -1, + first = 0, + last = length-1, + curr = ctrl.activeMatchIndex, + next = ctrl.activeMatchIndex+1, + prev = ctrl.activeMatchIndex-1, + newIndex = curr; + + if(caretPosition > 0 || (ctrl.search.length && key == KEY.RIGHT)) return false; + + ctrl.close(); + + function getNewActiveMatchIndex(){ + switch(key){ + case KEY.LEFT: + // Select previous/first item + if(~ctrl.activeMatchIndex) return prev; + // Select last item + else return last; + break; + case KEY.RIGHT: + // Open drop-down + if(!~ctrl.activeMatchIndex || curr === last){ + ctrl.activate(); + return false; + } + // Select next/last item + else return next; + break; + case KEY.BACKSPACE: + // Remove selected item and select previous/first + if(~ctrl.activeMatchIndex){ + ctrl.removeChoice(curr); + return prev; + } + // Select last item + else return last; + break; + case KEY.DELETE: + // Remove selected item and select next item + if(~ctrl.activeMatchIndex){ + ctrl.removeChoice(ctrl.activeMatchIndex); + return curr; + } + else return false; + } + } + + newIndex = getNewActiveMatchIndex(); + + if(!ctrl.selected.length || newIndex === false) ctrl.activeMatchIndex = -1; + else ctrl.activeMatchIndex = Math.min(last,Math.max(first,newIndex)); + + return true; + } + // Bind to keyboard shortcuts _searchInput.on('keydown', function(e) { - // Keyboard shortcuts are all about the items, - // does not make sense (and will crash) if ctrl.items is empty - if (ctrl.items && ctrl.items.length >= 0) { - var key = e.which; - - $scope.$apply(function() { - var processed = _onKeydown(key); - if (processed && key != Key.Tab) { - e.preventDefault(); - e.stopPropagation(); - } - }); - switch (key) { - case Key.Down: - case Key.Up: - _ensureHighlightVisible(); - break; + var key = e.which; + + // if(~[KEY.ESC,KEY.TAB].indexOf(key)){ + // //TODO: SEGURO? + // ctrl.close(); + // } + + $scope.$apply(function() { + var processed = false; + + if(ctrl.multiple && KEY.isHorizontalMovement(key)){ + processed = _handleMatchSelection(key); } + + if (!processed && ctrl.items.length > 0) { + processed = _handleDropDownSelection(key); + } + + if (processed && key != KEY.TAB) { + //TODO Check si el tab selecciona aun correctamente + //Crear test + e.preventDefault(); + e.stopPropagation(); + } + }); + + if(KEY.isVerticalMovement(key) && ctrl.items.length > 0){ + _ensureHighlightVisible(); } + + }); + + _searchInput.on('blur', function() { + $timeout(function() { + ctrl.activeMatchIndex = -1; + ctrl.activeIndex = 0; + }); }); + function _getCaretPosition(el) { + if(angular.isNumber(el.selectionStart)) return el.selectionStart; + // selectionStart is not supported in IE8 and we don't want hacky workarounds so we compromise + else return el.value.length; + } + // See https://github.com/ivaynberg/select2/blob/3.4.6/select2.js#L1431 function _ensureHighlightVisible() { var container = $element.querySelectorAll('.ui-select-choices-content'); @@ -333,7 +509,7 @@ } $scope.$on('$destroy', function() { - _searchInput.off('keydown'); + _searchInput.off('keydown blur'); }); }]) @@ -345,7 +521,7 @@ restrict: 'EA', templateUrl: function(tElement, tAttrs) { var theme = tAttrs.theme || uiSelectConfig.theme; - return theme + '/select.tpl.html'; + return theme + (angular.isDefined(tAttrs.multiple) ? '/select-multiple.tpl.html' : '/select.tpl.html'); }, replace: true, transclude: true, @@ -359,26 +535,70 @@ var $select = ctrls[0]; var ngModel = ctrls[1]; + $select.multiple = angular.isDefined(attrs.multiple); + $select.onSelectCallback = $parse(attrs.onSelect); //From view --> model ngModel.$parsers.unshift(function (inputValue) { - var locals = {}; - locals[$select.parserResult.itemName] = inputValue; - var result = $select.parserResult.modelMapper(scope, locals); - return result; + var locals = {}, + result; + if ($select.multiple){ + var resultMultiple = []; + for (var j = inputValue.length - 1; j >= 0; j--) { + locals = {}; + locals[$select.parserResult.itemName] = inputValue[j]; + result = $select.parserResult.modelMapper(scope, locals); + resultMultiple.unshift(result); + } + return resultMultiple; + }else{ + locals = {}; + locals[$select.parserResult.itemName] = inputValue; + result = $select.parserResult.modelMapper(scope, locals); + return result; + } }); //From model --> view ngModel.$formatters.unshift(function (inputValue) { - var data = $select.parserResult.source(scope); + var data = $select.parserResult.source (scope, { $select : {search:''}}), //Overwrite $search + locals = {}, + result; if (data){ - for (var i = data.length - 1; i >= 0; i--) { - var locals = {}; - locals[$select.parserResult.itemName] = data[i]; - var result = $select.parserResult.modelMapper(scope, locals); - if (result == inputValue){ - return data[i]; + if ($select.multiple){ + var resultMultiple = []; + var checkFnMultiple = function(list, value){ + if (!list || !list.length) return; + for (var p = list.length - 1; p >= 0; p--) { + locals[$select.parserResult.itemName] = list[p]; + result = $select.parserResult.modelMapper(scope, locals); + if (result == value){ + resultMultiple.unshift(list[p]); + return true; + } + } + return false; + }; + if (!inputValue) return resultMultiple; //If ngModel was undefined + for (var k = inputValue.length - 1; k >= 0; k--) { + if (!checkFnMultiple($select.selected, inputValue[k])){ + checkFnMultiple(data, inputValue[k]); + } + } + return resultMultiple; + }else{ + var checkFnSingle = function(d){ + locals[$select.parserResult.itemName] = d; + result = $select.parserResult.modelMapper(scope, locals); + return result == inputValue; + }; + //If possible pass same object stored in $select.selected + if ($select.selected && checkFnSingle($select.selected)) { + return $select.selected; + } + for (var i = data.length - 1; i >= 0; i--) { + if (checkFnSingle(data[i])) return data[i]; } } } @@ -393,100 +613,56 @@ $compile(focusser)(scope); $select.focusser = focusser; - element.append(focusser); - focusser.bind("focus", function(){ - scope.$evalAsync(function(){ - $select.focus = true; + if (!$select.multiple){ + + element.append(focusser); + focusser.bind("focus", function(){ + scope.$evalAsync(function(){ + $select.focus = true; + }); }); - }); - focusser.bind("blur", function(){ - scope.$evalAsync(function(){ - $select.focus = false; + focusser.bind("blur", function(){ + scope.$evalAsync(function(){ + $select.focus = false; + }); }); - }); - focusser.bind("keydown", function(e){ - - if (e.which === KEY.BACKSPACE) { - e.preventDefault(); - e.stopPropagation(); - $select.select(undefined); - scope.$digest(); - return; - } + focusser.bind("keydown", function(e){ + + if (e.which === KEY.BACKSPACE) { + e.preventDefault(); + e.stopPropagation(); + $select.select(undefined); + scope.$digest(); + return; + } - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { - return; - } + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { + return; + } - if (e.which == KEY.DOWN || e.which == KEY.UP || e.which == KEY.ENTER || e.which == KEY.SPACE){ - e.preventDefault(); - e.stopPropagation(); - $select.activate(); - } + if (e.which == KEY.DOWN || e.which == KEY.UP || e.which == KEY.ENTER || e.which == KEY.SPACE){ + e.preventDefault(); + e.stopPropagation(); + $select.activate(); + } - scope.$digest(); - }); + scope.$digest(); + }); - focusser.bind("keyup input", function(e){ + focusser.bind("keyup input", function(e){ - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC || e.which == KEY.ENTER || e.which === KEY.BACKSPACE) { - return; - } - - $select.activate(focusser.val()); //User pressed some regualar key, so we pass it to the search input - focusser.val(''); - scope.$digest(); - - }); + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC || e.which == KEY.ENTER || e.which === KEY.BACKSPACE) { + return; + } + + $select.activate(focusser.val()); //User pressed some regular key, so we pass it to the search input + focusser.val(''); + scope.$digest(); - //TODO Refactor to reuse the KEY object from uiSelectCtrl - var KEY = { - TAB: 9, - ENTER: 13, - ESC: 27, - SPACE: 32, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - SHIFT: 16, - CTRL: 17, - ALT: 18, - PAGE_UP: 33, - PAGE_DOWN: 34, - HOME: 36, - END: 35, - BACKSPACE: 8, - DELETE: 46, - isArrow: function (k) { - k = k.which ? k.which : k; - switch (k) { - case KEY.LEFT: - case KEY.RIGHT: - case KEY.UP: - case KEY.DOWN: - return true; - } - return false; - }, - isControl: function (e) { - var k = e.which; - switch (k) { - case KEY.SHIFT: - case KEY.CTRL: - case KEY.ALT: - return true; - } + }); - if (e.metaKey) return true; + } - return false; - }, - isFunctionKey: function (k) { - k = k.which ? k.which : k; - return k >= 112 && k <= 123; - } - }; scope.$watch('searchEnabled', function() { var searchEnabled = scope.$eval(attrs.searchEnabled); @@ -504,13 +680,33 @@ $select.resetSearchInput = resetSearchInput !== undefined ? resetSearchInput : true; }); - scope.$watch('$select.selected', function(newValue) { - if (ngModel.$viewValue !== newValue) { - ngModel.$setViewValue(newValue); - } - }); + if ($select.multiple){ + scope.$watchCollection('$select.selected', function(newValue) { + //On v1.2.19 the 2nd and 3rd parameteres are ignored + //On v1.3.0-beta+ 3rd parameter (revalidate) is true, to force $parsers to recreate model + ngModel.$setViewValue(newValue, null, true); + }); + focusser.prop('disabled', true); //Focusser isn't needed if multiple + }else{ + scope.$watch('$select.selected', function(newValue) { + if (ngModel.$viewValue !== newValue) { + ngModel.$setViewValue(newValue); + } + }); + } ngModel.$render = function() { + if($select.multiple){ + // Make sure that model value is array + if(!angular.isArray(ngModel.$viewValue)){ + // Have tolerance for null or undefined values + if(angular.isUndefined(ngModel.$viewValue) || ngModel.$viewValue === null){ + $select.selected = []; + } else { + throw uiSelectMinErr('multiarr', "Expected model value to be array but got '{0}'", ngModel.$viewValue); + } + } + } $select.selected = ngModel.$viewValue; }; @@ -612,7 +808,8 @@ $compile(element, transcludeFn)(scope); //Passing current transcludeFn to be able to append elements correctly from uisTranscludeAppend - scope.$watch('$select.search', function() { + scope.$watch('$select.search', function(newValue) { + if(newValue && !$select.open && $select.multiple) $select.activate(false, true); $select.activeIndex = 0; $select.refresh(attrs.refresh); }); @@ -626,6 +823,7 @@ } }; }]) + // Recreates old behavior of ng-transclude. Used internally. .directive('uisTranscludeAppend', function () { return { link: function (scope, element, attrs, ctrl, transclude) { @@ -644,12 +842,18 @@ templateUrl: function(tElement) { // Gets theme attribute from parent (ui-select) var theme = tElement.parent().attr('theme') || uiSelectConfig.theme; - return theme + '/match.tpl.html'; + var multi = tElement.parent().attr('multiple'); + return theme + (multi ? '/match-multiple.tpl.html' : '/match.tpl.html'); }, link: function(scope, element, attrs, $select) { attrs.$observe('placeholder', function(placeholder) { $select.placeholder = placeholder !== undefined ? placeholder : uiSelectConfig.placeholder; }); + + if($select.multiple){ + $select.sizeSearchInput(); + } + } }; }]) diff --git a/src/select2/match-multiple.tpl.html b/src/select2/match-multiple.tpl.html new file mode 100644 index 000000000..d4a2217e7 --- /dev/null +++ b/src/select2/match-multiple.tpl.html @@ -0,0 +1,12 @@ + + +
  • + + +
  • +
    \ No newline at end of file diff --git a/src/select2/select-multiple.tpl.html b/src/select2/select-multiple.tpl.html new file mode 100644 index 000000000..b014aff99 --- /dev/null +++ b/src/select2/select-multiple.tpl.html @@ -0,0 +1,26 @@ +
    + + +
    \ No newline at end of file diff --git a/test/select.spec.js b/test/select.spec.js index 3897bbc8d..77ecc6437 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -3,13 +3,26 @@ describe('ui-select tests', function() { var scope, $rootScope, $compile, $timeout; + var Key = { + Enter: 13, + Tab: 9, + Up: 38, + Down: 40, + Left: 37, + Right: 39, + Backspace: 8, + Delete: 46, + Escape: 27 + }; + beforeEach(module('ngSanitize', 'ui.select')); beforeEach(inject(function(_$rootScope_, _$compile_, _$timeout_) { $rootScope = _$rootScope_; scope = $rootScope.$new(); $compile = _$compile_; $timeout = _$timeout_; - scope.selection = {} + scope.selection = {}; + scope.getGroupLabel = function(person) { return person.age % 2 ? 'even' : 'odd'; }; @@ -93,6 +106,12 @@ describe('ui-select tests', function() { element.trigger(e); } + function setSearchText(el, text) { + el.scope().$select.search = text; + scope.$digest(); + $timeout.flush(); + } + // Tests it('should compile child directives', function() { @@ -552,6 +571,97 @@ describe('ui-select tests', function() { }); + it('should call refresh function when search text changes', function () { + + var el = compileTemplate( + ' \ + \ + \ + \ +
    \ +
    \ + I should appear only once\ +
    \ +
    \ +
    ' + ); + + scope.fetchFromServer = function(){}; + + spyOn(scope, 'fetchFromServer'); + + el.scope().$select.search = 'r'; + scope.$digest(); + $timeout.flush(); + + expect(scope.fetchFromServer).toHaveBeenCalledWith('r'); + + }); + + it('should call refresh function when search text changes', function () { + + var el = compileTemplate( + ' \ + \ + \ + \ +
    \ +
    \ + I should appear only once\ +
    \ +
    \ +
    ' + ); + + scope.fetchFromServer = function(){}; + + spyOn(scope, 'fetchFromServer'); + + el.scope().$select.search = 'r'; + scope.$digest(); + $timeout.flush(); + + expect(scope.fetchFromServer).toHaveBeenCalledWith('r'); + + }); + + it('should format view value correctly when using single property binding and refresh funcion', function () { + + var el = compileTemplate( + ' \ + {{$select.selected.name}} \ + \ +
    \ +
    \ + I should appear only once\ +
    \ +
    \ +
    ' + ); + + scope.fetchFromServer = function(searching){ + + if (searching == 's') + return scope.people + + if (searching == 'o'){ + scope.people = []; //To simulate cases were previously selected item isnt in the list anymore + } + + }; + + setSearchText(el, 'r') + clickItem(el, 'Samantha'); + expect(getMatchLabel(el)).toBe('Samantha'); + + setSearchText(el, 'o') + expect(getMatchLabel(el)).toBe('Samantha'); + + }); + describe('search-enabled option', function() { var el; @@ -614,4 +724,395 @@ describe('ui-select tests', function() { }); + + describe('multi selection', function() { + + function createUiSelectMultiple(attrs) { + var attrsHtml = ''; + if (attrs !== undefined) { + if (attrs.disabled !== undefined) { attrsHtml += ' ng-disabled="' + attrs.disabled + '"'; } + if (attrs.required !== undefined) { attrsHtml += ' ng-required="' + attrs.required + '"'; } + } + + return compileTemplate( + ' \ + {{$item.name}} <{{$item.email}}> \ + \ +
    \ +
    \ +
    \ +
    ' + ); + } + + it('should render initial state', function() { + var el = createUiSelectMultiple(); + expect(el).toHaveClass('ui-select-multiple'); + expect(el.scope().$select.selected.length).toBe(0); + expect(el.find('.ui-select-match-item').length).toBe(0); + }); + + it('should set model as an empty array if ngModel isnt defined', function () { + + // scope.selection.selectedMultiple = []; + var el = createUiSelectMultiple(); + + expect(scope.selection.selectedMultiple instanceof Array).toBe(true); + + }); + + it('should render initial selected items', function() { + scope.selection.selectedMultiple = [scope.people[4], scope.people[5]]; //Wladimir & Samantha + var el = createUiSelectMultiple(); + expect(el.scope().$select.selected.length).toBe(2); + expect(el.find('.ui-select-match-item').length).toBe(2); + }); + + it('should remove item by pressing X icon', function() { + scope.selection.selectedMultiple = [scope.people[4], scope.people[5]]; //Wladimir & Samantha + var el = createUiSelectMultiple(); + expect(el.scope().$select.selected.length).toBe(2); + el.find('.ui-select-match-item').first().find('.ui-select-match-close').click(); + expect(el.scope().$select.selected.length).toBe(1); + // $timeout.flush(); + }); + + it('should update size of search input after removing an item', function() { + scope.selection.selectedMultiple = [scope.people[4], scope.people[5]]; //Wladimir & Samantha + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + var oldWidth = searchInput.css('width'); + el.find('.ui-select-match-item').first().find('.ui-select-match-close').click(); + + $timeout.flush(); + expect(oldWidth).not.toBe(searchInput.css('width')); + + }); + + it('should move to last match when pressing BACKSPACE key from search', function() { + + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Backspace); + expect(isDropdownOpened(el)).toEqual(false); + expect(el.scope().$select.activeMatchIndex).toBe(el.scope().$select.selected.length - 1); + + }); + + it('should remove hightlighted match when pressing BACKSPACE key from search and decrease activeMatchIndex', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5], scope.people[6]]; //Wladimir, Samantha & Nicole + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Left); + triggerKeydown(searchInput, Key.Left); + triggerKeydown(searchInput, Key.Backspace); + expect(el.scope().$select.selected).toEqual([scope.people[4], scope.people[6]]); //Wladimir & Nicole + + expect(el.scope().$select.activeMatchIndex).toBe(0); + + }); + + it('should remove hightlighted match when pressing DELETE key from search and keep same activeMatchIndex', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5], scope.people[6]]; //Wladimir, Samantha & Nicole + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Left); + triggerKeydown(searchInput, Key.Left); + triggerKeydown(searchInput, Key.Delete); + expect(el.scope().$select.selected).toEqual([scope.people[4], scope.people[6]]); //Wladimir & Nicole + + expect(el.scope().$select.activeMatchIndex).toBe(1); + + }); + + it('should move to last match when pressing LEFT key from search', function() { + + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Left); + expect(isDropdownOpened(el)).toEqual(false); + expect(el.scope().$select.activeMatchIndex).toBe(el.scope().$select.selected.length - 1); + + }); + + it('should move between matches when pressing LEFT key from search', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5], scope.people[6]]; //Wladimir, Samantha & Nicole + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Left) + triggerKeydown(searchInput, Key.Left) + expect(isDropdownOpened(el)).toEqual(false); + expect(el.scope().$select.activeMatchIndex).toBe(el.scope().$select.selected.length - 2); + triggerKeydown(searchInput, Key.Left) + triggerKeydown(searchInput, Key.Left) + triggerKeydown(searchInput, Key.Left) + expect(el.scope().$select.activeMatchIndex).toBe(0); + + }); + + it('should decrease $select.activeMatchIndex when pressing LEFT key', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5], scope.people[6]]; //Wladimir, Samantha & Nicole + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + el.scope().$select.activeMatchIndex = 3 + triggerKeydown(searchInput, Key.Left) + triggerKeydown(searchInput, Key.Left) + expect(el.scope().$select.activeMatchIndex).toBe(1); + + }); + + it('should increase $select.activeMatchIndex when pressing RIGHT key', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5], scope.people[6]]; //Wladimir, Samantha & Nicole + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + el.scope().$select.activeMatchIndex = 0 + triggerKeydown(searchInput, Key.Right) + triggerKeydown(searchInput, Key.Right) + expect(el.scope().$select.activeMatchIndex).toBe(2); + + }); + + it('should open dropdown when pressing DOWN key', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5]]; //Wladimir & Samantha + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Down) + expect(isDropdownOpened(el)).toEqual(true); + + }); + + it('should search/open dropdown when writing to search input', function() { + + scope.selection.selectedMultiple = [scope.people[5]]; //Wladimir & Samantha + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + el.scope().$select.search = 'r'; + scope.$digest(); + expect(isDropdownOpened(el)).toEqual(true); + + }); + + it('should add selected match to selection array', function() { + + scope.selection.selectedMultiple = [scope.people[5]]; //Samantha + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + clickItem(el, 'Wladimir'); + expect(scope.selection.selectedMultiple).toEqual([scope.people[5], scope.people[4]]); //Samantha & Wladimir + + }); + + it('should close dropdown after selecting', function() { + + scope.selection.selectedMultiple = [scope.people[5]]; //Samantha + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Down) + expect(isDropdownOpened(el)).toEqual(true); + + clickItem(el, 'Wladimir'); + + expect(isDropdownOpened(el)).toEqual(false); + + }); + + it('should closes dropdown when pressing ESC key from search input', function() { + + scope.selection.selectedMultiple = [scope.people[4], scope.people[5], scope.people[6]]; //Wladimir, Samantha & Nicole + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + expect(isDropdownOpened(el)).toEqual(false); + triggerKeydown(searchInput, Key.Down) + expect(isDropdownOpened(el)).toEqual(true); + triggerKeydown(searchInput, Key.Escape) + expect(isDropdownOpened(el)).toEqual(false); + + }); + + it('should select highlighted match when pressing ENTER key from dropdown', function() { + + scope.selection.selectedMultiple = [scope.people[5]]; //Samantha + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + triggerKeydown(searchInput, Key.Down) + triggerKeydown(searchInput, Key.Enter) + expect(scope.selection.selectedMultiple.length).toEqual(2); + + }); + + it('should increase $select.activeIndex when pressing DOWN key from dropdown', function() { + + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + triggerKeydown(searchInput, Key.Down); //Open dropdown + + el.scope().$select.activeIndex = 0 + triggerKeydown(searchInput, Key.Down) + triggerKeydown(searchInput, Key.Down) + expect(el.scope().$select.activeIndex).toBe(2); + + }); + + it('should decrease $select.activeIndex when pressing UP key from dropdown', function() { + + var el = createUiSelectMultiple(); + var searchInput = el.find('.ui-select-search'); + + triggerKeydown(searchInput, Key.Down); //Open dropdown + + el.scope().$select.activeIndex = 5 + triggerKeydown(searchInput, Key.Up) + triggerKeydown(searchInput, Key.Up) + expect(el.scope().$select.activeIndex).toBe(3); + + }); + + it('should render initial selected items', function() { + scope.selection.selectedMultiple = [scope.people[4], scope.people[5]]; //Wladimir & Samantha + var el = createUiSelectMultiple(); + expect(el.scope().$select.selected.length).toBe(2); + expect(el.find('.ui-select-match-item').length).toBe(2); + }); + + it('should parse the items correctly using single property binding', function() { + + scope.selection.selectedMultiple = ['wladimir@email.com', 'samantha@email.com']; + + var el = compileTemplate( + ' \ + {{$item.name}} <{{$item.email}}> \ + \ +
    \ +
    \ +
    \ +
    ' + ); + + expect(el.scope().$select.selected).toEqual([scope.people[4], scope.people[5]]); + + }); + + it('should add selected match to selection array using single property binding', function() { + + scope.selection.selectedMultiple = ['wladimir@email.com', 'samantha@email.com']; + + var el = compileTemplate( + ' \ + {{$item.name}} <{{$item.email}}> \ + \ +
    \ +
    \ +
    \ +
    ' + ); + + var searchInput = el.find('.ui-select-search'); + + clickItem(el, 'Natasha'); + + expect(el.scope().$select.selected).toEqual([scope.people[4], scope.people[5], scope.people[7]]); + scope.selection.selectedMultiple = ['wladimir@email.com', 'samantha@email.com', 'natasha@email.com']; + + }); + + it('should format view value correctly when using single property binding and refresh funcion', function () { + + scope.selection.selectedMultiple = ['wladimir@email.com', 'samantha@email.com']; + + var el = compileTemplate( + ' \ + {{$item.name}} <{{$item.email}}> \ + \ +
    \ +
    \ +
    \ +
    ' + ); + + var searchInput = el.find('.ui-select-search'); + + scope.fetchFromServer = function(searching){ + + if (searching == 'n') + return scope.people + + if (searching == 'o'){ + scope.people = []; //To simulate cases were previously selected item isnt in the list anymore + } + + }; + + setSearchText(el, 'n') + clickItem(el, 'Nicole'); + + expect(el.find('.ui-select-match-item [uis-transclude-append]:not(.ng-hide)').text()) + .toBe("Wladimir Samantha Nicole "); + + setSearchText(el, 'o') + + expect(el.find('.ui-select-match-item [uis-transclude-append]:not(.ng-hide)').text()) + .toBe("Wladimir Samantha Nicole "); + + }); + + it('should watch changes for $select.selected and update formatted value correctly', function () { + + scope.selection.selectedMultiple = ['wladimir@email.com', 'samantha@email.com']; + + var el = compileTemplate( + ' \ + {{$item.name}} <{{$item.email}}> \ + \ +
    \ +
    \ +
    \ +
    \ + ' + ); + + var el2 = compileTemplate(''); + + expect(el.find('.ui-select-match-item [uis-transclude-append]:not(.ng-hide)').text()) + .toBe("Wladimir Samantha "); + + clickItem(el, 'Nicole'); + + expect(el.find('.ui-select-match-item [uis-transclude-append]:not(.ng-hide)').text()) + .toBe("Wladimir Samantha Nicole "); + + expect(scope.selection.selectedMultiple.length).toBe(3); + + }); + + }); + + });