From 6c67485f3759d02fbf6af4010cf4acd59a6ed76c Mon Sep 17 00:00:00 2001 From: komakino Date: Sat, 31 May 2014 20:50:29 +0200 Subject: [PATCH 1/2] Added initial support for multiple choices. Still to do: - Selectize and Bootstrap templates - Write tests - Only tested in Chrome, so other browser tests needs to be done - Possibly find a way to remove items from drop down that are currently selected - A user provided compare function to determine if an initially selected item matches an item in ctrl.items. Currently uses angular.equals - more Other minor changes - Remove Tab key events; they were illogical and prevented ui-select from functioning correctly in a form --- examples/demo.html | 28 +++- examples/demo.js | 10 +- src/select.js | 216 ++++++++++++++++++++++----- src/select2/match-multiple.tpl.html | 10 ++ src/select2/select-multiple.tpl.html | 24 +++ 5 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 src/select2/match-multiple.tpl.html create mode 100644 src/select2/select-multiple.tpl.html diff --git a/examples/demo.html b/examples/demo.html index 006f7da53..1bef57bc8 100644 --- a/examples/demo.html +++ b/examples/demo.html @@ -63,7 +63,6 @@

Bootstrap theme

-

Selected: {{address.selected.formatted_address}}

Bootstrap theme
+

Selected: {{address.selected.formatted_address}}

Select2 theme

-

Selected: {{person.selected}}

{{$select.selected.name}} @@ -89,9 +88,9 @@

Select2 theme

+

Selected: {{person.selected}}

Selectize theme

-

Selected: {{country.selected}}

{{$select.selected.name}} @@ -99,5 +98,28 @@

Selectize theme

+

Selected: {{country.selected}}

+ +

Multi select with objects

+ + {{$item.name}} + +
+ + age: + +
+
+

Selected: {{friends|json}}

+ +

Multi select with strings

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

Selected: {{colors}}

+ diff --git a/examples/demo.js b/examples/demo.js index 46756f011..fee038402 100644 --- a/examples/demo.js +++ b/examples/demo.js @@ -54,9 +54,15 @@ app.controller('DemoCtrl', function($scope, $http) { $scope.person.selected = undefined; $scope.address.selected = undefined; $scope.country.selected = undefined; + $scope.friends = []; + $scope.colors = []; }; - $scope.person = {}; + $scope.availableColors = ['Red','Green','Blue','Yellow','Magenta','Maroon','Umbra','Turquoise']; + $scope.colors = ['Blue','Red']; + $scope.friends = [{ name: 'Samantha', email: 'samantha@email.com', age: 31 }]; + + $scope.person = {selected:{ name: 'Nicole', email: 'nicole@email.com', age: 43 }}; $scope.people = [ { name: 'Adam', email: 'adam@email.com', age: 10 }, { name: 'Amalie', email: 'amalie@email.com', age: 12 }, @@ -79,7 +85,7 @@ app.controller('DemoCtrl', function($scope, $http) { }); }; - $scope.country = {}; + $scope.country = {name: 'Afghanistan', code: 'AF'}; $scope.countries = [ // Taken from https://gist.github.com/unceus/6501985 {name: 'Afghanistan', code: 'AF'}, {name: 'Ă…land Islands', code: 'AX'}, diff --git a/src/select.js b/src/select.js index 44d09c2ca..0eadadc07 100644 --- a/src/select.js +++ b/src/select.js @@ -113,12 +113,14 @@ angular.module('ui.select', []) ctrl.placeholder = undefined; ctrl.search = EMPTY_SEARCH; ctrl.activeIndex = 0; + ctrl.activeMatchIndex = -1; ctrl.items = []; ctrl.selected = undefined; ctrl.open = false; ctrl.disabled = 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 var _searchInput = $element.querySelectorAll('input.ui-select-search'); if (_searchInput.length !== 1) { @@ -133,10 +135,11 @@ angular.module('ui.select', []) } // When the user clicks on ui-select, displays the dropdown list - ctrl.activate = function() { - if (!ctrl.disabled) { - _resetSearchInput(); + ctrl.activate = function(noReset) { + if (!ctrl.disabled && !ctrl.open) { + if(!noReset) _resetSearchInput(); ctrl.open = true; + ctrl.activeMatchIndex = -1; // Give it time to appear before focus $timeout(function() { @@ -192,9 +195,15 @@ angular.module('ui.select', []) // When the user clicks on an item inside the dropdown ctrl.select = function(item) { - 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 @@ -205,29 +214,61 @@ angular.module('ui.select', []) } }; + // Remove item from multiple select + ctrl.removeChoice = function(index){ + ctrl.selected.splice(index, 1); + ctrl.activeMatchIndex = -1; + ctrl.sizeSearchInput(); + } + + ctrl.getPlaceholder = function(){ + 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.activate(true); + else if (ctrl.activeIndex < ctrl.items.length - 1) { ctrl.activeIndex++; } break; case Key.Up: - if (ctrl.activeIndex > 0) { ctrl.activeIndex--; } + if (!ctrl.open) ctrl.activate(true); + else if (ctrl.activeIndex > 0) { ctrl.activeIndex--; } break; - case Key.Tab: case Key.Enter: - ctrl.select(ctrl.items[ctrl.activeIndex]); - break; - case Key.Escape: - ctrl.close(); + if(ctrl.open){ + ctrl.select(ctrl.items[ctrl.activeIndex]); + } else { + ctrl.activate(true); + } break; default: processed = false; @@ -235,30 +276,117 @@ angular.module('ui.select', []) 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; + case Key.Right: + // Open drop-down + if(!~ctrl.activeMatchIndex || curr === last){ + ctrl.activate(); + return false; + } + // Select next/last item + else return next; + case Key.Backspace: + // Remove selected item and select previous/first + if(~ctrl.activeMatchIndex){ + ctrl.removeChoice(curr); + return prev; + } + // Select last item + else return last; + 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.length > 0) { - var key = e.which; - - $scope.$apply(function() { - var processed = _onKeydown(key); - if (processed) { - e.preventDefault(); - e.stopPropagation(); - } - }); + var key = e.which; + + if(~[Key.Escape,Key.Tab].indexOf(key)){ + ctrl.close(); + } + + $scope.$apply(function() { + var processed = false; - switch (key) { - case Key.Down: - case Key.Up: - _ensureHighlightVisible(); - break; + if(ctrl.multiple && ~Key.horizontalMovement.indexOf(key)){ + processed = _handleMatchSelection(key); + } + + if (!processed && ctrl.items.length > 0) { + processed = _handleDropDownSelection(key); } + + if (processed) { + e.preventDefault(); + e.stopPropagation(); + } + }); + + if(~Key.verticalMovement.indexOf(key)){ + _ensureHighlightVisible(); } }); + _searchInput.on('blur', function(e) { + $timeout(function() { + ctrl.activeMatchIndex = -1; + ctrl.activeIndex = 0; + // ctrl.close(); + }); + }); + + 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'); @@ -279,7 +407,7 @@ angular.module('ui.select', []) } $scope.$on('$destroy', function() { - _searchInput.off('keydown'); + _searchInput.off('keydown blur'); }); }]) @@ -291,7 +419,7 @@ angular.module('ui.select', []) 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, @@ -305,6 +433,8 @@ angular.module('ui.select', []) var $select = ctrls[0]; var ngModel = ctrls[1]; + $select.multiple = angular.isDefined(attrs.multiple); + attrs.$observe('disabled', function() { // No need to use $eval() (thanks to ng-disabled) since we already get a boolean instead of a string $select.disabled = attrs.disabled !== undefined ? attrs.disabled : false; @@ -323,6 +453,18 @@ angular.module('ui.select', []) }); 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; }; @@ -405,7 +547,8 @@ angular.module('ui.select', []) return function link(scope, element, attrs, $select) { $select.parseRepeatAttr(attrs.repeat); - scope.$watch('$select.search', function() { + scope.$watch('$select.search', function(value,oldValue) { + if(value && !$select.open) $select.activate(true); $select.activeIndex = 0; $select.refresh(attrs.refresh); }); @@ -428,13 +571,18 @@ angular.module('ui.select', []) transclude: true, templateUrl: function(tElement) { // Gets theme attribute from parent (ui-select) + var multi = tElement.parent().attr('multiple'); var theme = tElement.parent().attr('theme') || uiSelectConfig.theme; - return theme + '/match.tpl.html'; + 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..7d71243df --- /dev/null +++ b/src/select2/match-multiple.tpl.html @@ -0,0 +1,10 @@ + + +
  • + +
  • +
    \ 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..64e6ba822 --- /dev/null +++ b/src/select2/select-multiple.tpl.html @@ -0,0 +1,24 @@ +
    + + +
    From ead84de40ec9de8140fc965a5d86cc65d021562e Mon Sep 17 00:00:00 2001 From: komakino Date: Sun, 1 Jun 2014 04:04:35 +0200 Subject: [PATCH 2/2] Added bootstrap theme --- examples/demo.html | 43 +++++++++++++------------- src/bootstrap/match-multiple.tpl.html | 13 ++++++++ src/bootstrap/select-multiple.tpl.html | 19 ++++++++++++ src/select.css | 21 ++++++++++--- src/select.js | 5 ++- src/select2/select-multiple.tpl.html | 2 ++ 6 files changed, 75 insertions(+), 28 deletions(-) create mode 100644 src/bootstrap/match-multiple.tpl.html create mode 100644 src/bootstrap/select-multiple.tpl.html diff --git a/examples/demo.html b/examples/demo.html index 1bef57bc8..3148e3d45 100644 --- a/examples/demo.html +++ b/examples/demo.html @@ -62,7 +62,7 @@ -

    Bootstrap theme

    +

    Bootstrap theme

    Bootstrap theme

    Selected: {{address.selected.formatted_address}}

    -

    Select2 theme

    - - {{$select.selected.name}} +

    Multi select

    + + {{$item.name}}
    - email: {{person.email}} age:
    -

    Selected: {{person.selected}}

    - -

    Selectize theme

    - - {{$select.selected.name}} - - - - - -

    Selected: {{country.selected}}

    +

    Selected: {{friends|json}}

    -

    Multi select with objects

    - - {{$item.name}} +

    Select2 theme

    + + {{$select.selected.name}}
    + email: {{person.email}} age:
    -

    Selected: {{friends|json}}

    +

    Selected: {{person.selected}}

    -

    Multi select with strings

    +

    Multi select

    {{$item}} @@ -121,5 +111,16 @@

    Multi select with strings

    Selected: {{colors}}

    +

    Selectize theme

    + + {{$select.selected.name}} + + + + + +

    Selected: {{country.selected}}

    + + diff --git a/src/bootstrap/match-multiple.tpl.html b/src/bootstrap/match-multiple.tpl.html new file mode 100644 index 000000000..d10c23e12 --- /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 b69832974..4faceeec2 100644 --- a/src/select.css +++ b/src/select.css @@ -45,13 +45,26 @@ 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; +} \ No newline at end of file diff --git a/src/select.js b/src/select.js index 0eadadc07..442e2a1a5 100644 --- a/src/select.js +++ b/src/select.js @@ -367,7 +367,6 @@ angular.module('ui.select', []) $timeout(function() { ctrl.activeMatchIndex = -1; ctrl.activeIndex = 0; - // ctrl.close(); }); }); @@ -450,7 +449,8 @@ angular.module('ui.select', []) if (ngModel.$viewValue !== newValue) { ngModel.$setViewValue(newValue); } - }); + if($select.multiple) $select.sizeSearchInput() + },$select.multiple); ngModel.$render = function() { if($select.multiple){ @@ -464,7 +464,6 @@ angular.module('ui.select', []) } } } - $select.selected = ngModel.$viewValue; }; diff --git a/src/select2/select-multiple.tpl.html b/src/select2/select-multiple.tpl.html index 64e6ba822..4f8a13e3a 100644 --- a/src/select2/select-multiple.tpl.html +++ b/src/select2/select-multiple.tpl.html @@ -12,6 +12,8 @@ spellcheck="false" class="select2-input ui-select-search" placeholder="{{$select.getPlaceholder()}}" + ng-disabled="$select.disabled" + ng-hide="$select.disabled" ng-model="$select.search" ng-click="$select.activate()" style="width: 34px;">