From 1bb4b20637fe173557ec5827a06b07cadc4e72e7 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 3 Aug 2014 20:04:42 -0500 Subject: [PATCH 01/18] feat(multiple): support for multi-selection --- src/select.js | 409 +++++++++++++++++++-------- src/select2/match-multiple.tpl.html | 12 + src/select2/select-multiple.tpl.html | 26 ++ 3 files changed, 332 insertions(+), 115 deletions(-) create mode 100644 src/select2/match-multiple.tpl.html create mode 100644 src/select2/select-multiple.tpl.html diff --git a/src/select.js b/src/select.js index dd1c489c4..8b31875dc 100644 --- a/src/select.js +++ b/src/select.js @@ -102,6 +102,7 @@ ctrl.placeholder = undefined; ctrl.search = EMPTY_SEARCH; ctrl.activeIndex = 0; + ctrl.activeMatchIndex = -1; ctrl.items = []; ctrl.selected = undefined; ctrl.open = false; @@ -111,6 +112,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 === ''; @@ -133,10 +135,11 @@ } // 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; // Give it time to appear before focus $timeout(function() { @@ -238,11 +241,17 @@ ctrl.onSelectCallback($scope, { $item: item, $model: ctrl.parserResult.modelMapper($scope, locals) - }); + }); - ctrl.selected = item; + if(ctrl.multiple){ + if(!_itemInSelected(item)){ + 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,26 +265,66 @@ } }; + // Remove item from multiple select + ctrl.removeChoice = function(index){ + ctrl.selected.splice(index, 1); + ctrl.activeMatchIndex = -1; + ctrl.sizeSearchInput(); + }; + + 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; + if(newWidth < 50) newWidth = container.clientWidth; + _searchInput.css('width',newWidth+'px'); + }); + }; + var Key = { Enter: 13, Tab: 9, Up: 38, Down: 40, + Left: 37, + Right: 39, + Backspace: 8, + Delete: 46, Escape: 27 }; - function _onKeydown(key) { + Key.verticalMovement = [Key.Up,Key.Down]; + Key.horizontalMovement = [Key.Left,Key.Right,Key.Backspace,Key.Delete]; + + function _handleDropDownSelection(key) { var processed = true; switch (key) { case Key.Down: - if (ctrl.activeIndex < ctrl.items.length - 1) { ctrl.activeIndex++; } + if (!ctrl.open && ctrl.multiple) ctrl.activate(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.activeIndex > 0) { ctrl.activeIndex--; } + if (!ctrl.open && ctrl.multiple) ctrl.activate(true); //In case its the search input in 'multiple' mode + else if (ctrl.activeIndex > 0) { ctrl.activeIndex--; } break; case Key.Tab: + //TODO: Que hacemos en modo multiple? + if (!ctrl.multiple) ctrl.select(ctrl.items[ctrl.activeIndex]); + break; case Key.Enter: - ctrl.select(ctrl.items[ctrl.activeIndex]); + if(ctrl.open){ + ctrl.select(ctrl.items[ctrl.activeIndex]); + } else { + ctrl.activate(true); //In case its the search input in 'multiple' mode + } break; case Key.Escape: ctrl.close(); @@ -286,30 +335,124 @@ 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 __getNewIndex(){ + 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 = __getNewIndex(); + + 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.Escape,Key.Tab].indexOf(key)){ + // //TODO: SEGURO? + // ctrl.close(); + // } + + $scope.$apply(function() { + var processed = false; + + if(ctrl.multiple && ~Key.horizontalMovement.indexOf(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.verticalMovement.indexOf(key)){ + _ensureHighlightVisible(); } + + }); + + _searchInput.on('blur', function() { + $timeout(function() { + ctrl.activeMatchIndex = -1; + ctrl.activeIndex = 0; + }); }); + function _itemInSelected(item){ + var match = false; + angular.forEach(ctrl.selected,function(value){ + // We need to use angular.equals for when an initially selected item lacks $$hashKey and stuff. + if(angular.equals(item,value)) match = true; + }); + + return match; + } + + 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 +476,7 @@ } $scope.$on('$destroy', function() { - _searchInput.off('keydown'); + _searchInput.off('keydown blur'); }); }]) @@ -345,7 +488,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,6 +502,8 @@ var $select = ctrls[0]; var ngModel = ctrls[1]; + $select.multiple = angular.isDefined(attrs.multiple); + $select.onSelectCallback = $parse(attrs.onSelect); //From view --> model @@ -393,100 +538,105 @@ $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; - } + //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; + } + }; - 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); @@ -508,9 +658,23 @@ if (ngModel.$viewValue !== newValue) { ngModel.$setViewValue(newValue); } - }); + if($select.multiple) $select.sizeSearchInput(); + },$select.multiple); //Do depth watch if multiple + + if ($select.multiple) focusser.prop('disabled', true); //Focusser isn't needed if multiple 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 +776,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(true); $select.activeIndex = 0; $select.refresh(attrs.refresh); }); @@ -644,12 +809,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(); + } + } }; }]) @@ -668,5 +839,13 @@ return function(matchItem, query) { return query && matchItem ? matchItem.replace(new RegExp(escapeRegexp(query), 'gi'), '$&') : matchItem; }; + }) + // Re-creates the old behavior of ng-transclude. Used internally. + .directive('transinject', function() { + return function(scope, element, attrs, ctrl, transcludeFn) { + transcludeFn(scope, function(clone) { + element.append(clone); + }); + }; }); }()); diff --git a/src/select2/match-multiple.tpl.html b/src/select2/match-multiple.tpl.html new file mode 100644 index 000000000..4adf18dcd --- /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..c19b3fb3c --- /dev/null +++ b/src/select2/select-multiple.tpl.html @@ -0,0 +1,26 @@ +
    + + +
    \ No newline at end of file From f244ee478f39805b43166a4438242351a04d110e Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 3 Aug 2014 21:11:52 -0500 Subject: [PATCH 02/18] Fix: multiple choices highlighted at same time --- src/select.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/select.js b/src/select.js index 8b31875dc..9def21e1a 100644 --- a/src/select.js +++ b/src/select.js @@ -128,7 +128,7 @@ 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); } } From 40cd4504fe538338994472538dd50903d83b2270 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 3 Aug 2014 21:21:39 -0500 Subject: [PATCH 03/18] Fix: dropdown doesn't appear when pressing key down (just the word "true") --- src/select.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/select.js b/src/select.js index 9def21e1a..5b10c1314 100644 --- a/src/select.js +++ b/src/select.js @@ -308,11 +308,11 @@ var processed = true; switch (key) { case Key.Down: - if (!ctrl.open && ctrl.multiple) ctrl.activate(true); //In case its the search input in 'multiple' mode + 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(true); //In case its the search input in 'multiple' mode + 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.Tab: @@ -323,7 +323,7 @@ if(ctrl.open){ ctrl.select(ctrl.items[ctrl.activeIndex]); } else { - ctrl.activate(true); //In case its the search input in 'multiple' mode + ctrl.activate(false, true); //In case its the search input in 'multiple' mode } break; case Key.Escape: @@ -777,7 +777,7 @@ $compile(element, transcludeFn)(scope); //Passing current transcludeFn to be able to append elements correctly from uisTranscludeAppend scope.$watch('$select.search', function(newValue) { - if(newValue && !$select.open && $select.multiple) $select.activate(true); + if(newValue && !$select.open && $select.multiple) $select.activate(false, true); $select.activeIndex = 0; $select.refresh(attrs.refresh); }); From 5b7d95800a7435fdb985402d90b9d371a3a51b6e Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 3 Aug 2014 22:43:37 -0500 Subject: [PATCH 04/18] Remove items from dropdown that are already selected --- src/select.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/select.js b/src/select.js index 5b10c1314..dfb7ae30f 100644 --- a/src/select.js +++ b/src/select.js @@ -141,6 +141,8 @@ 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() { ctrl.search = initSearchValue || ctrl.search; @@ -200,6 +202,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; @@ -424,7 +436,7 @@ } }); - if(~Key.verticalMovement.indexOf(key)){ + if(~Key.verticalMovement.indexOf(key) && ctrl.items.length > 0){ _ensureHighlightVisible(); } From f3c1d04b6b9f1aa5835c5047e851c0483fa23615 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Tue, 5 Aug 2014 01:22:29 -0500 Subject: [PATCH 05/18] Check formatter/parser (single property binding) to work correctly on "multiple" mode --- src/select.js | 73 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/src/select.js b/src/select.js index dfb7ae30f..89a1b0479 100644 --- a/src/select.js +++ b/src/select.js @@ -520,22 +520,53 @@ //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), + 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 = []; + for (var k = data.length - 1; k >= 0; k--) { + locals = {}; + locals[$select.parserResult.itemName] = data[k]; + result = $select.parserResult.modelMapper(scope, locals); + for (var j = inputValue.length - 1; j >= 0; j--) { + if (result == inputValue[j]){ + resultMultiple.push(data[k]); + break; + } + } + } + return resultMultiple; + }else{ + for (var i = data.length - 1; i >= 0; i--) { + locals = {}; + locals[$select.parserResult.itemName] = data[i]; + result = $select.parserResult.modelMapper(scope, locals); + if (result == inputValue){ + return data[i]; + } } } } @@ -666,14 +697,18 @@ $select.resetSearchInput = resetSearchInput !== undefined ? resetSearchInput : true; }); - scope.$watch('$select.selected', function(newValue) { - if (ngModel.$viewValue !== newValue) { - ngModel.$setViewValue(newValue); - } - if($select.multiple) $select.sizeSearchInput(); - },$select.multiple); //Do depth watch if multiple - - if ($select.multiple) focusser.prop('disabled', true); //Focusser isn't needed if multiple + if ($select.multiple){ + scope.$watchCollection('$select.selected', function(newValue) { + ngModel.$setViewValue(newValue, null, true); //Third parameter (revalidate) is true, to force $parsers to recreate model + }); + 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){ From c727d3f1ce4d5258b612dcd61b4d56de6b5ed3f0 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Tue, 5 Aug 2014 03:01:15 -0500 Subject: [PATCH 06/18] Fix: when pressing a key on the input, the whole selected array is erased --- src/select.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/select.js b/src/select.js index 89a1b0479..23c522129 100644 --- a/src/select.js +++ b/src/select.js @@ -541,19 +541,19 @@ //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){ if ($select.multiple){ var resultMultiple = []; - for (var k = data.length - 1; k >= 0; k--) { - locals = {}; - locals[$select.parserResult.itemName] = data[k]; - result = $select.parserResult.modelMapper(scope, locals); - for (var j = inputValue.length - 1; j >= 0; j--) { - if (result == inputValue[j]){ - resultMultiple.push(data[k]); + for (var k = inputValue.length - 1; k >= 0; k--) { + for (var j = data.length - 1; j >= 0; j--) { + locals = {}; + locals[$select.parserResult.itemName] = data[j]; + result = $select.parserResult.modelMapper(scope, locals); + if (result == inputValue[k]){ + resultMultiple.unshift(data[j]); break; } } From 6cfc28b9e719b0bb666336dc859199116beb8483 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Tue, 5 Aug 2014 15:11:15 -0500 Subject: [PATCH 07/18] Prevent already selected items to show when searching --- src/select.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/select.js b/src/select.js index 23c522129..e7d7aedf0 100644 --- a/src/select.js +++ b/src/select.js @@ -193,8 +193,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 } From 57a5db6d77c34411d0ab677c73d164e6d7c1e332 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Mon, 11 Aug 2014 00:12:13 -0500 Subject: [PATCH 08/18] Add bootstrap template --- src/bootstrap/match-multiple.tpl.html | 13 +++++++++++++ src/bootstrap/select-multiple.tpl.html | 19 +++++++++++++++++++ src/select.css | 22 ++++++++++++++++++---- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/bootstrap/match-multiple.tpl.html create mode 100644 src/bootstrap/select-multiple.tpl.html diff --git a/src/bootstrap/match-multiple.tpl.html b/src/bootstrap/match-multiple.tpl.html new file mode 100644 index 000000000..fe28909ae --- /dev/null +++ b/src/bootstrap/match-multiple.tpl.html @@ -0,0 +1,13 @@ + + + + + \ 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..b391d06e1 --- /dev/null +++ b/src/bootstrap/select-multiple.tpl.html @@ -0,0 +1,19 @@ + 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; From 958b1530d5b5094cbbbce26e85597ed6b453dc35 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Wed, 3 Sep 2014 23:51:26 -0500 Subject: [PATCH 09/18] Improve margins between buttons at bootstrap theme --- src/bootstrap/match-multiple.tpl.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bootstrap/match-multiple.tpl.html b/src/bootstrap/match-multiple.tpl.html index fe28909ae..fc282e8ba 100644 --- a/src/bootstrap/match-multiple.tpl.html +++ b/src/bootstrap/match-multiple.tpl.html @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/src/select.js b/src/select.js index 6b246733f..b1606e58b 100644 --- a/src/select.js +++ b/src/select.js @@ -843,6 +843,7 @@ } }; }]) + // Recreates old behavior of ng-transclude. Used internally. .directive('uisTranscludeAppend', function () { return { link: function (scope, element, attrs, ctrl, transclude) { @@ -891,13 +892,5 @@ return function(matchItem, query) { return query && matchItem ? matchItem.replace(new RegExp(escapeRegexp(query), 'gi'), '$&') : matchItem; }; - }) - // Re-creates the old behavior of ng-transclude. Used internally. - .directive('transinject', function() { - return function(scope, element, attrs, ctrl, transcludeFn) { - transcludeFn(scope, function(clone) { - element.append(clone); - }); - }; }); }()); diff --git a/src/select2/match-multiple.tpl.html b/src/select2/match-multiple.tpl.html index 4adf18dcd..93b0ed040 100644 --- a/src/select2/match-multiple.tpl.html +++ b/src/select2/match-multiple.tpl.html @@ -6,7 +6,7 @@
  • - +
  • \ No newline at end of file From 1cb314b1da21246ddcd21dd0fdd1125b5e57230a Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 7 Sep 2014 19:45:34 -0500 Subject: [PATCH 12/18] Refactor/Clean code --- src/bootstrap/match-multiple.tpl.html | 4 +- src/bootstrap/select-multiple.tpl.html | 26 ++--- src/select.js | 155 ++++++++++--------------- src/select2/match-multiple.tpl.html | 4 +- src/select2/select-multiple.tpl.html | 2 +- 5 files changed, 78 insertions(+), 113 deletions(-) diff --git a/src/bootstrap/match-multiple.tpl.html b/src/bootstrap/match-multiple.tpl.html index 9e618d0e4..e2dd7a0f5 100644 --- a/src/bootstrap/match-multiple.tpl.html +++ b/src/bootstrap/match-multiple.tpl.html @@ -2,12 +2,12 @@ diff --git a/src/bootstrap/select-multiple.tpl.html b/src/bootstrap/select-multiple.tpl.html index b391d06e1..a7c3e1cf1 100644 --- a/src/bootstrap/select-multiple.tpl.html +++ b/src/bootstrap/select-multiple.tpl.html @@ -1,19 +1,17 @@ diff --git a/src/select.js b/src/select.js index b1606e58b..b0b65ef8c 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. * @@ -261,10 +304,8 @@ }); if(ctrl.multiple){ - if(!_itemInSelected(item)){ - ctrl.selected.push(item); - ctrl.sizeSearchInput(); - } + ctrl.selected.push(item); + ctrl.sizeSearchInput(); } else { ctrl.selected = item; } @@ -306,44 +347,29 @@ }, 0, false); }; - var Key = { - Enter: 13, - Tab: 9, - Up: 38, - Down: 40, - Left: 37, - Right: 39, - Backspace: 8, - Delete: 46, - Escape: 27 - }; - - Key.verticalMovement = [Key.Up,Key.Down]; - Key.horizontalMovement = [Key.Left,Key.Right,Key.Backspace,Key.Delete]; - function _handleDropDownSelection(key) { var processed = true; switch (key) { - case Key.Down: + 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: + 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.Tab: + case KEY.TAB: //TODO: Que hacemos en modo multiple? if (!ctrl.multiple) ctrl.select(ctrl.items[ctrl.activeIndex]); break; - case Key.Enter: + 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: @@ -364,19 +390,19 @@ prev = ctrl.activeMatchIndex-1, newIndex = curr; - if(caretPosition > 0 || (ctrl.search.length && key == Key.Right)) return false; + if(caretPosition > 0 || (ctrl.search.length && key == KEY.RIGHT)) return false; ctrl.close(); - function __getNewIndex(){ + function getNewActiveMatchIndex(){ switch(key){ - case Key.Left: + case KEY.LEFT: // Select previous/first item if(~ctrl.activeMatchIndex) return prev; // Select last item else return last; break; - case Key.Right: + case KEY.RIGHT: // Open drop-down if(!~ctrl.activeMatchIndex || curr === last){ ctrl.activate(); @@ -385,7 +411,7 @@ // Select next/last item else return next; break; - case Key.Backspace: + case KEY.BACKSPACE: // Remove selected item and select previous/first if(~ctrl.activeMatchIndex){ ctrl.removeChoice(curr); @@ -394,7 +420,7 @@ // Select last item else return last; break; - case Key.Delete: + case KEY.DELETE: // Remove selected item and select next item if(~ctrl.activeMatchIndex){ ctrl.removeChoice(ctrl.activeMatchIndex); @@ -404,7 +430,7 @@ } } - newIndex = __getNewIndex(); + newIndex = getNewActiveMatchIndex(); if(!ctrl.selected.length || newIndex === false) ctrl.activeMatchIndex = -1; else ctrl.activeMatchIndex = Math.min(last,Math.max(first,newIndex)); @@ -417,7 +443,7 @@ var key = e.which; - // if(~[Key.Escape,Key.Tab].indexOf(key)){ + // if(~[KEY.ESC,KEY.TAB].indexOf(key)){ // //TODO: SEGURO? // ctrl.close(); // } @@ -425,7 +451,7 @@ $scope.$apply(function() { var processed = false; - if(ctrl.multiple && ~Key.horizontalMovement.indexOf(key)){ + if(ctrl.multiple && KEY.isHorizontalMovement(key)){ processed = _handleMatchSelection(key); } @@ -433,7 +459,7 @@ processed = _handleDropDownSelection(key); } - if (processed && key != Key.Tab) { + if (processed && key != KEY.TAB) { //TODO Check si el tab selecciona aun correctamente //Crear test e.preventDefault(); @@ -441,7 +467,7 @@ } }); - if(~Key.verticalMovement.indexOf(key) && ctrl.items.length > 0){ + if(KEY.isVerticalMovement(key) && ctrl.items.length > 0){ _ensureHighlightVisible(); } @@ -454,16 +480,6 @@ }); }); - function _itemInSelected(item){ - var match = false; - angular.forEach(ctrl.selected,function(value){ - // We need to use angular.equals for when an initially selected item lacks $$hashKey and stuff. - if(angular.equals(item,value)) match = true; - }); - - return match; - } - 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 @@ -634,55 +650,6 @@ }); - //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; - } - }; - } diff --git a/src/select2/match-multiple.tpl.html b/src/select2/match-multiple.tpl.html index 93b0ed040..d4a2217e7 100644 --- a/src/select2/match-multiple.tpl.html +++ b/src/select2/match-multiple.tpl.html @@ -4,9 +4,9 @@ do not work: [class^="select2-choice"] --> -
  • - +
  • \ No newline at end of file diff --git a/src/select2/select-multiple.tpl.html b/src/select2/select-multiple.tpl.html index c19b3fb3c..b014aff99 100644 --- a/src/select2/select-multiple.tpl.html +++ b/src/select2/select-multiple.tpl.html @@ -1,4 +1,4 @@ -
      From 35c25845bc8e8beba526a111c23cadcacea5c7af Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 7 Sep 2014 19:46:15 -0500 Subject: [PATCH 13/18] Write tests --- test/select.spec.js | 332 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 330 insertions(+), 2 deletions(-) diff --git a/test/select.spec.js b/test/select.spec.js index 3897bbc8d..d24715662 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -3,13 +3,29 @@ 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 = {}; + + //TESTME + scope.selection.selectedMultiple = []; + scope.getGroupLabel = function(person) { return person.age % 2 ? 'even' : 'odd'; }; @@ -542,7 +558,7 @@ describe('ui-select tests', function() {
      \
      \ I should appear only once\ -
      \ + ®
    \ \ ' ); @@ -614,4 +630,316 @@ 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 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']; + + }); + + }); + + }); From 9d1bd79bf5ac279d3a75192a9de177ff91beb005 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 7 Sep 2014 21:42:06 -0500 Subject: [PATCH 14/18] Add demo --- examples/demo-multi-select.html | 168 ++++++++++++++++++++++++++++++++ examples/demo.js | 11 ++- 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 examples/demo-multi-select.html 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}; From 785c6a47b0e4bf84b96d8dfbf31fed6547513412 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 7 Sep 2014 23:15:48 -0500 Subject: [PATCH 15/18] Fix: match label shows correctly even if selected item isn't on items list anymore and using single property binding --- src/select.js | 15 ++++--- test/select.spec.js | 99 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/select.js b/src/select.js index b0b65ef8c..223826b6f 100644 --- a/src/select.js +++ b/src/select.js @@ -581,13 +581,18 @@ } return resultMultiple; }else{ - for (var i = data.length - 1; i >= 0; i--) { + var checkFn = function(d){ locals = {}; - locals[$select.parserResult.itemName] = data[i]; + locals[$select.parserResult.itemName] = d; result = $select.parserResult.modelMapper(scope, locals); - if (result == inputValue){ - return data[i]; - } + return result == inputValue; + }; + //If possible pass same object stored in $select.selected + if ($select.selected && checkFn($select.selected)) { + return $select.selected; + } + for (var i = data.length - 1; i >= 0; i--) { + if (checkFn(data[i])) return data[i]; } } } diff --git a/test/select.spec.js b/test/select.spec.js index d24715662..6e0975cc0 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -109,6 +109,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() { @@ -558,7 +564,7 @@ describe('ui-select tests', function() {
    \
    \ I should appear only once\ - ®
    \ + \ \ ' ); @@ -568,6 +574,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; From 172d3abc800082d31328d1f0faee5f3d8473f702 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Sun, 7 Sep 2014 23:50:30 -0500 Subject: [PATCH 16/18] Fix: refresh function, every new search removes items selected earlier --- src/select.js | 27 ++++++++++++++++----------- test/select.spec.js | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/select.js b/src/select.js index 223826b6f..9c8c56d3f 100644 --- a/src/select.js +++ b/src/select.js @@ -568,31 +568,36 @@ if (data){ if ($select.multiple){ var resultMultiple = []; - for (var k = inputValue.length - 1; k >= 0; k--) { - for (var j = data.length - 1; j >= 0; j--) { - locals = {}; - locals[$select.parserResult.itemName] = data[j]; + 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 == inputValue[k]){ - resultMultiple.unshift(data[j]); - break; + if (result == value){ + resultMultiple.unshift(list[p]); + return true; } } + return false; + }; + for (var k = inputValue.length - 1; k >= 0; k--) { + if (!checkFnMultiple($select.selected, inputValue[k])){ + checkFnMultiple(data, inputValue[k]); + } } return resultMultiple; }else{ - var checkFn = function(d){ - locals = {}; + 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 && checkFn($select.selected)) { + if ($select.selected && checkFnSingle($select.selected)) { return $select.selected; } for (var i = data.length - 1; i >= 0; i--) { - if (checkFn(data[i])) return data[i]; + if (checkFnSingle(data[i])) return data[i]; } } } diff --git a/test/select.spec.js b/test/select.spec.js index 6e0975cc0..8506eba5f 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -1036,6 +1036,47 @@ describe('ui-select tests', function() { }); + 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 "); + + }); + }); From 07484656c53f10044898a720a013c7403dcdce2d Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Mon, 8 Sep 2014 16:53:56 -0500 Subject: [PATCH 17/18] Add test to check $setViewValue on watch (using 3rd parameter to be be compatible with v1.3.0-beta+) --- src/select.js | 4 +++- test/select.spec.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/select.js b/src/select.js index 9c8c56d3f..6db3c7b24 100644 --- a/src/select.js +++ b/src/select.js @@ -681,7 +681,9 @@ if ($select.multiple){ scope.$watchCollection('$select.selected', function(newValue) { - ngModel.$setViewValue(newValue, null, true); //Third parameter (revalidate) is true, to force $parsers to recreate model + //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{ diff --git a/test/select.spec.js b/test/select.spec.js index 8506eba5f..77fc38e45 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -1077,6 +1077,35 @@ describe('ui-select tests', function() { }); + 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); + + }); + }); From 22f67aa5a63727a7f169afa9f5d04ab7b941fce8 Mon Sep 17 00:00:00 2001 From: Wladimir Coka Date: Mon, 8 Sep 2014 18:31:24 -0500 Subject: [PATCH 18/18] Sets ngModel as an empty array if it's undefined --- src/select.js | 1 + test/select.spec.js | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/select.js b/src/select.js index 6db3c7b24..1c532b599 100644 --- a/src/select.js +++ b/src/select.js @@ -580,6 +580,7 @@ } 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]); diff --git a/test/select.spec.js b/test/select.spec.js index 77fc38e45..77ecc6437 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -23,9 +23,6 @@ describe('ui-select tests', function() { $timeout = _$timeout_; scope.selection = {}; - //TESTME - scope.selection.selectedMultiple = []; - scope.getGroupLabel = function(person) { return person.age % 2 ? 'even' : 'odd'; }; @@ -755,6 +752,15 @@ describe('ui-select tests', function() { 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();