From 2ce5b8a1fa826a94f63460b55bad42d3ec10f081 Mon Sep 17 00:00:00 2001 From: "o.istomin" Date: Wed, 21 Jan 2015 15:16:30 +0300 Subject: [PATCH] add select as, new item mode --- CHANGELOG.md | 13 ++ README.md | 12 +- bower.json | 2 +- dist/multiselect-tpls.js | 251 ++++++++++++++------- dist/multiselect-tpls.min.js | 2 +- dist/multiselect.js | 251 ++++++++++++++------- dist/multiselect.min.js | 2 +- src/multiselect/directives.js | 186 +++++++++------ src/multiselect/docs/multiselect.demo.html | 107 +++++++++ src/multiselect/docs/multiselect.demo.js | 39 +++- src/multiselect/services.js | 69 +++++- 11 files changed, 696 insertions(+), 238 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bce2e6..08de10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ + +# 0.1.5 + +## Features + +- select `as` support + +- **oi-multiselect-options:** + - saveTrigger + - newItem + - newItemModel + - newItemFn + # 0.1.4 diff --git a/README.md b/README.md index 346c8ea..3063ddf 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ ## Features * API compatible with [Angular select](http://docs.angularjs.org/api/ng/directive/select) -* without jQuery and other dependencies +* Angular 1.2+ without jQuery and other dependencies * search options by substring (including the search query to the server) * use [Bootstrap](http://getbootstrap.com) styles (but you can use own styles) -* 16 KB minified +* 17 KB minified ## Demo @@ -57,7 +57,7 @@ Use `oi-multiselect` directive: ## Attributes * `ng-options` — see: [Angular select](http://docs.angularjs.org/api/ng/directive/select) * `ng-options="item for item in shopArrShort | limitTo: 3"` — filter input list - * `ng-options="item for item in shopArrFn($query)"` — generate input list (expects array/object or promise) + * `ng-options="item for item in shopArrFn($query, $querySelectAs)"` — generate input list (expects array/object or promise) * `ng-model` — chosen item/items * `ng-disabled` — specifies that a drop-down list should be disabled * `multiple` — specifies that multiple options can be selected at once @@ -70,4 +70,8 @@ Use `oi-multiselect` directive: * `searchFilter` — filter name for items in search field * `dropdownFilter` — filter name for items in dropdown * `listFilter` — filter name for items order in dropdown - * `saveLastQuery` — function which get `lastQuery` and `removedValue` and return string for input after element was removed (default: ''). + * `saveLastQuery` — function which get `lastQuery` and `removedValue` and return string for input after element was removed (default: '') + * `saveTrigger` — Trigger on which element is stored in the model. May be `enter`, `slash`, `tab`, `blur` (default: `enter`, `slash`) + * `newItem` — Mode of adding new items from query (default: false). May be `autocomplete` (priority save matches), `prompt` (priority save new item) + * `newItemModel` — New items model (default: model = query). `$query` value from model will be changed to query string. + * `newItemFn` — function which get query and return new item object or promise diff --git a/bower.json b/bower.json index 3083834..b766a30 100644 --- a/bower.json +++ b/bower.json @@ -3,7 +3,7 @@ "name": "https://github.com/tamtakoe" }, "name": "oi.multiselect", - "version": "0.1.4", + "version": "0.1.5", "main": ["./dist/multiselect-tpls.js"], "dependencies": { "angular": ">=1.2" diff --git a/dist/multiselect-tpls.js b/dist/multiselect-tpls.js index 71f0c6f..0289831 100644 --- a/dist/multiselect-tpls.js +++ b/dist/multiselect-tpls.js @@ -40,7 +40,9 @@ angular.module('oi.multiselect') searchFilter: 'oiMultiselectCloseIcon', dropdownFilter: 'oiMultiselectHighlight', listFilter: 'oiMultiselectAscSort', - saveLastQuery: null + saveLastQuery: null, + newItem: false, + saveTrigger: 'enter, slash' }, $get: function() { return { @@ -255,12 +257,69 @@ angular.module('oi.multiselect') return arr; } + //lodash _.isEqual + function isEqual(x, y) { + if ( x === y ) return true; + if ( ! ( x instanceof Object ) || ! ( y instanceof Object ) ) return false; + if ( x.constructor !== y.constructor ) return false; + + for ( var p in x ) { + if ( ! x.hasOwnProperty( p ) ) continue; + if ( ! y.hasOwnProperty( p ) ) return false; + if ( x[ p ] === y[ p ] ) continue; + if ( typeof( x[ p ] ) !== "object" ) return false; + if ( ! objectEquals( x[ p ], y[ p ] ) ) return false; + } + + for ( p in y ) { + if ( y.hasOwnProperty( p ) && ! x.hasOwnProperty( p ) ) return false; + } + return true; + } + + //lodash _.intersection + filter + callback + invert + function intersection(xArr, yArr, callback, xFilter, yFilter, invert) { + var i, j, n, filteredX, filteredY, out = invert ? [].concat(xArr) : []; + + callback = callback || function(xValue, yValue) { + return xValue === yValue; + }; + + for (i = 0, n = xArr.length; i < xArr.length; i++) { + filteredX = xFilter ? xFilter(xArr[i]) : xArr[i]; + + for (j = 0; j < yArr.length; j++) { + filteredY = yFilter ? yFilter(yArr[j]) : yArr[j]; + + if (callback(filteredX, filteredY, xArr, yArr, i, j)) { + invert ? out.splice(i + out.length - n, 1) : out.push(xArr[i]); + break; + } + } + } + return out; + } + + function getValue(valueName, item, scope, getter) { + var locals = {}; + + //'name.subname' -> {name: {subname: list}}' + valueName.split('.').reduce(function(previousValue, currentItem, index, arr) { + return previousValue[currentItem] = index < arr.length - 1 ? {} : item; + }, locals); + + return getter(scope, locals); + } + return { - copyWidth: copyWidth, - measureString: measureString, + copyWidth: copyWidth, + measureString: measureString, scrollActiveOption: scrollActiveOption, - groupsIsEmpty: groupsIsEmpty, - objToArr: objToArr + groupsIsEmpty: groupsIsEmpty, + objToArr: objToArr, + getValue: getValue, + isEqual: isEqual, + intersection: intersection } }]); angular.module('oi.multiselect') @@ -282,40 +341,45 @@ angular.module('oi.multiselect') throw new Error("Expected expression in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'"); } - var displayName = match[2] || match[1], - valueName = match[4] || match[6], - groupByName = match[3] || '', - trackByName = match[8] || displayName, - valueMatches = match[7].match(VALUES_REGEXP); + var selectAsName = / as /.test(match[0]) && match[1], //item.modelValue + displayName = match[2] || match[1], //item.label + valueName = match[4] || match[6], //item + groupByName = match[3] || '', //item.groupName + trackByName = match[8] || displayName, //item.id + valueMatches = match[7].match(VALUES_REGEXP); //collection - var valuesName = valueMatches[1], - filteredValuesName = valuesName + (valueMatches[3] || ''), - valuesFnName = valuesName + (valueMatches[2] || ''); + var valuesName = valueMatches[1], //collection + filteredValuesName = valuesName + (valueMatches[3] || ''), //collection | filter + valuesFnName = valuesName + (valueMatches[2] || ''); //collection() - var displayFn = $parse(displayName), + var selectAsFn = selectAsName && $parse(selectAsName), + displayFn = $parse(displayName), groupByFn = $parse(groupByName), filteredValuesFn = $parse(filteredValuesName), valuesFn = $parse(valuesFnName), trackByFn = $parse(trackByName); - var locals = {}, - timeoutPromise, + var timeoutPromise, lastQuery; var multiple = angular.isDefined(attrs.multiple), multipleLimit = Number(attrs.multipleLimit), placeholderFn = $interpolate(attrs.placeholder || ''), - optionsFn = $parse(attrs.oiMultiselectOptions), keyUpDownWerePressed = false, - matchesWereReset = false; + matchesWereReset = false, + optionsFn = $parse(attrs.oiMultiselectOptions); return function(scope, element, attrs, ctrl) { var inputElement = element.find('input'), listElement = angular.element(element[0].querySelector('.multiselect-dropdown')), placeholder = placeholderFn(scope), - options = angular.extend({}, oiMultiselect.options, optionsFn(scope)), + options = angular.extend({}, oiMultiselect.options, optionsFn(scope.$parent)), lastQueryFn = options.saveLastQuery ? $injector.get(options.saveLastQuery) : function() {return ''}; + options.newItemModelFn = function (query) { + return (optionsFn({$query: query}) || {}).newItemModel || query; + }; + if (angular.isDefined(attrs.autofocus)) { $timeout(function() { inputElement[0].focus(); @@ -333,11 +397,24 @@ angular.module('oi.multiselect') scope.$parent.$watch(attrs.ngModel, function(value) { adjustInput(); - if (multiple) { - scope.output = value; - } else { - scope.output = value ? [value] : []; + var output = value instanceof Array ? value : value ? [value]: [], + promise = $q.when(output); + + if (selectAsFn && value) { + promise = getMatches(null, value) + .then(function(collection) { + return oiUtils.intersection(collection, output, oiUtils.isEqual, selectAs); + }); + timeoutPromise = null; //`resetMatches` should not cancel the `promise` } + + promise.then(function(collection) { + scope.output = collection; + + if (collection.length !== output.length) { + scope.removeItem(collection.length); //if newItem was not created + } + }); }); scope.$watch('query', function(inputValue, oldValue) { @@ -393,17 +470,22 @@ angular.module('oi.multiselect') scope.addItem = function addItem(option) { lastQuery = scope.query; + //duplicate + if (oiUtils.intersection(scope.output, [option], null, getLabel, getLabel).length) return; + + //limit is reached if (!isNaN(multipleLimit) && scope.output.length >= multipleLimit) return; var optionGroup = scope.groups[getGroupName(option)]; + var modelOption = selectAsFn ? selectAs(option) : option; optionGroup.splice(optionGroup.indexOf(option), 1); if (multiple) { - ctrl.$setViewValue(angular.isArray(ctrl.$modelValue) ? ctrl.$modelValue.concat(option) : [option]); + ctrl.$setViewValue(angular.isArray(ctrl.$modelValue) ? ctrl.$modelValue.concat(modelOption) : [modelOption]); updateGroupPos(); } else { - ctrl.$setViewValue(option); + ctrl.$setViewValue(modelOption); resetMatches(); } @@ -450,17 +532,45 @@ angular.module('oi.multiselect') } }; + function saveOn(triggerName) { + var isTriggered = (new RegExp(triggerName)).test(options.saveTrigger), + isNewItem = options.newItem && scope.query, + isSelectedItem = angular.isNumber(scope.selectorPosition), + selectedOrder = scope.order[scope.selectorPosition], + newItemFn = options.newItemFn || options.newItemModelFn, + itemPromise = $q.reject(); + + if (isTriggered && (isNewItem || isSelectedItem && selectedOrder)) { + scope.showLoader = true; + itemPromise = $q.when(selectedOrder || newItemFn(scope.query)); + } + + itemPromise + .then(scope.addItem) + .finally(function() { + var bottom = scope.order.length - 1; + + if (scope.selectorPosition === bottom) { + setOption(listElement, 0); //TODO optimise when list will be closed + } + options.newItemFn && !isSelectedItem || $timeout(angular.noop); //TODO $applyAsync work since Angular 1.3 + resetMatches(); + }); + } + scope.keyParser = function keyParser(event) { var top = 0, bottom = scope.order.length - 1; switch (event.keyCode) { case 38: /* up */ + scope.selectorPosition = angular.isNumber(scope.selectorPosition) ? scope.selectorPosition : top; setOption(listElement, scope.selectorPosition === top ? bottom : scope.selectorPosition - 1); keyUpDownWerePressed = true; break; case 40: /* down */ + scope.selectorPosition = angular.isNumber(scope.selectorPosition) ? scope.selectorPosition : top - 1; setOption(listElement, scope.selectorPosition === bottom ? top : scope.selectorPosition + 1); keyUpDownWerePressed = true; if (!scope.query.length && !scope.isOpen) { @@ -473,13 +583,14 @@ angular.module('oi.multiselect') break; case 13: /* enter */ - //case 9: /* tab */ - if (!oiUtils.groupsIsEmpty(scope.groups)) { - scope.addItem(scope.order[scope.selectorPosition]); - if (scope.selectorPosition === bottom) { - setOption(listElement, 0); - } - } + saveOn('enter'); + break; + case 9: /* tab */ + saveOn('tab'); + break; + case 220: /* slash */ + saveOn('slash'); + event.preventDefault(); //backslash interpreted as a regexp break; case 27: /* esc */ @@ -533,7 +644,7 @@ angular.module('oi.multiselect') function blurHandler(event) { if (event.target.ownerDocument.activeElement !== inputElement[0]) { - resetMatches(); + saveOn('blur'); $document.off('click', blurHandler); scope.isFocused = false; scope.$digest(); @@ -548,39 +659,32 @@ angular.module('oi.multiselect') } function trackBy(item) { - locals = {}; - locals[valueName] = item; - return trackByFn(scope, locals); + return oiUtils.getValue(valueName, item, scope, trackByFn); } - function filter(list) { - locals = {}; - //'name.subname' -> {name: {subname: list}}' - valuesName.split('.').reduce(function(previousValue, currentItem, index, arr) { - return previousValue[currentItem] = index < arr.length - 1 ? {} : list; - }, locals); - return filteredValuesFn(scope.$parent, locals); + function selectAs(item) { + return oiUtils.getValue(valueName, item, scope, selectAsFn); } function getLabel(item) { - locals = {}; - locals[valueName] = item; - return displayFn(scope, locals); + return oiUtils.getValue(valueName, item, scope, displayFn); } function getGroupName(option) { - locals = {}; - locals[valueName] = option; - return groupByFn(scope, locals) || ''; + return oiUtils.getValue(valueName, option, scope, groupByFn) || ''; } - function getMatches(query) { - var values = valuesFn(scope.$parent, {$query: query}), + function filter(list) { + return oiUtils.getValue(valuesName, list, scope.$parent, filteredValuesFn); + } + + function getMatches(query, querySelectAs) { + var values = valuesFn(scope.$parent, {$query: query, $querySelectAs: querySelectAs}), waitTime = 0; - scope.selectorPosition = 0; + scope.selectorPosition = options.newItem === 'prompt' ? false : 0; - if (!query) { + if (!query && !querySelectAs) { scope.oldQuery = null; } @@ -591,14 +695,26 @@ angular.module('oi.multiselect') timeoutPromise = $timeout(function() { scope.showLoader = true; - $q.when(values).then(function(values) { - scope.groups = group(filter(removeChoosenFromList($filter(options.listFilter)(toArr(values), query, getLabel)))); - updateGroupPos(); - }).finally(function(){ - scope.showLoader = false; - }); + return $q.when(values) + .then(function(values) { + if (!querySelectAs) { + var filteredList = $filter(options.listFilter)(toArr(values), query, getLabel); + var withoutOverlap = oiUtils.intersection(filteredList, scope.output, oiUtils.isEqual, trackBy, trackBy, true); + var filteredOutput = filter(withoutOverlap); + + scope.groups = group(filteredOutput); + + updateGroupPos(); + } + return values; + }) + .finally(function(){ + scope.showLoader = false; + }); }, waitTime); + + return timeoutPromise; } function toArr(list) { @@ -607,23 +723,6 @@ angular.module('oi.multiselect') return [].concat(input); } - function removeChoosenFromList(input) { - var i, j, chosen = [].concat(scope.output); - - for (i = 0; i < input.length; i++) { - for (j = 0; j < chosen.length; j++) { - if (trackBy(input[i]) === trackBy(chosen[j])) { - input.splice(i, 1); - chosen.splice(j, 1); - i--; - break; - } - } - } - - return input; - } - function updateGroupPos() { var i, key, value, collectionKeys = [], groupCount = 0; diff --git a/dist/multiselect-tpls.min.js b/dist/multiselect-tpls.min.js index c2ceaa3..bcd313c 100644 --- a/dist/multiselect-tpls.min.js +++ b/dist/multiselect-tpls.min.js @@ -1 +1 @@ -angular.module("oi.multiselect",["template/multiselect/template.html"]),angular.module("template/multiselect/template.html",[]).run(["$templateCache",function(a){a.put("template/multiselect/template.html",'\n
\n \n
')}]),angular.module("oi.multiselect").provider("oiMultiselect",function(){return{options:{debounce:500,searchFilter:"oiMultiselectCloseIcon",dropdownFilter:"oiMultiselectHighlight",listFilter:"oiMultiselectAscSort",saveLastQuery:null},$get:function(){return{options:this.options}}}}).factory("oiUtils",["$document",function(a){function b(b,d){var e=angular.element("").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre",visibility:"hidden","z-index":-99999}).text(b||"");c(d,e,"letterSpacing fontSize fontFamily fontWeight textTransform".split(" ")),a[0].body.appendChild(e[0]);var f=e[0].offsetWidth;return e.remove(),f}function c(a,b,c){for(var d={},e=getComputedStyle(a[0],""),f=0,g=c.length;g>f;f++)d[c[f]]=e[c[f]];b.css(d)}function d(a,b){var c,d,e,g,i,j;b&&(d=a.offsetHeight,e=h(b,"height","margin"),g=a.scrollTop||0,c=f(b).top-f(a).top+g,i=c,j=c-d+e,c+e>d+g?a.scrollTop=j:g>c&&(a.scrollTop=i))}function e(a,b,c,d,e){function f(a){return parseFloat(e[a])}for(var g=c===(d?"border":"content")?4:"width"===b?1:0,h=0,i=["Top","Right","Bottom","Left"];4>g;g+=2)"margin"===c&&(h+=f(c+i[g])),d?("content"===c&&(h-=f("padding"+i[g])),"margin"!==c&&(h-=f("border"+i[g]+"Width"))):(h+=f("padding"+i[g]),"padding"!==c&&(h+=f("border"+i[g]+"Width")));return h}function f(a){var b,c,d=a.getBoundingClientRect(),e=a&&a.ownerDocument;if(e)return b=e.documentElement,c=g(e),{top:d.top+c.pageYOffset-b.clientTop,left:d.left+c.pageXOffset-b.clientLeft}}function g(a){return null!=a&&a===a.window?a:9===a.nodeType&&a.defaultView}function h(a,b,c){var d=!0,f="width"===b?a.offsetWidth:a.offsetHeight,g=window.getComputedStyle(a,null),h=!1;if(0>=f||null==f){if(f=g[b],(0>f||null==f)&&(f=a.style[b]),m.test(f))return f;f=parseFloat(f)||0}return f+e(a,b,c||(h?"border":"content"),d,g)}function i(a,b){b.css("width",h(a[0],"width","margin")+"px")}function j(a){for(var b in a)if(a.hasOwnProperty(b)&&a[b].length)return!1;return!0}function k(a){var b=[];return angular.forEach(a,function(a){b.push(a)}),b}var l=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,m=new RegExp("^("+l+")(?!px)[a-z%]+$","i");return{copyWidth:i,measureString:b,scrollActiveOption:d,groupsIsEmpty:j,objToArr:k}}]),angular.module("oi.multiselect").directive("oiMultiselect",["$document","$q","$timeout","$parse","$interpolate","$injector","$filter","oiUtils","oiMultiselect",function(a,b,c,d,e,f,g,h,i){var j=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/,k=/([^\(\)\s\|\s]*)\s*(\(.*\))?\s*(\|?\s*.+)?/;return{restrict:"AE",templateUrl:"template/multiselect/template.html",require:"ngModel",scope:{},compile:function(l,m){var n,o=m.ngOptions;if(!(n=o.match(j)))throw new Error("Expected expression in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'");var p,q,r=n[2]||n[1],s=n[4]||n[6],t=n[3]||"",u=n[8]||r,v=n[7].match(k),w=v[1],x=w+(v[3]||""),y=w+(v[2]||""),z=d(r),A=d(t),B=d(x),C=d(y),D=d(u),E={},F=angular.isDefined(m.multiple),G=Number(m.multipleLimit),H=e(m.placeholder||""),I=d(m.oiMultiselectOptions),J=!1,K=!1;return function(d,e,j,k){function l(b){b.target.ownerDocument.activeElement!==O[0]&&(L(),a.off("click",l),d.isFocused=!1,d.$digest())}function m(){var a=k.$modelValue&&k.$modelValue.length?"":Q;O.attr("placeholder",a),d.inputWidth=h.measureString(d.query||a,O)+4}function n(a){return E={},E[s]=a,D(d,E)}function o(a){return E={},w.split(".").reduce(function(b,c,d,e){return b[c]=d=G)){var b=d.groups[t(a)];b.splice(b.indexOf(a),1),F?(k.$setViewValue(angular.isArray(k.$modelValue)?k.$modelValue.concat(a):[a]),y()):(k.$setViewValue(a),L()),h.groupsIsEmpty(d.groups)&&(d.groups={}),d.oldQuery=d.oldQuery||d.query,d.query="",d.backspaceFocus=!1,m()}},d.removeItem=function(a){var b;j.disabled||(F?(b=k.$modelValue[a],k.$modelValue.splice(a,1),k.$setViewValue([].concat(k.$modelValue))):angular.isDefined(j.notempty)||(b=k.$modelValue,k.$setViewValue(void 0)),d.query=S(b,q),(d.isOpen||d.oldQuery||!F)&&u(d.oldQuery),m())},d.setSelection=function(a){J||d.selectorPosition===a?J=!1:M(P,a)},d.keyParser=function(a){var b=0,c=d.order.length-1;switch(a.keyCode){case 38:M(P,d.selectorPosition===b?c:d.selectorPosition-1),J=!0;break;case 40:M(P,d.selectorPosition===c?b:d.selectorPosition+1),J=!0,d.query.length||d.isOpen||u();break;case 37:case 39:break;case 13:h.groupsIsEmpty(d.groups)||(d.addItem(d.order[d.selectorPosition]),d.selectorPosition===c&&M(P,0));break;case 27:L();break;case 8:if(!d.query.length){if(d.backspaceFocus&&d.output&&(d.removeItem(d.output.length-1),!d.output.length)){u();break}d.backspaceFocus=!d.backspaceFocus;break}default:return d.backspaceFocus=!1,!1}},d.getSearchLabel=function(a){var b=r(a);return R.searchFilter&&(b=g(R.searchFilter)(b,d.oldQuery||d.query,a)),b},d.getDropdownLabel=function(a){var b=r(a);return R.dropdownFilter&&(b=g(R.dropdownFilter)(b,d.oldQuery||d.query,a)),b},F&&(k.$isEmpty=function(a){return!a||!a.length}),L()}}}}]),angular.module("oi.multiselect").filter("oiMultiselectCloseIcon",["$sce",function(a){return function(b){var c='×';return a.trustAsHtml(b+c)}}]).filter("oiMultiselectHighlight",["$sce",function(a){return function(b,c){var d;return c.length>0||angular.isNumber(c)?(b=b.toString(),c=c.toString(),d=b.replace(new RegExp(c,"gi"),"$&")):d=b,a.trustAsHtml(d)}}]).filter("oiMultiselectAscSort",function(){function a(a,b,c){var d,e,f=[],g=[],h=[];if(b){for(d=0;d\n
    \n
  • \n
  • \n
  • \n
\n\n
\n
    \n
    \n
  • \n
\n
')}]),angular.module("oi.multiselect").provider("oiMultiselect",function(){return{options:{debounce:500,searchFilter:"oiMultiselectCloseIcon",dropdownFilter:"oiMultiselectHighlight",listFilter:"oiMultiselectAscSort",saveLastQuery:null,newItem:!1,saveTrigger:"enter, slash"},$get:function(){return{options:this.options}}}}).factory("oiUtils",["$document",function(a){function b(b,d){var e=angular.element("").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre",visibility:"hidden","z-index":-99999}).text(b||"");c(d,e,"letterSpacing fontSize fontFamily fontWeight textTransform".split(" ")),a[0].body.appendChild(e[0]);var f=e[0].offsetWidth;return e.remove(),f}function c(a,b,c){for(var d={},e=getComputedStyle(a[0],""),f=0,g=c.length;g>f;f++)d[c[f]]=e[c[f]];b.css(d)}function d(a,b){var c,d,e,g,i,j;b&&(d=a.offsetHeight,e=h(b,"height","margin"),g=a.scrollTop||0,c=f(b).top-f(a).top+g,i=c,j=c-d+e,c+e>d+g?a.scrollTop=j:g>c&&(a.scrollTop=i))}function e(a,b,c,d,e){function f(a){return parseFloat(e[a])}for(var g=c===(d?"border":"content")?4:"width"===b?1:0,h=0,i=["Top","Right","Bottom","Left"];4>g;g+=2)"margin"===c&&(h+=f(c+i[g])),d?("content"===c&&(h-=f("padding"+i[g])),"margin"!==c&&(h-=f("border"+i[g]+"Width"))):(h+=f("padding"+i[g]),"padding"!==c&&(h+=f("border"+i[g]+"Width")));return h}function f(a){var b,c,d=a.getBoundingClientRect(),e=a&&a.ownerDocument;if(e)return b=e.documentElement,c=g(e),{top:d.top+c.pageYOffset-b.clientTop,left:d.left+c.pageXOffset-b.clientLeft}}function g(a){return null!=a&&a===a.window?a:9===a.nodeType&&a.defaultView}function h(a,b,c){var d=!0,f="width"===b?a.offsetWidth:a.offsetHeight,g=window.getComputedStyle(a,null),h=!1;if(0>=f||null==f){if(f=g[b],(0>f||null==f)&&(f=a.style[b]),p.test(f))return f;f=parseFloat(f)||0}return f+e(a,b,c||(h?"border":"content"),d,g)}function i(a,b){b.css("width",h(a[0],"width","margin")+"px")}function j(a){for(var b in a)if(a.hasOwnProperty(b)&&a[b].length)return!1;return!0}function k(a){var b=[];return angular.forEach(a,function(a){b.push(a)}),b}function l(a,b){if(a===b)return!0;if(!(a instanceof Object&&b instanceof Object))return!1;if(a.constructor!==b.constructor)return!1;for(var c in a)if(a.hasOwnProperty(c)){if(!b.hasOwnProperty(c))return!1;if(a[c]!==b[c]){if("object"!=typeof a[c])return!1;if(!objectEquals(a[c],b[c]))return!1}}for(c in b)if(b.hasOwnProperty(c)&&!a.hasOwnProperty(c))return!1;return!0}function m(a,b,c,d,e,f){var g,h,i,j,k,l=f?[].concat(a):[];for(c=c||function(a,b){return a===b},g=0,i=a.length;g=H)){var b=d.groups[u(a)],c=A?r(a):a;b.splice(b.indexOf(a),1),G?(k.$setViewValue(angular.isArray(k.$modelValue)?k.$modelValue.concat(c):[c]),z()):(k.$setViewValue(c),M()),h.groupsIsEmpty(d.groups)&&(d.groups={}),d.oldQuery=d.oldQuery||d.query,d.query="",d.backspaceFocus=!1,n()}},d.removeItem=function(a){var b;j.disabled||(G?(b=k.$modelValue[a],k.$modelValue.splice(a,1),k.$setViewValue([].concat(k.$modelValue))):angular.isDefined(j.notempty)||(b=k.$modelValue,k.$setViewValue(void 0)),d.query=T(b,q),(d.isOpen||d.oldQuery||!G)&&w(d.oldQuery),n())},d.setSelection=function(a){J||d.selectorPosition===a?J=!1:N(Q,a)},d.keyParser=function(a){var b=0,c=d.order.length-1;switch(a.keyCode){case 38:d.selectorPosition=angular.isNumber(d.selectorPosition)?d.selectorPosition:b,N(Q,d.selectorPosition===b?c:d.selectorPosition-1),J=!0;break;case 40:d.selectorPosition=angular.isNumber(d.selectorPosition)?d.selectorPosition:b-1,N(Q,d.selectorPosition===c?b:d.selectorPosition+1),J=!0,d.query.length||d.isOpen||w();break;case 37:case 39:break;case 13:l("enter");break;case 9:l("tab");break;case 220:l("slash"),a.preventDefault();break;case 27:M();break;case 8:if(!d.query.length){if(d.backspaceFocus&&d.output&&(d.removeItem(d.output.length-1),!d.output.length)){w();break}d.backspaceFocus=!d.backspaceFocus;break}default:return d.backspaceFocus=!1,!1}},d.getSearchLabel=function(a){var b=s(a);return S.searchFilter&&(b=g(S.searchFilter)(b,d.oldQuery||d.query,a)),b},d.getDropdownLabel=function(a){var b=s(a);return S.dropdownFilter&&(b=g(S.dropdownFilter)(b,d.oldQuery||d.query,a)),b},G&&(k.$isEmpty=function(a){return!a||!a.length}),M()}}}}]),angular.module("oi.multiselect").filter("oiMultiselectCloseIcon",["$sce",function(a){return function(b){var c='×';return a.trustAsHtml(b+c)}}]).filter("oiMultiselectHighlight",["$sce",function(a){return function(b,c){var d;return c.length>0||angular.isNumber(c)?(b=b.toString(),c=c.toString(),d=b.replace(new RegExp(c,"gi"),"$&")):d=b,a.trustAsHtml(d)}}]).filter("oiMultiselectAscSort",function(){function a(a,b,c){var d,e,f=[],g=[],h=[];if(b){for(d=0;d {name: {subname: list}}' + valueName.split('.').reduce(function(previousValue, currentItem, index, arr) { + return previousValue[currentItem] = index < arr.length - 1 ? {} : item; + }, locals); + + return getter(scope, locals); + } + return { - copyWidth: copyWidth, - measureString: measureString, + copyWidth: copyWidth, + measureString: measureString, scrollActiveOption: scrollActiveOption, - groupsIsEmpty: groupsIsEmpty, - objToArr: objToArr + groupsIsEmpty: groupsIsEmpty, + objToArr: objToArr, + getValue: getValue, + isEqual: isEqual, + intersection: intersection } }]); angular.module('oi.multiselect') @@ -250,40 +309,45 @@ angular.module('oi.multiselect') throw new Error("Expected expression in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'"); } - var displayName = match[2] || match[1], - valueName = match[4] || match[6], - groupByName = match[3] || '', - trackByName = match[8] || displayName, - valueMatches = match[7].match(VALUES_REGEXP); + var selectAsName = / as /.test(match[0]) && match[1], //item.modelValue + displayName = match[2] || match[1], //item.label + valueName = match[4] || match[6], //item + groupByName = match[3] || '', //item.groupName + trackByName = match[8] || displayName, //item.id + valueMatches = match[7].match(VALUES_REGEXP); //collection - var valuesName = valueMatches[1], - filteredValuesName = valuesName + (valueMatches[3] || ''), - valuesFnName = valuesName + (valueMatches[2] || ''); + var valuesName = valueMatches[1], //collection + filteredValuesName = valuesName + (valueMatches[3] || ''), //collection | filter + valuesFnName = valuesName + (valueMatches[2] || ''); //collection() - var displayFn = $parse(displayName), + var selectAsFn = selectAsName && $parse(selectAsName), + displayFn = $parse(displayName), groupByFn = $parse(groupByName), filteredValuesFn = $parse(filteredValuesName), valuesFn = $parse(valuesFnName), trackByFn = $parse(trackByName); - var locals = {}, - timeoutPromise, + var timeoutPromise, lastQuery; var multiple = angular.isDefined(attrs.multiple), multipleLimit = Number(attrs.multipleLimit), placeholderFn = $interpolate(attrs.placeholder || ''), - optionsFn = $parse(attrs.oiMultiselectOptions), keyUpDownWerePressed = false, - matchesWereReset = false; + matchesWereReset = false, + optionsFn = $parse(attrs.oiMultiselectOptions); return function(scope, element, attrs, ctrl) { var inputElement = element.find('input'), listElement = angular.element(element[0].querySelector('.multiselect-dropdown')), placeholder = placeholderFn(scope), - options = angular.extend({}, oiMultiselect.options, optionsFn(scope)), + options = angular.extend({}, oiMultiselect.options, optionsFn(scope.$parent)), lastQueryFn = options.saveLastQuery ? $injector.get(options.saveLastQuery) : function() {return ''}; + options.newItemModelFn = function (query) { + return (optionsFn({$query: query}) || {}).newItemModel || query; + }; + if (angular.isDefined(attrs.autofocus)) { $timeout(function() { inputElement[0].focus(); @@ -301,11 +365,24 @@ angular.module('oi.multiselect') scope.$parent.$watch(attrs.ngModel, function(value) { adjustInput(); - if (multiple) { - scope.output = value; - } else { - scope.output = value ? [value] : []; + var output = value instanceof Array ? value : value ? [value]: [], + promise = $q.when(output); + + if (selectAsFn && value) { + promise = getMatches(null, value) + .then(function(collection) { + return oiUtils.intersection(collection, output, oiUtils.isEqual, selectAs); + }); + timeoutPromise = null; //`resetMatches` should not cancel the `promise` } + + promise.then(function(collection) { + scope.output = collection; + + if (collection.length !== output.length) { + scope.removeItem(collection.length); //if newItem was not created + } + }); }); scope.$watch('query', function(inputValue, oldValue) { @@ -361,17 +438,22 @@ angular.module('oi.multiselect') scope.addItem = function addItem(option) { lastQuery = scope.query; + //duplicate + if (oiUtils.intersection(scope.output, [option], null, getLabel, getLabel).length) return; + + //limit is reached if (!isNaN(multipleLimit) && scope.output.length >= multipleLimit) return; var optionGroup = scope.groups[getGroupName(option)]; + var modelOption = selectAsFn ? selectAs(option) : option; optionGroup.splice(optionGroup.indexOf(option), 1); if (multiple) { - ctrl.$setViewValue(angular.isArray(ctrl.$modelValue) ? ctrl.$modelValue.concat(option) : [option]); + ctrl.$setViewValue(angular.isArray(ctrl.$modelValue) ? ctrl.$modelValue.concat(modelOption) : [modelOption]); updateGroupPos(); } else { - ctrl.$setViewValue(option); + ctrl.$setViewValue(modelOption); resetMatches(); } @@ -418,17 +500,45 @@ angular.module('oi.multiselect') } }; + function saveOn(triggerName) { + var isTriggered = (new RegExp(triggerName)).test(options.saveTrigger), + isNewItem = options.newItem && scope.query, + isSelectedItem = angular.isNumber(scope.selectorPosition), + selectedOrder = scope.order[scope.selectorPosition], + newItemFn = options.newItemFn || options.newItemModelFn, + itemPromise = $q.reject(); + + if (isTriggered && (isNewItem || isSelectedItem && selectedOrder)) { + scope.showLoader = true; + itemPromise = $q.when(selectedOrder || newItemFn(scope.query)); + } + + itemPromise + .then(scope.addItem) + .finally(function() { + var bottom = scope.order.length - 1; + + if (scope.selectorPosition === bottom) { + setOption(listElement, 0); //TODO optimise when list will be closed + } + options.newItemFn && !isSelectedItem || $timeout(angular.noop); //TODO $applyAsync work since Angular 1.3 + resetMatches(); + }); + } + scope.keyParser = function keyParser(event) { var top = 0, bottom = scope.order.length - 1; switch (event.keyCode) { case 38: /* up */ + scope.selectorPosition = angular.isNumber(scope.selectorPosition) ? scope.selectorPosition : top; setOption(listElement, scope.selectorPosition === top ? bottom : scope.selectorPosition - 1); keyUpDownWerePressed = true; break; case 40: /* down */ + scope.selectorPosition = angular.isNumber(scope.selectorPosition) ? scope.selectorPosition : top - 1; setOption(listElement, scope.selectorPosition === bottom ? top : scope.selectorPosition + 1); keyUpDownWerePressed = true; if (!scope.query.length && !scope.isOpen) { @@ -441,13 +551,14 @@ angular.module('oi.multiselect') break; case 13: /* enter */ - //case 9: /* tab */ - if (!oiUtils.groupsIsEmpty(scope.groups)) { - scope.addItem(scope.order[scope.selectorPosition]); - if (scope.selectorPosition === bottom) { - setOption(listElement, 0); - } - } + saveOn('enter'); + break; + case 9: /* tab */ + saveOn('tab'); + break; + case 220: /* slash */ + saveOn('slash'); + event.preventDefault(); //backslash interpreted as a regexp break; case 27: /* esc */ @@ -501,7 +612,7 @@ angular.module('oi.multiselect') function blurHandler(event) { if (event.target.ownerDocument.activeElement !== inputElement[0]) { - resetMatches(); + saveOn('blur'); $document.off('click', blurHandler); scope.isFocused = false; scope.$digest(); @@ -516,39 +627,32 @@ angular.module('oi.multiselect') } function trackBy(item) { - locals = {}; - locals[valueName] = item; - return trackByFn(scope, locals); + return oiUtils.getValue(valueName, item, scope, trackByFn); } - function filter(list) { - locals = {}; - //'name.subname' -> {name: {subname: list}}' - valuesName.split('.').reduce(function(previousValue, currentItem, index, arr) { - return previousValue[currentItem] = index < arr.length - 1 ? {} : list; - }, locals); - return filteredValuesFn(scope.$parent, locals); + function selectAs(item) { + return oiUtils.getValue(valueName, item, scope, selectAsFn); } function getLabel(item) { - locals = {}; - locals[valueName] = item; - return displayFn(scope, locals); + return oiUtils.getValue(valueName, item, scope, displayFn); } function getGroupName(option) { - locals = {}; - locals[valueName] = option; - return groupByFn(scope, locals) || ''; + return oiUtils.getValue(valueName, option, scope, groupByFn) || ''; } - function getMatches(query) { - var values = valuesFn(scope.$parent, {$query: query}), + function filter(list) { + return oiUtils.getValue(valuesName, list, scope.$parent, filteredValuesFn); + } + + function getMatches(query, querySelectAs) { + var values = valuesFn(scope.$parent, {$query: query, $querySelectAs: querySelectAs}), waitTime = 0; - scope.selectorPosition = 0; + scope.selectorPosition = options.newItem === 'prompt' ? false : 0; - if (!query) { + if (!query && !querySelectAs) { scope.oldQuery = null; } @@ -559,14 +663,26 @@ angular.module('oi.multiselect') timeoutPromise = $timeout(function() { scope.showLoader = true; - $q.when(values).then(function(values) { - scope.groups = group(filter(removeChoosenFromList($filter(options.listFilter)(toArr(values), query, getLabel)))); - updateGroupPos(); - }).finally(function(){ - scope.showLoader = false; - }); + return $q.when(values) + .then(function(values) { + if (!querySelectAs) { + var filteredList = $filter(options.listFilter)(toArr(values), query, getLabel); + var withoutOverlap = oiUtils.intersection(filteredList, scope.output, oiUtils.isEqual, trackBy, trackBy, true); + var filteredOutput = filter(withoutOverlap); + + scope.groups = group(filteredOutput); + + updateGroupPos(); + } + return values; + }) + .finally(function(){ + scope.showLoader = false; + }); }, waitTime); + + return timeoutPromise; } function toArr(list) { @@ -575,23 +691,6 @@ angular.module('oi.multiselect') return [].concat(input); } - function removeChoosenFromList(input) { - var i, j, chosen = [].concat(scope.output); - - for (i = 0; i < input.length; i++) { - for (j = 0; j < chosen.length; j++) { - if (trackBy(input[i]) === trackBy(chosen[j])) { - input.splice(i, 1); - chosen.splice(j, 1); - i--; - break; - } - } - } - - return input; - } - function updateGroupPos() { var i, key, value, collectionKeys = [], groupCount = 0; diff --git a/dist/multiselect.min.js b/dist/multiselect.min.js index edb0c9d..721ff74 100644 --- a/dist/multiselect.min.js +++ b/dist/multiselect.min.js @@ -1 +1 @@ -angular.module("oi.multiselect",[]),angular.module("oi.multiselect").provider("oiMultiselect",function(){return{options:{debounce:500,searchFilter:"oiMultiselectCloseIcon",dropdownFilter:"oiMultiselectHighlight",listFilter:"oiMultiselectAscSort",saveLastQuery:null},$get:function(){return{options:this.options}}}}).factory("oiUtils",["$document",function(a){function b(b,d){var e=angular.element("").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre",visibility:"hidden","z-index":-99999}).text(b||"");c(d,e,"letterSpacing fontSize fontFamily fontWeight textTransform".split(" ")),a[0].body.appendChild(e[0]);var f=e[0].offsetWidth;return e.remove(),f}function c(a,b,c){for(var d={},e=getComputedStyle(a[0],""),f=0,g=c.length;g>f;f++)d[c[f]]=e[c[f]];b.css(d)}function d(a,b){var c,d,e,g,i,j;b&&(d=a.offsetHeight,e=h(b,"height","margin"),g=a.scrollTop||0,c=f(b).top-f(a).top+g,i=c,j=c-d+e,c+e>d+g?a.scrollTop=j:g>c&&(a.scrollTop=i))}function e(a,b,c,d,e){function f(a){return parseFloat(e[a])}for(var g=c===(d?"border":"content")?4:"width"===b?1:0,h=0,i=["Top","Right","Bottom","Left"];4>g;g+=2)"margin"===c&&(h+=f(c+i[g])),d?("content"===c&&(h-=f("padding"+i[g])),"margin"!==c&&(h-=f("border"+i[g]+"Width"))):(h+=f("padding"+i[g]),"padding"!==c&&(h+=f("border"+i[g]+"Width")));return h}function f(a){var b,c,d=a.getBoundingClientRect(),e=a&&a.ownerDocument;if(e)return b=e.documentElement,c=g(e),{top:d.top+c.pageYOffset-b.clientTop,left:d.left+c.pageXOffset-b.clientLeft}}function g(a){return null!=a&&a===a.window?a:9===a.nodeType&&a.defaultView}function h(a,b,c){var d=!0,f="width"===b?a.offsetWidth:a.offsetHeight,g=window.getComputedStyle(a,null),h=!1;if(0>=f||null==f){if(f=g[b],(0>f||null==f)&&(f=a.style[b]),m.test(f))return f;f=parseFloat(f)||0}return f+e(a,b,c||(h?"border":"content"),d,g)}function i(a,b){b.css("width",h(a[0],"width","margin")+"px")}function j(a){for(var b in a)if(a.hasOwnProperty(b)&&a[b].length)return!1;return!0}function k(a){var b=[];return angular.forEach(a,function(a){b.push(a)}),b}var l=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,m=new RegExp("^("+l+")(?!px)[a-z%]+$","i");return{copyWidth:i,measureString:b,scrollActiveOption:d,groupsIsEmpty:j,objToArr:k}}]),angular.module("oi.multiselect").directive("oiMultiselect",["$document","$q","$timeout","$parse","$interpolate","$injector","$filter","oiUtils","oiMultiselect",function(a,b,c,d,e,f,g,h,i){var j=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/,k=/([^\(\)\s\|\s]*)\s*(\(.*\))?\s*(\|?\s*.+)?/;return{restrict:"AE",templateUrl:"template/multiselect/template.html",require:"ngModel",scope:{},compile:function(l,m){var n,o=m.ngOptions;if(!(n=o.match(j)))throw new Error("Expected expression in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'");var p,q,r=n[2]||n[1],s=n[4]||n[6],t=n[3]||"",u=n[8]||r,v=n[7].match(k),w=v[1],x=w+(v[3]||""),y=w+(v[2]||""),z=d(r),A=d(t),B=d(x),C=d(y),D=d(u),E={},F=angular.isDefined(m.multiple),G=Number(m.multipleLimit),H=e(m.placeholder||""),I=d(m.oiMultiselectOptions),J=!1,K=!1;return function(d,e,j,k){function l(b){b.target.ownerDocument.activeElement!==O[0]&&(L(),a.off("click",l),d.isFocused=!1,d.$digest())}function m(){var a=k.$modelValue&&k.$modelValue.length?"":Q;O.attr("placeholder",a),d.inputWidth=h.measureString(d.query||a,O)+4}function n(a){return E={},E[s]=a,D(d,E)}function o(a){return E={},w.split(".").reduce(function(b,c,d,e){return b[c]=d=G)){var b=d.groups[t(a)];b.splice(b.indexOf(a),1),F?(k.$setViewValue(angular.isArray(k.$modelValue)?k.$modelValue.concat(a):[a]),y()):(k.$setViewValue(a),L()),h.groupsIsEmpty(d.groups)&&(d.groups={}),d.oldQuery=d.oldQuery||d.query,d.query="",d.backspaceFocus=!1,m()}},d.removeItem=function(a){var b;j.disabled||(F?(b=k.$modelValue[a],k.$modelValue.splice(a,1),k.$setViewValue([].concat(k.$modelValue))):angular.isDefined(j.notempty)||(b=k.$modelValue,k.$setViewValue(void 0)),d.query=S(b,q),(d.isOpen||d.oldQuery||!F)&&u(d.oldQuery),m())},d.setSelection=function(a){J||d.selectorPosition===a?J=!1:M(P,a)},d.keyParser=function(a){var b=0,c=d.order.length-1;switch(a.keyCode){case 38:M(P,d.selectorPosition===b?c:d.selectorPosition-1),J=!0;break;case 40:M(P,d.selectorPosition===c?b:d.selectorPosition+1),J=!0,d.query.length||d.isOpen||u();break;case 37:case 39:break;case 13:h.groupsIsEmpty(d.groups)||(d.addItem(d.order[d.selectorPosition]),d.selectorPosition===c&&M(P,0));break;case 27:L();break;case 8:if(!d.query.length){if(d.backspaceFocus&&d.output&&(d.removeItem(d.output.length-1),!d.output.length)){u();break}d.backspaceFocus=!d.backspaceFocus;break}default:return d.backspaceFocus=!1,!1}},d.getSearchLabel=function(a){var b=r(a);return R.searchFilter&&(b=g(R.searchFilter)(b,d.oldQuery||d.query,a)),b},d.getDropdownLabel=function(a){var b=r(a);return R.dropdownFilter&&(b=g(R.dropdownFilter)(b,d.oldQuery||d.query,a)),b},F&&(k.$isEmpty=function(a){return!a||!a.length}),L()}}}}]),angular.module("oi.multiselect").filter("oiMultiselectCloseIcon",["$sce",function(a){return function(b){var c='×';return a.trustAsHtml(b+c)}}]).filter("oiMultiselectHighlight",["$sce",function(a){return function(b,c){var d;return c.length>0||angular.isNumber(c)?(b=b.toString(),c=c.toString(),d=b.replace(new RegExp(c,"gi"),"$&")):d=b,a.trustAsHtml(d)}}]).filter("oiMultiselectAscSort",function(){function a(a,b,c){var d,e,f=[],g=[],h=[];if(b){for(d=0;d").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre",visibility:"hidden","z-index":-99999}).text(b||"");c(d,e,"letterSpacing fontSize fontFamily fontWeight textTransform".split(" ")),a[0].body.appendChild(e[0]);var f=e[0].offsetWidth;return e.remove(),f}function c(a,b,c){for(var d={},e=getComputedStyle(a[0],""),f=0,g=c.length;g>f;f++)d[c[f]]=e[c[f]];b.css(d)}function d(a,b){var c,d,e,g,i,j;b&&(d=a.offsetHeight,e=h(b,"height","margin"),g=a.scrollTop||0,c=f(b).top-f(a).top+g,i=c,j=c-d+e,c+e>d+g?a.scrollTop=j:g>c&&(a.scrollTop=i))}function e(a,b,c,d,e){function f(a){return parseFloat(e[a])}for(var g=c===(d?"border":"content")?4:"width"===b?1:0,h=0,i=["Top","Right","Bottom","Left"];4>g;g+=2)"margin"===c&&(h+=f(c+i[g])),d?("content"===c&&(h-=f("padding"+i[g])),"margin"!==c&&(h-=f("border"+i[g]+"Width"))):(h+=f("padding"+i[g]),"padding"!==c&&(h+=f("border"+i[g]+"Width")));return h}function f(a){var b,c,d=a.getBoundingClientRect(),e=a&&a.ownerDocument;if(e)return b=e.documentElement,c=g(e),{top:d.top+c.pageYOffset-b.clientTop,left:d.left+c.pageXOffset-b.clientLeft}}function g(a){return null!=a&&a===a.window?a:9===a.nodeType&&a.defaultView}function h(a,b,c){var d=!0,f="width"===b?a.offsetWidth:a.offsetHeight,g=window.getComputedStyle(a,null),h=!1;if(0>=f||null==f){if(f=g[b],(0>f||null==f)&&(f=a.style[b]),p.test(f))return f;f=parseFloat(f)||0}return f+e(a,b,c||(h?"border":"content"),d,g)}function i(a,b){b.css("width",h(a[0],"width","margin")+"px")}function j(a){for(var b in a)if(a.hasOwnProperty(b)&&a[b].length)return!1;return!0}function k(a){var b=[];return angular.forEach(a,function(a){b.push(a)}),b}function l(a,b){if(a===b)return!0;if(!(a instanceof Object&&b instanceof Object))return!1;if(a.constructor!==b.constructor)return!1;for(var c in a)if(a.hasOwnProperty(c)){if(!b.hasOwnProperty(c))return!1;if(a[c]!==b[c]){if("object"!=typeof a[c])return!1;if(!objectEquals(a[c],b[c]))return!1}}for(c in b)if(b.hasOwnProperty(c)&&!a.hasOwnProperty(c))return!1;return!0}function m(a,b,c,d,e,f){var g,h,i,j,k,l=f?[].concat(a):[];for(c=c||function(a,b){return a===b},g=0,i=a.length;g=H)){var b=d.groups[u(a)],c=A?r(a):a;b.splice(b.indexOf(a),1),G?(k.$setViewValue(angular.isArray(k.$modelValue)?k.$modelValue.concat(c):[c]),z()):(k.$setViewValue(c),M()),h.groupsIsEmpty(d.groups)&&(d.groups={}),d.oldQuery=d.oldQuery||d.query,d.query="",d.backspaceFocus=!1,n()}},d.removeItem=function(a){var b;j.disabled||(G?(b=k.$modelValue[a],k.$modelValue.splice(a,1),k.$setViewValue([].concat(k.$modelValue))):angular.isDefined(j.notempty)||(b=k.$modelValue,k.$setViewValue(void 0)),d.query=T(b,q),(d.isOpen||d.oldQuery||!G)&&w(d.oldQuery),n())},d.setSelection=function(a){J||d.selectorPosition===a?J=!1:N(Q,a)},d.keyParser=function(a){var b=0,c=d.order.length-1;switch(a.keyCode){case 38:d.selectorPosition=angular.isNumber(d.selectorPosition)?d.selectorPosition:b,N(Q,d.selectorPosition===b?c:d.selectorPosition-1),J=!0;break;case 40:d.selectorPosition=angular.isNumber(d.selectorPosition)?d.selectorPosition:b-1,N(Q,d.selectorPosition===c?b:d.selectorPosition+1),J=!0,d.query.length||d.isOpen||w();break;case 37:case 39:break;case 13:l("enter");break;case 9:l("tab");break;case 220:l("slash"),a.preventDefault();break;case 27:M();break;case 8:if(!d.query.length){if(d.backspaceFocus&&d.output&&(d.removeItem(d.output.length-1),!d.output.length)){w();break}d.backspaceFocus=!d.backspaceFocus;break}default:return d.backspaceFocus=!1,!1}},d.getSearchLabel=function(a){var b=s(a);return S.searchFilter&&(b=g(S.searchFilter)(b,d.oldQuery||d.query,a)),b},d.getDropdownLabel=function(a){var b=s(a);return S.dropdownFilter&&(b=g(S.dropdownFilter)(b,d.oldQuery||d.query,a)),b},G&&(k.$isEmpty=function(a){return!a||!a.length}),M()}}}}]),angular.module("oi.multiselect").filter("oiMultiselectCloseIcon",["$sce",function(a){return function(b){var c='×';return a.trustAsHtml(b+c)}}]).filter("oiMultiselectHighlight",["$sce",function(a){return function(b,c){var d;return c.length>0||angular.isNumber(c)?(b=b.toString(),c=c.toString(),d=b.replace(new RegExp(c,"gi"),"$&")):d=b,a.trustAsHtml(d)}}]).filter("oiMultiselectAscSort",function(){function a(a,b,c){var d,e,f=[],g=[],h=[];if(b){for(d=0;d= multipleLimit) return; var optionGroup = scope.groups[getGroupName(option)]; + var modelOption = selectAsFn ? selectAs(option) : option; optionGroup.splice(optionGroup.indexOf(option), 1); if (multiple) { - ctrl.$setViewValue(angular.isArray(ctrl.$modelValue) ? ctrl.$modelValue.concat(option) : [option]); + ctrl.$setViewValue(angular.isArray(ctrl.$modelValue) ? ctrl.$modelValue.concat(modelOption) : [modelOption]); updateGroupPos(); } else { - ctrl.$setViewValue(option); + ctrl.$setViewValue(modelOption); resetMatches(); } @@ -185,17 +208,45 @@ angular.module('oi.multiselect') } }; + function saveOn(triggerName) { + var isTriggered = (new RegExp(triggerName)).test(options.saveTrigger), + isNewItem = options.newItem && scope.query, + isSelectedItem = angular.isNumber(scope.selectorPosition), + selectedOrder = scope.order[scope.selectorPosition], + newItemFn = options.newItemFn || options.newItemModelFn, + itemPromise = $q.reject(); + + if (isTriggered && (isNewItem || isSelectedItem && selectedOrder)) { + scope.showLoader = true; + itemPromise = $q.when(selectedOrder || newItemFn(scope.query)); + } + + itemPromise + .then(scope.addItem) + .finally(function() { + var bottom = scope.order.length - 1; + + if (scope.selectorPosition === bottom) { + setOption(listElement, 0); //TODO optimise when list will be closed + } + options.newItemFn && !isSelectedItem || $timeout(angular.noop); //TODO $applyAsync work since Angular 1.3 + resetMatches(); + }); + } + scope.keyParser = function keyParser(event) { var top = 0, bottom = scope.order.length - 1; switch (event.keyCode) { case 38: /* up */ + scope.selectorPosition = angular.isNumber(scope.selectorPosition) ? scope.selectorPosition : top; setOption(listElement, scope.selectorPosition === top ? bottom : scope.selectorPosition - 1); keyUpDownWerePressed = true; break; case 40: /* down */ + scope.selectorPosition = angular.isNumber(scope.selectorPosition) ? scope.selectorPosition : top - 1; setOption(listElement, scope.selectorPosition === bottom ? top : scope.selectorPosition + 1); keyUpDownWerePressed = true; if (!scope.query.length && !scope.isOpen) { @@ -208,13 +259,14 @@ angular.module('oi.multiselect') break; case 13: /* enter */ - //case 9: /* tab */ - if (!oiUtils.groupsIsEmpty(scope.groups)) { - scope.addItem(scope.order[scope.selectorPosition]); - if (scope.selectorPosition === bottom) { - setOption(listElement, 0); - } - } + saveOn('enter'); + break; + case 9: /* tab */ + saveOn('tab'); + break; + case 220: /* slash */ + saveOn('slash'); + event.preventDefault(); //backslash interpreted as a regexp break; case 27: /* esc */ @@ -268,7 +320,7 @@ angular.module('oi.multiselect') function blurHandler(event) { if (event.target.ownerDocument.activeElement !== inputElement[0]) { - resetMatches(); + saveOn('blur'); $document.off('click', blurHandler); scope.isFocused = false; scope.$digest(); @@ -283,39 +335,32 @@ angular.module('oi.multiselect') } function trackBy(item) { - locals = {}; - locals[valueName] = item; - return trackByFn(scope, locals); + return oiUtils.getValue(valueName, item, scope, trackByFn); } - function filter(list) { - locals = {}; - //'name.subname' -> {name: {subname: list}}' - valuesName.split('.').reduce(function(previousValue, currentItem, index, arr) { - return previousValue[currentItem] = index < arr.length - 1 ? {} : list; - }, locals); - return filteredValuesFn(scope.$parent, locals); + function selectAs(item) { + return oiUtils.getValue(valueName, item, scope, selectAsFn); } function getLabel(item) { - locals = {}; - locals[valueName] = item; - return displayFn(scope, locals); + return oiUtils.getValue(valueName, item, scope, displayFn); } function getGroupName(option) { - locals = {}; - locals[valueName] = option; - return groupByFn(scope, locals) || ''; + return oiUtils.getValue(valueName, option, scope, groupByFn) || ''; } - function getMatches(query) { - var values = valuesFn(scope.$parent, {$query: query}), + function filter(list) { + return oiUtils.getValue(valuesName, list, scope.$parent, filteredValuesFn); + } + + function getMatches(query, querySelectAs) { + var values = valuesFn(scope.$parent, {$query: query, $querySelectAs: querySelectAs}), waitTime = 0; - scope.selectorPosition = 0; + scope.selectorPosition = options.newItem === 'prompt' ? false : 0; - if (!query) { + if (!query && !querySelectAs) { scope.oldQuery = null; } @@ -326,14 +371,26 @@ angular.module('oi.multiselect') timeoutPromise = $timeout(function() { scope.showLoader = true; - $q.when(values).then(function(values) { - scope.groups = group(filter(removeChoosenFromList($filter(options.listFilter)(toArr(values), query, getLabel)))); - updateGroupPos(); - }).finally(function(){ - scope.showLoader = false; - }); + return $q.when(values) + .then(function(values) { + if (!querySelectAs) { + var filteredList = $filter(options.listFilter)(toArr(values), query, getLabel); + var withoutOverlap = oiUtils.intersection(filteredList, scope.output, oiUtils.isEqual, trackBy, trackBy, true); + var filteredOutput = filter(withoutOverlap); + + scope.groups = group(filteredOutput); + + updateGroupPos(); + } + return values; + }) + .finally(function(){ + scope.showLoader = false; + }); }, waitTime); + + return timeoutPromise; } function toArr(list) { @@ -342,23 +399,6 @@ angular.module('oi.multiselect') return [].concat(input); } - function removeChoosenFromList(input) { - var i, j, chosen = [].concat(scope.output); - - for (i = 0; i < input.length; i++) { - for (j = 0; j < chosen.length; j++) { - if (trackBy(input[i]) === trackBy(chosen[j])) { - input.splice(i, 1); - chosen.splice(j, 1); - i--; - break; - } - } - } - - return input; - } - function updateGroupPos() { var i, key, value, collectionKeys = [], groupCount = 0; diff --git a/src/multiselect/docs/multiselect.demo.html b/src/multiselect/docs/multiselect.demo.html index 1ce76cd..fdb8310 100644 --- a/src/multiselect/docs/multiselect.demo.html +++ b/src/multiselect/docs/multiselect.demo.html @@ -226,6 +226,113 @@

Multiple limit

+

Autoselect

+
+
+ +
+
+
+
+ng-options="item.name for (key, item) in shopObj" +ng-model="bundle13" +oi-multiselect-options="{ + newItem: 'autoselect', + newItemModel: {id: null, name: $query, category: 'shoes'}, + saveTrigger: 'enter, blur' +}" +
+
+{{bundle13}}
+
+
+ +

Prompt

+
+
+ +
+
+
+
+ng-options="item.name for (key, item) in shopObj" +ng-model="bundle14" +oi-multiselect-options="{ + newItem: 'prompt', + newItemModel: {id: null, name: $query, category: 'shoes'}, + saveTrigger: 'enter, blur' +}" +
+
+{{bundle14}}
+
+
+ + +

Select as (lazy loading case)

+
+
+ +
+
+
+
+
+ng-options="item.id as item.name for item in shopArrFn($query, $querySelectAs)" +ng-model="bundle15" +multiple +oi-multiselect-options="{ + newItem: 'prompt', + newItemFn: addItem, +}" +
+
+$scope.shopArrFn = function(query, querySelectAs) { + if (querySelectAs) { + return getOptionsById(querySelectAs); + } else { + return findOptions(query); + } +}; +
+
+$scope.addItem = function(query) { + return createOption(query); +}; +
+
+
+{{bundle15}}
+
+
+

Customization

diff --git a/src/multiselect/docs/multiselect.demo.js b/src/multiselect/docs/multiselect.demo.js index 1f533f2..d63d689 100644 --- a/src/multiselect/docs/multiselect.demo.js +++ b/src/multiselect/docs/multiselect.demo.js @@ -18,12 +18,43 @@ angular.module('multiselectDemo', ['oi.multiselect', 'hljs']) }) ]).then(function() { - $scope.shopArrFn = function(query) { + var newItem = { + id: 0, + name: "SavedItem", + category: "shoes" + }; + + function findOptions(query) { var deferred = $q.defer(); $timeout(function() { deferred.resolve($scope.shopArr); }, 1000); return deferred.promise; + } + + function getOptionsById(querySelectAs) { + var deferred = $q.defer(); + $timeout(function() { + deferred.resolve($scope.shopArr.concat(newItem)); + }, 1000); + return deferred.promise; + } + + $scope.shopArrFn = function(query, querySelectAs) { + if (querySelectAs) { + return getOptionsById(querySelectAs); + + } else { + return findOptions(query); + } + }; + + $scope.addItem = function(query) { + var deferred = $q.defer(); + $timeout(function() { + deferred.resolve(newItem); + }, 1000); + return deferred.promise; }; $scope.bundle1 = [{ @@ -72,6 +103,12 @@ angular.module('multiselectDemo', ['oi.multiselect', 'hljs']) "name": "shoes", "category": "shoes" }]; + + $scope.bundle13 = $scope.shopObj[5]; + + $scope.bundle14 = $scope.shopObj[5]; + + $scope.bundle15 = [2,3,4]; }); $scope.scrollTo = function(id) { diff --git a/src/multiselect/services.js b/src/multiselect/services.js index a86c1cc..1b7a369 100644 --- a/src/multiselect/services.js +++ b/src/multiselect/services.js @@ -7,7 +7,9 @@ angular.module('oi.multiselect') searchFilter: 'oiMultiselectCloseIcon', dropdownFilter: 'oiMultiselectHighlight', listFilter: 'oiMultiselectAscSort', - saveLastQuery: null + saveLastQuery: null, + newItem: false, + saveTrigger: 'enter, slash' }, $get: function() { return { @@ -222,11 +224,68 @@ angular.module('oi.multiselect') return arr; } + //lodash _.isEqual + function isEqual(x, y) { + if ( x === y ) return true; + if ( ! ( x instanceof Object ) || ! ( y instanceof Object ) ) return false; + if ( x.constructor !== y.constructor ) return false; + + for ( var p in x ) { + if ( ! x.hasOwnProperty( p ) ) continue; + if ( ! y.hasOwnProperty( p ) ) return false; + if ( x[ p ] === y[ p ] ) continue; + if ( typeof( x[ p ] ) !== "object" ) return false; + if ( ! objectEquals( x[ p ], y[ p ] ) ) return false; + } + + for ( p in y ) { + if ( y.hasOwnProperty( p ) && ! x.hasOwnProperty( p ) ) return false; + } + return true; + } + + //lodash _.intersection + filter + callback + invert + function intersection(xArr, yArr, callback, xFilter, yFilter, invert) { + var i, j, n, filteredX, filteredY, out = invert ? [].concat(xArr) : []; + + callback = callback || function(xValue, yValue) { + return xValue === yValue; + }; + + for (i = 0, n = xArr.length; i < xArr.length; i++) { + filteredX = xFilter ? xFilter(xArr[i]) : xArr[i]; + + for (j = 0; j < yArr.length; j++) { + filteredY = yFilter ? yFilter(yArr[j]) : yArr[j]; + + if (callback(filteredX, filteredY, xArr, yArr, i, j)) { + invert ? out.splice(i + out.length - n, 1) : out.push(xArr[i]); + break; + } + } + } + return out; + } + + function getValue(valueName, item, scope, getter) { + var locals = {}; + + //'name.subname' -> {name: {subname: list}}' + valueName.split('.').reduce(function(previousValue, currentItem, index, arr) { + return previousValue[currentItem] = index < arr.length - 1 ? {} : item; + }, locals); + + return getter(scope, locals); + } + return { - copyWidth: copyWidth, - measureString: measureString, + copyWidth: copyWidth, + measureString: measureString, scrollActiveOption: scrollActiveOption, - groupsIsEmpty: groupsIsEmpty, - objToArr: objToArr + groupsIsEmpty: groupsIsEmpty, + objToArr: objToArr, + getValue: getValue, + isEqual: isEqual, + intersection: intersection } }]); \ No newline at end of file