├── .gitignore ├── bower.json ├── package.json ├── gulpFile.js ├── lrTypeaheadMultiselect.min.js ├── test ├── karma.conf.js └── spec.js ├── lrTypeaheadMultiselect.js └── lrTypeaheadMultiselect.min.js.map /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules 3 | bower_components 4 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lr-typeahead-multiselect", 3 | "main": "lrTypeaheadMultiselect.js", 4 | "version": "1.1.2", 5 | "authors": [ 6 | "lorenzofox3 " 7 | ], 8 | "description": "typeahead multiselect component for angular js", 9 | "keywords": [ 10 | "angular", 11 | "typeahead", 12 | "multi" 13 | ], 14 | "license": "MIT", 15 | "ignore": [ 16 | "**/.*", 17 | "node_modules", 18 | "bower_components", 19 | "test", 20 | "tests" 21 | ], 22 | "dev-dependencies": { 23 | "angular-mocks": "~1.4.4", 24 | "angular": "~1.4.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lr-typeahead-multiselect", 3 | "version": "1.1.2", 4 | "description": "typeahead control for angular to be bound to an array", 5 | "main": "lrTypeaheadMultiselect.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "gulp karma-CI" 11 | }, 12 | "keywords": [ 13 | "angularjs", 14 | "typeahead" 15 | ], 16 | "author": "Laurent Renard", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "gulp": "^3.9.0", 20 | "gulp-concat": "^2.6.0", 21 | "gulp-insert": "^0.5.0", 22 | "gulp-sourcemaps": "^1.5.2", 23 | "gulp-uglify": "^1.2.0", 24 | "jasmine-core": "^2.3.4", 25 | "karma": "^0.13.9", 26 | "karma-chrome-launcher": "^0.2.0", 27 | "karma-jasmine": "^0.3.6", 28 | "karma-phantomjs-launcher": "^0.2.1", 29 | "phantomjs": "^1.9.18" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /gulpFile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var karma = require('karma').Server; 3 | var sourcemaps = require('gulp-sourcemaps'); 4 | var uglify = require('gulp-uglify'); 5 | var concat = require('gulp-concat'); 6 | var insert = require('gulp-insert'); 7 | var packageJson = require('./package.json'); 8 | 9 | 10 | gulp.task('karma-CI', function (done) { 11 | var conf = require('./test/karma.conf.js'); 12 | conf.singleRun = true; 13 | conf.browsers = ['PhantomJS']; 14 | conf.basePath = './'; 15 | var server = new karma(conf); 16 | server.start(); 17 | }); 18 | 19 | gulp.task('min', function () { 20 | gulp.src('./lrTypeaheadMultiselect.js') 21 | .pipe(concat('lrTypeaheadMultiselect.min.js')) 22 | .pipe(sourcemaps.init()) 23 | .pipe(uglify()) 24 | .pipe(sourcemaps.write('.')) 25 | .pipe(gulp.dest('./')); 26 | }); 27 | 28 | gulp.task('build', ['min', 'karma-CI'], function () { 29 | var version = packageJson.version; 30 | var string = '/** \n* @version ' + version + '\n* @license MIT\n*/\n'; 31 | 32 | gulp.src('./lrTypeaheadMultiselect.js') 33 | .pipe(insert.prepend(string)) 34 | .pipe(gulp.dest('./')); 35 | }); -------------------------------------------------------------------------------- /lrTypeaheadMultiselect.min.js: -------------------------------------------------------------------------------- 1 | !function(e,n){"use strict";e.module("lrTypeaheadMultiSelect",[]).controller("lrTypeaheadCtrl",["$q",function(e){var n=!1,t=[],l="",i=null,u={addValue:function(e){-1===this.valueCollection.indexOf(e)&&(this.valueCollection.push(e),t=[],l="",i=null)},removeValue:function(e){var n=this.valueCollection.indexOf(e);-1!==n?this.valueCollection.splice(n,1):this.valueCollection.pop()},suggest:function(u){var o=this;o.valueCollection=o.valueCollection||0,l=u,t=u?function(){n=!0,e.resolve(o.suggester(u)).then(function(e){t=e.filter(function(e){return-1===o.valueCollection.indexOf(e)}),i=t.length?t[0]:null})["finally"](function(){n=!1})}():[]},isHighlighted:function(e){return i===e},highlightPrevious:function(){var e=t.indexOf(i);i=e>=0&&t.length?t[e-1]:i},highlightNext:function(){var e=t.indexOf(i);i=e>=0&&e+1= 0 && suggestions.length ? suggestions[index - 1] : highlighted; 72 | }, 73 | /** 74 | * highlight the previous suggestion in the suggestion list 75 | */ 76 | highlightNext: function highlightNext () { 77 | var index = suggestions.indexOf(highlighted); 78 | highlighted = index >= 0 && index + 1 < suggestions.length ? suggestions[index + 1] : highlighted; 79 | } 80 | }; 81 | 82 | return Object.create(proto, { 83 | isLoading: { 84 | get: function () { 85 | return isLoading; 86 | } 87 | }, 88 | suggestions: { 89 | get: function () { 90 | return suggestions; 91 | } 92 | }, 93 | suggestionInput: { 94 | get: function () { 95 | return suggestionInput; 96 | } 97 | }, 98 | highlighted: { 99 | get: function () { 100 | return highlighted; 101 | } 102 | }, 103 | valueCollection: { 104 | value: [], 105 | writable: true 106 | } 107 | }); 108 | }]) 109 | .directive('lrTypeahead', function () { 110 | return { 111 | scope: true, 112 | bindToController: { 113 | valueCollection: '=lrTypeahead', 114 | suggester: '=' 115 | }, 116 | controller: 'lrTypeaheadCtrl', 117 | controllerAs: 'lrTypeaheadCtrl' 118 | } 119 | }) 120 | .directive('lrSuggestionInput', ['$timeout', function ($timeout) { 121 | return { 122 | require: '^lrTypeahead', 123 | link: function (scope, element, attr, ctrl) { 124 | var editing = null; 125 | var strict = attr.strictMode !== 'false'; 126 | element.bind('input', function () { 127 | if (editing !== null) { 128 | $timeout.cancel(editing); 129 | } 130 | editing = $timeout(function () { 131 | ctrl.suggest(element[0].value); 132 | editing = null; 133 | }, 200); 134 | }); 135 | element.bind('keydown', function (event) { 136 | scope.$apply(function () { 137 | var key = event.keyCode; 138 | if (key === 13) { 139 | if (!strict || ctrl.suggestions[0]) { 140 | ctrl.addValue(ctrl.highlighted || element[0].value); 141 | element[0].value = ''; 142 | event.preventDefault(); 143 | } 144 | } else if (key === 8 && !element[0].value) { 145 | ctrl.removeValue(); 146 | } else if (key === 40) { 147 | ctrl.highlightNext(); 148 | } else if (key === 38) { 149 | ctrl.highlightPrevious(); 150 | } 151 | }) 152 | }); 153 | } 154 | } 155 | }]); 156 | })(angular); 157 | -------------------------------------------------------------------------------- /test/spec.js: -------------------------------------------------------------------------------- 1 | describe('controller', function () { 2 | var ctrl; 3 | var root; 4 | beforeEach(module('lrTypeaheadMultiSelect')); 5 | beforeEach(inject(function ($controller, $rootScope) { 6 | ctrl = $controller('lrTypeaheadCtrl'); 7 | root = $rootScope; 8 | })); 9 | 10 | it('should add a new value', function () { 11 | ctrl.addValue({foo: 'bar'}); 12 | expect(ctrl.valueCollection.length).toBe(1); 13 | expect(ctrl.valueCollection[0]).toEqual({foo: 'bar'}); 14 | }); 15 | 16 | it('should not add an existing value', function () { 17 | var item = {foo: 'bar'}; 18 | ctrl.addValue(item); 19 | expect(ctrl.valueCollection.length).toBe(1); 20 | expect(ctrl.valueCollection[0]).toEqual(item); 21 | ctrl.addValue(item); 22 | expect(ctrl.valueCollection.length).toBe(1); 23 | }); 24 | 25 | it('should remove a specific value', function () { 26 | var item1 = {foo: 'bar'}; 27 | var item2 = {foo: 'bar2'}; 28 | ctrl.addValue(item1); 29 | ctrl.addValue(item2); 30 | expect(ctrl.valueCollection.length).toBe(2); 31 | expect(ctrl.valueCollection[0]).toEqual(item1); 32 | ctrl.removeValue(item1); 33 | expect(ctrl.valueCollection.length).toBe(1); 34 | expect(ctrl.valueCollection[0]).toEqual(item2); 35 | }); 36 | 37 | it('should remove the last value by default', function () { 38 | var item1 = {foo: 'bar'}; 39 | var item2 = {foo: 'bar2'}; 40 | ctrl.addValue(item1); 41 | ctrl.addValue(item2); 42 | expect(ctrl.valueCollection.length).toBe(2); 43 | expect(ctrl.valueCollection[0]).toEqual(item1); 44 | ctrl.removeValue(); 45 | expect(ctrl.valueCollection.length).toBe(1); 46 | expect(ctrl.valueCollection[0]).toEqual(item1); 47 | }); 48 | 49 | it('should suggest based on the suggest function provided ', function () { 50 | ctrl.suggester = function (input) { 51 | return [{val: input + '1'}, {val: input + '2'}, {val: input + '3'}]; 52 | }; 53 | ctrl.suggest('foo'); 54 | expect(ctrl.isLoading).toBe(true); 55 | root.$digest(); 56 | expect(ctrl.suggestions).toEqual([{val: 'foo1'}, {val: 'foo2'}, {val: 'foo3'}]); 57 | expect(ctrl.suggestionInput).toEqual('foo'); 58 | expect(ctrl.isLoading).toBe(false); 59 | }); 60 | 61 | it('should filter out suggestion which are already part of collection value', function () { 62 | var suggestions = [{val: '1'}, {val: '2'}, {val: '3'}]; 63 | ctrl.suggester = function (input) { 64 | return suggestions; 65 | }; 66 | ctrl.addValue((suggestions[0])); 67 | ctrl.suggest('foo'); 68 | expect(ctrl.isLoading).toBe(true); 69 | root.$digest(); 70 | expect(ctrl.suggestionInput).toEqual('foo'); 71 | expect(ctrl.suggestions).toEqual([{val: '2'}, {val: '3'}]); 72 | expect(ctrl.isLoading).toBe(false); 73 | }); 74 | 75 | it('should highlight the first itme of suggestion list', function () { 76 | var suggestions = [{val: '1'}, {val: '2'}, {val: '3'}]; 77 | ctrl.suggester = function (input) { 78 | return suggestions; 79 | }; 80 | ctrl.suggest('foo'); 81 | expect(ctrl.isLoading).toBe(true); 82 | root.$digest(); 83 | expect(ctrl.suggestionInput).toEqual('foo'); 84 | expect(ctrl.suggestions).toEqual([{val: '1'}, {val: '2'}, {val: '3'}]); 85 | expect(ctrl.isLoading).toBe(false); 86 | expect(ctrl.isHighlighted(suggestions[0])).toBe(true); 87 | expect(ctrl.isHighlighted(suggestions[1])).toBe(false); 88 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 89 | }); 90 | 91 | it('should highlight the next/previous item', function () { 92 | var suggestions = [{val: '1'}, {val: '2'}, {val: '3'}]; 93 | ctrl.suggester = function (input) { 94 | return suggestions; 95 | }; 96 | ctrl.suggest('foo'); 97 | expect(ctrl.isLoading).toBe(true); 98 | root.$digest(); 99 | expect(ctrl.suggestionInput).toEqual('foo'); 100 | expect(ctrl.suggestions).toEqual([{val: '1'}, {val: '2'}, {val: '3'}]); 101 | expect(ctrl.isLoading).toBe(false); 102 | expect(ctrl.isHighlighted(suggestions[0])).toBe(true); 103 | expect(ctrl.isHighlighted(suggestions[1])).toBe(false); 104 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 105 | ctrl.highlightNext(); 106 | expect(ctrl.isHighlighted(suggestions[0])).toBe(false); 107 | expect(ctrl.isHighlighted(suggestions[1])).toBe(true); 108 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 109 | ctrl.highlightNext(); 110 | expect(ctrl.isHighlighted(suggestions[0])).toBe(false); 111 | expect(ctrl.isHighlighted(suggestions[1])).toBe(false); 112 | expect(ctrl.isHighlighted(suggestions[2])).toBe(true); 113 | ctrl.highlightPrevious(); 114 | expect(ctrl.isHighlighted(suggestions[0])).toBe(false); 115 | expect(ctrl.isHighlighted(suggestions[1])).toBe(true); 116 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 117 | ctrl.highlightPrevious(); 118 | expect(ctrl.isHighlighted(suggestions[0])).toBe(true); 119 | expect(ctrl.isHighlighted(suggestions[1])).toBe(false); 120 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 121 | }); 122 | 123 | it('should reset suggestion when a value is added', function () { 124 | var suggestions = [{val: '1'}, {val: '2'}, {val: '3'}]; 125 | ctrl.suggester = function (input) { 126 | return suggestions; 127 | }; 128 | ctrl.suggest('foo'); 129 | expect(ctrl.isLoading).toBe(true); 130 | root.$digest(); 131 | expect(ctrl.suggestionInput).toEqual('foo'); 132 | expect(ctrl.suggestions).toEqual([{val: '1'}, {val: '2'}, {val: '3'}]); 133 | expect(ctrl.isLoading).toBe(false); 134 | expect(ctrl.isHighlighted(suggestions[0])).toBe(true); 135 | expect(ctrl.isHighlighted(suggestions[1])).toBe(false); 136 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 137 | ctrl.addValue(suggestions[0]); 138 | expect(ctrl.isHighlighted(suggestions[0])).toBe(false); 139 | expect(ctrl.isHighlighted(suggestions[1])).toBe(false); 140 | expect(ctrl.isHighlighted(suggestions[2])).toBe(false); 141 | expect(ctrl.suggestions.length).toBe(0); 142 | expect(ctrl.suggestionInput).toEqual(''); 143 | }); 144 | 145 | 146 | }); -------------------------------------------------------------------------------- /lrTypeaheadMultiselect.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["lrTypeaheadMultiselect.min.js"],"names":["ng","undefined","module","controller","$q","isLoading","suggestions","suggestionInput","highlighted","proto","addValue","item","this","valueCollection","indexOf","push","removeValue","index","splice","pop","suggest","input","ctrl","resolve","suggester","then","sugg","filter","s","length","isHighlighted","highlightPrevious","highlightNext","Object","create","get","value","writable","directive","scope","bindToController","controllerAs","$timeout","require","link","element","attr","editing","strict","strictMode","bind","cancel","event","$apply","key","keyCode","preventDefault","angular"],"mappings":"CAAA,SAAWA,EAAIC,GACb,YACAD,GAAGE,OAAO,6BACPC,WAAW,mBAAoB,KAAM,SAAUC,GAC9C,GAAIC,IAAY,EACZC,KACAC,EAAkB,GAClBC,EAAc,KAEdC,GAKFC,SAAU,SAAUC,GAEyB,KAAvCC,KAAKC,gBAAgBC,QAAQH,KAC/BC,KAAKC,gBAAgBE,KAAKJ,GAC1BL,KACAC,EAAkB,GAClBC,EAAc,OAOlBQ,YAAa,SAAsBL,GACjC,GAAIM,GAAQL,KAAKC,gBAAgBC,QAAQH,EAC3B,MAAVM,EACFL,KAAKC,gBAAgBK,OAAOD,EAAO,GAEnCL,KAAKC,gBAAgBM,OAOzBC,QAAS,SAAkBC,GACzB,GAAIC,GAAOV,IACXU,GAAKT,gBAAkBS,EAAKT,iBAAmB,EAC/CN,EAAkBc,EAClBf,EAAce,EACZ,WACEhB,GAAY,EACZD,EAAGmB,QAAQD,EAAKE,UAAUH,IACvBI,KAAK,SAAUC,GACdpB,EAAcoB,EAAKC,OAAO,SAAUC,GAClC,MAA2C,KAApCN,EAAKT,gBAAgBC,QAAQc,KAEtCpB,EAAcF,EAAYuB,OAASvB,EAAY,GAAK,OALxDF,WAOW,WACPC,GAAY,WAQtByB,cAAe,SAAuBnB,GACpC,MAAOH,KAAgBG,GAKzBoB,kBAAmB,WACjB,GAAId,GAAQX,EAAYQ,QAAQN,EAChCA,GAAcS,GAAS,GAAKX,EAAYuB,OAASvB,EAAYW,EAAQ,GAAKT,GAK5EwB,cAAe,WACb,GAAIf,GAAQX,EAAYQ,QAAQN,EAChCA,GAAcS,GAAS,GAAKA,EAAQ,EAAIX,EAAYuB,OAASvB,EAAYW,EAAQ,GAAKT,GAI1F,OAAOyB,QAAOC,OAAOzB,GACnBJ,WACE8B,IAAK,WACH,MAAO9B,KAGXC,aACE6B,IAAK,WACH,MAAO7B,KAGXC,iBACE4B,IAAK,WACH,MAAO5B,KAGXC,aACE2B,IAAK,WACH,MAAO3B,KAGXK,iBACEuB,SACAC,UAAU,QAIfC,UAAU,cAAe,WACxB,OACEC,OAAO,EACPC,kBACE3B,gBAAiB,eACjBW,UAAW,KAEbrB,WAAY,kBACZsC,aAAc,qBAGjBH,UAAU,qBAAsB,WAAY,SAAUI,GACrD,OACEC,QAAS,eACTC,KAAM,SAAUL,EAAOM,EAASC,EAAMxB,GACpC,GAAIyB,GAAU,KACVC,EAA6B,UAApBF,EAAKG,UAClBJ,GAAQK,KAAK,QAAS,WACJ,OAAZH,GACFL,EAASS,OAAOJ,GAElBA,EAAUL,EAAS,WACjBpB,EAAKF,QAAQyB,EAAQ,GAAGT,OACxBW,EAAU,MACT,OAELF,EAAQK,KAAK,UAAW,SAAUE,GAChCb,EAAMc,OAAO,WACX,GAAIC,GAAMF,EAAMG,OACJ,MAARD,IACGN,GAAU1B,EAAKhB,YAAY,MAC9BgB,EAAKZ,SAASY,EAAKd,aAAeqC,EAAQ,GAAGT,OAC7CS,EAAQ,GAAGT,MAAQ,GACnBgB,EAAMI,kBAES,IAARF,GAAcT,EAAQ,GAAGT,MAEjB,KAARkB,EACThC,EAAKU,gBACY,KAARsB,GACThC,EAAKS,oBAJLT,EAAKN,wBAWlByC","file":"lrTypeaheadMultiselect.min.js","sourcesContent":["(function (ng, undefined) {\n 'use strict';\n ng.module('lrTypeaheadMultiSelect', [])\n .controller('lrTypeaheadCtrl', ['$q', function ($q) {\n var isLoading = false;\n var suggestions = [];\n var suggestionInput = '';\n var highlighted = null;\n\n var proto = {\n /**\n * add a value to the valueCollection\n * @param item - the value to add\n */\n addValue: function (item) {\n //only add new items\n if (this.valueCollection.indexOf(item) === -1) {\n this.valueCollection.push(item);\n suggestions = [];\n suggestionInput = '';\n highlighted = null;\n }\n },\n /**\n * remove a value from the valueCollection (or remove the last item)\n * @param item - the value to remove\n */\n removeValue: function removeValue (item) {\n var index = this.valueCollection.indexOf(item);\n if (index !== -1) {\n this.valueCollection.splice(index, 1);\n } else {\n this.valueCollection.pop();\n }\n },\n /**\n * update suggestions based on the input\n * @param input {String} - the suggestion input\n */\n suggest: function suggest (input) {\n var ctrl = this;\n ctrl.valueCollection = ctrl.valueCollection || 0;\n suggestionInput = input;\n suggestions = input ?\n function () {\n isLoading = true;\n $q.resolve(ctrl.suggester(input))\n .then(function (sugg) {\n suggestions = sugg.filter(function (s) {\n return ctrl.valueCollection.indexOf(s) === -1;\n });\n highlighted = suggestions.length ? suggestions[0] : null;\n })\n .finally(function () {\n isLoading = false;\n })\n }() : [];\n },\n /**\n * tells whether a suggestion is currently highlighted\n * @param item - the suggestion to verify\n */\n isHighlighted: function isHiglighted (item) {\n return highlighted === item;\n },\n /**\n * highlight the next suggestion in the suggestion list\n */\n highlightPrevious: function highlightPrevious () {\n var index = suggestions.indexOf(highlighted);\n highlighted = index >= 0 && suggestions.length ? suggestions[index - 1] : highlighted;\n },\n /**\n * highlight the previous suggestion in the suggestion list\n */\n highlightNext: function highlightNext () {\n var index = suggestions.indexOf(highlighted);\n highlighted = index >= 0 && index + 1 < suggestions.length ? suggestions[index + 1] : highlighted;\n }\n };\n\n return Object.create(proto, {\n isLoading: {\n get: function () {\n return isLoading;\n }\n },\n suggestions: {\n get: function () {\n return suggestions;\n }\n },\n suggestionInput: {\n get: function () {\n return suggestionInput;\n }\n },\n highlighted: {\n get: function () {\n return highlighted;\n }\n },\n valueCollection: {\n value: [],\n writable: true\n }\n });\n }])\n .directive('lrTypeahead', function () {\n return {\n scope: true,\n bindToController: {\n valueCollection: '=lrTypeahead',\n suggester: '='\n },\n controller: 'lrTypeaheadCtrl',\n controllerAs: 'lrTypeaheadCtrl'\n }\n })\n .directive('lrSuggestionInput', ['$timeout', function ($timeout) {\n return {\n require: '^lrTypeahead',\n link: function (scope, element, attr, ctrl) {\n var editing = null;\n var strict = attr.strictMode !== 'false';\n element.bind('input', function () {\n if (editing !== null) {\n $timeout.cancel(editing);\n }\n editing = $timeout(function () {\n ctrl.suggest(element[0].value);\n editing = null;\n }, 200);\n });\n element.bind('keydown', function (event) {\n scope.$apply(function () {\n var key = event.keyCode;\n if (key === 13) {\n if (!strict || ctrl.suggestions[0]) {\n ctrl.addValue(ctrl.highlighted || element[0].value);\n element[0].value = '';\n event.preventDefault();\n }\n } else if (key === 8 && !element[0].value) {\n ctrl.removeValue();\n } else if (key === 40) {\n ctrl.highlightNext();\n } else if (key === 38) {\n ctrl.highlightPrevious();\n }\n })\n });\n }\n }\n }]);\n})(angular);\n"],"sourceRoot":"/source/"} --------------------------------------------------------------------------------