├── .bowerrc ├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── bower.json ├── dist ├── angular-tags-0.3.1-tpls.js ├── angular-tags-0.3.1-tpls.map.js ├── angular-tags-0.3.1-tpls.min.js ├── angular-tags-0.3.1.css ├── angular-tags-0.3.1.js ├── angular-tags-0.3.1.less ├── angular-tags-0.3.1.map.js ├── angular-tags-0.3.1.min.js └── templates │ └── tags.html ├── less └── tags.less ├── package.json ├── src └── tags.js ├── templates └── tags.html └── test ├── test-tags.html └── test-tags.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | .idea 16 | test/lib 17 | dist/generated 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | - "0.10" 5 | - "0.8" 6 | 7 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | 3 | // Project configuration. 4 | grunt.initConfig({ 5 | pkg: require('./package.json'), 6 | 7 | qunit: { 8 | all: { 9 | options: { 10 | urls: [ 11 | 'http://localhost:8000/test/test-tags.html' 12 | ], 13 | force: true 14 | } 15 | } 16 | }, 17 | connect: { 18 | server: { 19 | options: { 20 | port: 8000, 21 | base: '.' 22 | } 23 | } 24 | }, 25 | bower: { 26 | install: { 27 | options: { 28 | targetDir: './test/lib', 29 | cleanup: true 30 | } 31 | } 32 | }, 33 | watch: { 34 | scripts: { 35 | files: [ 36 | 'src/tags.js', 37 | 'templates/tags.html', 38 | 'test/test-tags.html', 39 | 'test/test-tags.js' 40 | ], 41 | tasks: ['test'] 42 | } 43 | }, 44 | html2js: { 45 | options: { 46 | base: '.' 47 | }, 48 | dist: { 49 | src: ['./templates/tags.html'], 50 | dest: 'dist/generated/templates.js', 51 | module: 'decipher.tags.templates' 52 | 53 | } 54 | }, 55 | less: { 56 | dist: { 57 | options: { 58 | paths: ["."], 59 | yuicompress: false 60 | }, 61 | files: { 62 | "dist/<%=pkg.name%>-<%=pkg.version%>.css": "less/tags.less" 63 | } 64 | } 65 | }, 66 | uglify: { 67 | dist: { 68 | files: { 69 | 'dist/<%= pkg.name %>-<%= pkg.version %>.min.js': [ 70 | 'dist/generated/tags.js' 71 | ] 72 | }, 73 | options: { 74 | report: 'min', 75 | sourceMap: 'dist/<%=' + 76 | ' pkg.name %>-<%= pkg.version %>.map.js', 77 | sourceMapRoot: '/', 78 | sourceMapPrefix: 1, 79 | sourceMappingURL: '<%= pkg.name %>-<%= pkg.version %>.map.js' 80 | } 81 | }, 82 | distTpls: { 83 | files: { 84 | 'dist/<%= pkg.name %>-<%= pkg.version %>-tpls.min.js': [ 85 | 'dist/generated/*.js' 86 | ] 87 | }, 88 | options: { 89 | report: 'min', 90 | sourceMap: 'dist/<%= pkg.name %>-<%= pkg.version %>-tpls.map.js', 91 | sourceMapRoot: '/', 92 | sourceMapPrefix: 1, 93 | sourceMappingURL: '<%= pkg.name %>-<%= pkg.version %>-tpls.map.js' 94 | } 95 | } 96 | }, 97 | concat: { 98 | dist: { 99 | src: ['dist/generated/tags.js'], 100 | dest: 'dist/<%=pkg.name%>-<%=pkg.version%>.js' 101 | }, 102 | distTpls: { 103 | src: ['dist/generated/templates.js', 'dist/generated/tags.js'], 104 | dest: 'dist/<%=pkg.name%>-<%=pkg.version%>-tpls.js' 105 | } 106 | }, 107 | copy: { 108 | dist: { 109 | files: [ 110 | { 111 | src: ['templates/tags.html'], 112 | dest: 'dist/templates/tags.html' 113 | }, 114 | { 115 | src: ['less/tags.less'], 116 | dest: 'dist/<%=pkg.name%>-<%=pkg.version%>.less' 117 | }, 118 | { 119 | src: ['src/tags.js'], 120 | dest: 'dist/generated/tags.js' 121 | } 122 | ] 123 | } 124 | } 125 | }); 126 | 127 | grunt.loadNpmTasks('grunt-contrib-qunit'); 128 | grunt.loadNpmTasks('grunt-contrib-connect'); 129 | grunt.loadNpmTasks('grunt-bower-task'); 130 | grunt.loadNpmTasks('grunt-contrib-watch'); 131 | grunt.loadNpmTasks('grunt-html2js'); 132 | grunt.loadNpmTasks('grunt-contrib-less'); 133 | grunt.loadNpmTasks('grunt-contrib-uglify'); 134 | grunt.loadNpmTasks('grunt-contrib-concat'); 135 | grunt.loadNpmTasks('grunt-contrib-copy'); 136 | 137 | grunt.registerTask('test', 138 | ['build', 'bower:install', 'connect', 'qunit']); 139 | grunt.registerTask('build', ['less', 'html2js', 'copy', 'concat', 'uglify']); 140 | grunt.registerTask('default', ['build']); 141 | 142 | grunt.event.on('qunit.log', 143 | function (result, actual, expected, message) { 144 | if (!!result) { 145 | grunt.log.ok(message); 146 | } 147 | }); 148 | }; 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Christopher Hiller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Achtung! 2 | 3 | > I don't have much time to work on this project and I no longer use it for anything. 4 | 5 | > I'd love it if I could work with somebody to get v1.0.0 out the door. I have refactors, bug fixes, and new tests in the pipeline, but there's more work to do. 6 | 7 | > If any user of this library wants to assist me, I'll happily answer any questions, provide tasks, and discuss direction. 8 | 9 | > Once we release [v1.0.0](https://github.com/boneskull/angular-tags/tree/v1.0.0), I could potentially transfer ownership of this project to another collaborator. 10 | 11 | > *Please comment in [issue #48](https://github.com/boneskull/angular-tags/issues/48) if you are interested!* 12 | 13 | # angular-tags [![Build Status](https://api.travis-ci.org/boneskull/angular-tags.png?branch=master)](https://travis-ci.org/boneskull/angular-tags) 14 | 15 | Pure AngularJS tagging widget with typeahead support courtesy of 16 | [ui-bootstrap](http://angular-ui.github.io/bootstrap). 17 | 18 | Current Version 19 | --------------- 20 | ``` 21 | 0.3.1 22 | ``` 23 | 24 | Installation 25 | ------------ 26 | ``` 27 | bower install angular-tags 28 | ``` 29 | 30 | Requirements 31 | ------------ 32 | 33 | - [AngularJS](http://angularjs.org) 34 | - [ui-bootstrap](http://angular-ui.github.io/bootstrap) (ui.bootstrap.typeahead module) 35 | - [Bootstrap CSS](http://getbootstrap.com) (optional) 36 | - [Font Awesome](http://fortawesome.github.io/Font-Awesome/) (optional) 37 | 38 | Running Tests 39 | ------------- 40 | 41 | Clone this repo and execute: 42 | 43 | ``` 44 | npm install 45 | ``` 46 | 47 | to grab the dependencies. Then execute: 48 | 49 | ``` 50 | grunt test 51 | ``` 52 | 53 | to run the tests. This will grab the test deps from bower, and run them against QUnit in a local server on port 8000. 54 | 55 | 56 | Usage 57 | ===== 58 | 59 | ### Demo 60 | 61 | Demo here. 62 | 63 | 64 | ### Setup 65 | 66 | angular-tags comes in two versions; one with embedded templates and another without. Without templates: 67 | 68 | ```html 69 | 70 | ``` 71 | 72 | With templates: 73 | 74 | ```html 75 | 76 | ``` 77 | 78 | You will also want to include the CSS if you are using this version: 79 | 80 | ```html 81 | 82 | ``` 83 | 84 | Templates are included in the `templates/` directory if you want to load them manually and/or modify them. 85 | 86 | You'll also need to make sure you have included the ui-bootstrap source. 87 | 88 | Finally, include the module in your code, and the required `ui.bootstrap.typeahead` module: 89 | 90 | ```javascript 91 | angular.module('myModule', ['decipher.tags', 'ui.bootstrap.typeahead']; 92 | ``` 93 | 94 | ### Directive 95 | 96 | This is a directive, so at its most basic: 97 | 98 | ```html 99 | 100 | ``` 101 | 102 | This will render the tags contained in `foo` (if anything) and provide an input prompt for more tags. 103 | 104 | `foo` can be a delimited string, array of strings, or array of objects with `name` properties: 105 | 106 | ```javascript 107 | foo = 'foo,bar'; 108 | foo = ['foo', 'bar']; 109 | foo = [{name: 'foo'}, {name: 'bar'}]; 110 | ``` 111 | 112 | All will render identically. Depending on the format you use, you will get the same type back when adding tags via the input. For example, if you add "baz" in the input and your original model happened to be a delimited string, you will get: 113 | 114 | ```javascript 115 | 'foo,bar,baz' 116 | ``` 117 | 118 | Likewise if you had an array of strings: 119 | 120 | ```javascript 121 | ['foo', 'bar', 'baz'] 122 | ``` 123 | 124 | With Typeahead 125 | -------------- 126 | 127 | The above directive usage will not use the typeahead functionality of ui-bootstrap. To use the typehead functionality, which provides a list of tags from which to choose, you have to specify some values to read from: 128 | 129 | ```html 130 | 131 | ``` 132 | 133 | The value of `src` is a comprehension expression, like found in [ngOptions](http://docs.angularjs.org/api/ng.directive:select). `baz` here should resemble `foo` as above; a delimited string, an array of strings, or an array of objects. See Tag Objects below. 134 | 135 | *Note*: Here we're using `b` (the entire object) for the value; feel free to use something else, but if we use `b`, the directive will retain any *extra data* you have put in the tag objects: 136 | 137 | ```javascript 138 | baz = [ 139 | {foo: 'bar', value: 'baz', name: 'derp'}, 140 | {foo: 'spam', value: 'baz', name: 'herp'}, 141 | ] 142 | ``` 143 | 144 | and 145 | 146 | ```html 147 | 148 | ``` 149 | 150 | The resulting source tags will look like this: 151 | 152 | ```javascript 153 | baz = [ 154 | {value: 'baz', name: 'derp'}, 155 | {value: 'baz', name: 'herp'}, 156 | ] 157 | ``` 158 | 159 | Basically, whatever you set here will become the `value` of these tags unless you specify an entire object. 160 | 161 | ### Typeahead Options 162 | 163 | You can pass options through to the typeahead module. Simply pass a `typeahead-options` attribute to the `` element. Available options are shown here: 164 | 165 | ```javascript 166 | $scope.typeaheadOpts = { 167 | inputFormatter: myInputFormatterFunction, 168 | loading: myLoadingBoolean, 169 | minLength: 3, 170 | onSelect: myOnSelectFunction, // this will be run in addition to directive internals 171 | templateUrl: '/path/to/my/template.html', 172 | waitMs: 500, 173 | allowsEditable: true 174 | }; 175 | ``` 176 | 177 | and: 178 | 179 | ```html 180 | 181 | ``` 182 | 183 | Tag Objects 184 | ----------- 185 | 186 | Tag objects have three main properties: 187 | 188 | - `name` The name (display name) of the tag 189 | - `group` (optional) The "group" of the tag, for assigning class names 190 | - `value` (optional) The "value" of the tag, which is not displayed 191 | 192 | Tag objects can include any other properties you wish to add. 193 | 194 | Options 195 | ----------- 196 | 197 | ### Global Options 198 | 199 | To set defaults module-wide, inject the `decipherTagsOptions` constant into anything and modify it: 200 | 201 | ```javascript 202 | myModule.config(function(decipherTagsOptions) { 203 | decipherTagsOptions.delimiter = ':'; 204 | decipherTagsOptions.classes = { 205 | myGroup: 'myClass', 206 | myOtherGroup: 'myOtherClass' 207 | }; 208 | }); 209 | ``` 210 | 211 | ### Available Options 212 | 213 | - `addable` whether or not the user is allowed to type arbitrary tags into the input (defaults to `false` by default if a `src` is supplied, otherwise defaults to `true`; see Adding Tags below. 214 | - `delimiter` what to use for a delimiter when typing or pasting into the input. Defaults to `,` 215 | - `classes` An object mapping of group names to class names 216 | - `templateUrl` URL to the main template. Defaults to `templates/tags.html` 217 | - `tagTemplateUrl` URL to the "tag" template. Defaults to `templates/tag.html` 218 | 219 | #### Adding Tags 220 | 221 | If you neglect to supply a `src` (thus not using typeahead) you will be able to enter whatever you like into the tags input, adding tags willy-nilly. If you *do* supply a `src`, by default the user will be limited to what's in the list. You can override this by passing an `addable` property to the options: 222 | 223 | ```html 224 | 225 | ``` 226 | 227 | #### Classes 228 | 229 | If you specify classes, your tags will each be assigned a class name based on the group. For example: 230 | 231 | ```html 232 | \n" + 6 | "\n" + 7 | "
\n" + 8 | " \n" + 10 | "{{tag.name}}\n" + 11 | " \n" + 12 | "\n" + 13 | " \n" + 14 | "
\n" + 15 | "\n" + 16 | " \n" + 17 | " \n" + 19 | " \n" + 21 | " \n" + 32 | "\n" + 33 | " \n" + 34 | "\n" + 35 | ""); 36 | }]); 37 | 38 | /*global angular*/ 39 | (function () { 40 | 'use strict'; 41 | 42 | try { 43 | angular.module('decipher.tags.templates'); 44 | } catch (e) { 45 | angular.module('decipher.tags.templates', []); 46 | } 47 | 48 | var tags = angular.module('decipher.tags', 49 | ['ui.bootstrap.typeahead', 'decipher.tags.templates']); 50 | 51 | var defaultOptions = { 52 | delimiter: ',', // if given a string model, it splits on this 53 | classes: {} // obj of group names to classes 54 | }, 55 | 56 | // for parsing comprehension expression 57 | SRC_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/, 58 | 59 | // keycodes 60 | kc = { 61 | enter: 13, 62 | esc: 27, 63 | backspace: 8 64 | }, 65 | kcCompleteTag = [kc.enter], 66 | kcRemoveTag = [kc.backspace], 67 | kcCancelInput = [kc.esc], 68 | id = 0; 69 | 70 | tags.constant('decipherTagsOptions', {}); 71 | 72 | /** 73 | * TODO: do we actually share functionality here? We're using this 74 | * controller on both the subdirective and its parent, but I'm not sure 75 | * if we actually use the same functions in both. 76 | */ 77 | tags.controller('TagsCtrl', 78 | ['$scope', '$timeout', '$q', function ($scope, $timeout, $q) { 79 | 80 | /** 81 | * Figures out what classes to put on the tag span. It'll add classes 82 | * if defined by group, and it'll add a selected class if the tag 83 | * is preselected to delete. 84 | * @param tag 85 | * @returns {{}} 86 | */ 87 | $scope.getClasses = function getGroupClass(tag) { 88 | var r = {}; 89 | 90 | if (tag === $scope.toggles.selectedTag) { 91 | r.selected = true; 92 | } 93 | angular.forEach($scope.options.classes, function (klass, groupName) { 94 | if (tag.group === groupName) { 95 | r[klass] = true; 96 | } 97 | }); 98 | return r; 99 | }; 100 | 101 | /** 102 | * Finds a tag in the src list and removes it. 103 | * @param tag 104 | * @returns {boolean} 105 | */ 106 | $scope._filterSrcTags = function filterSrcTags(tag) { 107 | // wrapped in timeout or typeahead becomes confused 108 | return $timeout(function () { 109 | var idx = $scope.srcTags.indexOf(tag); 110 | if (idx >= 0) { 111 | $scope.srcTags.splice(idx, 1); 112 | $scope._deletedSrcTags.push(tag); 113 | return; 114 | } 115 | return $q.reject(); 116 | }); 117 | }; 118 | 119 | /** 120 | * Adds a tag to the list of tags, and if in the typeahead list, 121 | * removes it from that list (and saves it). emits decipher.tags.added 122 | * @param tag 123 | */ 124 | $scope.add = function add(tag) { 125 | var _add = function _add(tag) { 126 | $scope.tags.push(tag); 127 | delete $scope.inputTag; 128 | $scope.$emit('decipher.tags.added', { 129 | tag: tag, 130 | $id: $scope.$id 131 | }); 132 | }, 133 | fail = function fail() { 134 | $scope.$emit('decipher.tags.addfailed', { 135 | tag: tag, 136 | $id: $scope.$id 137 | }); 138 | dfrd.reject(); 139 | }, 140 | i, 141 | dfrd = $q.defer(); 142 | 143 | // don't add dupe names 144 | i = $scope.tags.length; 145 | while (i--) { 146 | if ($scope.tags[i].name === tag.name) { 147 | fail(); 148 | } 149 | } 150 | 151 | $scope._filterSrcTags(tag) 152 | .then(function () { 153 | _add(tag); 154 | }, function () { 155 | if ($scope.options.addable) { 156 | _add(tag); 157 | dfrd.resolve(); 158 | } 159 | else { 160 | fail(); 161 | } 162 | }); 163 | 164 | return dfrd.promise; 165 | }; 166 | 167 | $scope.trust = function(tag) { 168 | return $sce.trustAsHtml(tag.name); 169 | }; 170 | /** 171 | * Toggle the input box active. 172 | */ 173 | $scope.selectArea = function selectArea() { 174 | $scope.toggles.inputActive = true; 175 | }; 176 | 177 | /** 178 | * Removes a tag. Restores stuff into srcTags if it came from there. 179 | * Kills any selected tag. Emit a decipher.tags.removed event. 180 | * @param tag 181 | */ 182 | $scope.remove = function remove(tag) { 183 | var idx; 184 | $scope.tags.splice($scope.tags.indexOf(tag), 1); 185 | 186 | if (idx = $scope._deletedSrcTags.indexOf(tag) >= 0) { 187 | $scope._deletedSrcTags.splice(idx, 1); 188 | if ($scope.srcTags.indexOf(tag) === -1) { 189 | $scope.srcTags.push(tag); 190 | } 191 | } 192 | 193 | delete $scope.toggles.selectedTag; 194 | 195 | $scope.$emit('decipher.tags.removed', { 196 | tag: tag, 197 | $id: $scope.$id 198 | }); 199 | }; 200 | 201 | }]); 202 | 203 | /** 204 | * Directive for the 'input' tag itself, which is of class 205 | * decipher-tags-input. 206 | */ 207 | tags.directive('decipherTagsInput', 208 | ['$timeout', '$filter', '$rootScope', 209 | function ($timeout, $filter, $rootScope) { 210 | return { 211 | restrict: 'C', 212 | require: 'ngModel', 213 | link: function (scope, element, attrs, ngModel) { 214 | var delimiterRx = new RegExp('^' + 215 | scope.options.delimiter + 216 | '+$'), 217 | 218 | /** 219 | * Cancels the text input box. 220 | */ 221 | cancel = function cancel() { 222 | ngModel.$setViewValue(''); 223 | ngModel.$render(); 224 | }, 225 | 226 | /** 227 | * Adds a tag you typed/pasted in unless it's a bunch of delimiters. 228 | * @param value 229 | */ 230 | addTag = function addTag(value) { 231 | if (value) { 232 | if (value.match(delimiterRx)) { 233 | cancel(); 234 | return; 235 | } 236 | if (scope.add({ 237 | name: value 238 | })) { 239 | cancel(); 240 | } 241 | } 242 | }, 243 | 244 | /** 245 | * Adds multiple tags in case you pasted them. 246 | * @param tags 247 | */ 248 | addTags = function (tags) { 249 | var i; 250 | for (i = 0; i < tags.length; 251 | i++) { 252 | addTag(tags[i]); 253 | } 254 | }, 255 | 256 | /** 257 | * Backspace one to select, and a second time to delete. 258 | */ 259 | removeLastTag = function removeLastTag() { 260 | var orderedTags; 261 | if (scope.toggles.selectedTag) { 262 | scope.remove(scope.toggles.selectedTag); 263 | delete scope.toggles.selectedTag; 264 | } 265 | // only do this if the input field is empty. 266 | else if (!ngModel.$viewValue) { 267 | orderedTags = 268 | $filter('orderBy')(scope.tags, 269 | scope.orderBy); 270 | scope.toggles.selectedTag = 271 | orderedTags[orderedTags.length - 1]; 272 | } 273 | }; 274 | 275 | /** 276 | * When we focus the text input area, drop the selected tag 277 | */ 278 | element.bind('focus', function () { 279 | // this avoids what looks like a bug in typeahead. It seems 280 | // to be calling element[0].focus() somewhere within a digest loop. 281 | if ($rootScope.$$phase) { 282 | delete scope.toggles.selectedTag; 283 | } else { 284 | scope.$apply(function () { 285 | delete scope.toggles.selectedTag; 286 | }); 287 | } 288 | }); 289 | 290 | /** 291 | * Detects the delimiter. 292 | */ 293 | element.bind('keypress', 294 | function (evt) { 295 | scope.$apply(function () { 296 | if (scope.options.delimiter.charCodeAt() === 297 | evt.which) { 298 | addTag(ngModel.$viewValue); 299 | } 300 | }); 301 | }); 302 | 303 | /** 304 | * Inspects whatever you typed to see if there were character(s) of 305 | * concern. 306 | */ 307 | element.bind('keydown', 308 | function (evt) { 309 | scope.$apply(function () { 310 | // to "complete" a tag 311 | 312 | if (kcCompleteTag.indexOf(evt.which) >= 313 | 0) { 314 | addTag(ngModel.$viewValue); 315 | 316 | // or if you want to get out of the text area 317 | } else if (kcCancelInput.indexOf(evt.which) >= 318 | 0 && !evt.isPropagationStopped()) { 319 | cancel(); 320 | scope.toggles.inputActive = 321 | false; 322 | 323 | // or if you're trying to delete something 324 | } else if (kcRemoveTag.indexOf(evt.which) >= 325 | 0) { 326 | removeLastTag(); 327 | 328 | // otherwise if we're typing in here, just drop the selected tag. 329 | } else { 330 | delete scope.toggles.selectedTag; 331 | scope.$emit('decipher.tags.keyup', 332 | { 333 | value: ngModel.$viewValue, 334 | $id: scope.$id 335 | }); 336 | } 337 | }); 338 | }); 339 | 340 | /** 341 | * When inputActive toggle changes to true, focus the input. 342 | * And no I have no idea why this has to be in a timeout. 343 | */ 344 | scope.$watch('toggles.inputActive', 345 | function (newVal) { 346 | if (newVal) { 347 | $timeout(function () { 348 | element[0].focus(); 349 | }); 350 | } 351 | }); 352 | 353 | /** 354 | * Detects a paste or someone jamming on the delimiter key. 355 | */ 356 | ngModel.$parsers.unshift(function (value) { 357 | var values = value.split(scope.options.delimiter); 358 | if (values.length > 1) { 359 | addTags(values); 360 | } 361 | if (value.match(delimiterRx)) { 362 | element.val(''); 363 | return; 364 | } 365 | return value; 366 | }); 367 | 368 | /** 369 | * Resets the input field if we selected something from typeahead. 370 | */ 371 | ngModel.$formatters.push(function (tag) { 372 | if (tag && tag.value) { 373 | element.val(''); 374 | return; 375 | } 376 | return tag; 377 | }); 378 | } 379 | }; 380 | }]); 381 | 382 | /** 383 | * Main directive 384 | */ 385 | tags.directive('tags', 386 | ['$document', '$timeout', '$parse', 'decipherTagsOptions', 387 | function ($document, $timeout, $parse, decipherTagsOptions) { 388 | 389 | return { 390 | controller: 'TagsCtrl', 391 | restrict: 'E', 392 | replace: true, 393 | // IE8 is really, really fussy about this. 394 | template: '
', 395 | scope: { 396 | model: '=' 397 | }, 398 | link: function (scope, element, attrs) { 399 | var srcResult, 400 | source, 401 | tags, 402 | group, 403 | i, 404 | tagsWatch, 405 | srcWatch, 406 | modelWatch, 407 | model, 408 | pureStrings = false, 409 | stringArray = false, 410 | defaults = angular.copy(defaultOptions), 411 | userDefaults = angular.copy(decipherTagsOptions), 412 | 413 | /** 414 | * Parses the comprehension expression and gives us interesting bits. 415 | * @param input 416 | * @returns {{itemName: *, source: *, viewMapper: *, modelMapper: *}} 417 | */ 418 | parse = function parse(input) { 419 | var match = input.match(SRC_REGEXP); 420 | if (!match) { 421 | throw new Error( 422 | "Expected src specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" + 423 | " but got '" + input + "'."); 424 | } 425 | 426 | return { 427 | itemName: match[3], 428 | source: $parse(match[4]), 429 | sourceName: match[4], 430 | viewMapper: $parse(match[2] || match[1]), 431 | modelMapper: $parse(match[1]) 432 | }; 433 | 434 | }, 435 | 436 | watchModel = function watchModel() { 437 | modelWatch = scope.$watch('model', function (newVal) { 438 | var deletedTag, idx; 439 | if (angular.isDefined(newVal)) { 440 | tagsWatch(); 441 | scope.tags = format(newVal); 442 | 443 | // remove already used tags 444 | i = scope.tags.length; 445 | while (i--) { 446 | scope._filterSrcTags(scope.tags[i]); 447 | } 448 | 449 | // restore any deleted things to the src array that happen to not 450 | // be in the new value. 451 | i = scope._deletedSrcTags.length; 452 | while (i--) { 453 | deletedTag = scope._deletedSrcTags[i]; 454 | if (idx = newVal.indexOf(deletedTag) === -1 && 455 | scope.srcTags.indexOf(deletedTag) === -1) { 456 | scope.srcTags.push(deletedTag); 457 | scope._deletedSrcTags.splice(i, 1); 458 | } 459 | } 460 | 461 | watchTags(); 462 | } 463 | }, true); 464 | 465 | }, 466 | 467 | watchTags = function watchTags() { 468 | 469 | /** 470 | * Watches tags for changes and propagates to outer model 471 | * in the format which we originally specified (see below) 472 | */ 473 | tagsWatch = scope.$watch('tags', function (value, oldValue) { 474 | var i; 475 | if (value !== oldValue) { 476 | modelWatch(); 477 | if (stringArray || pureStrings) { 478 | value = value.map(function (tag) { 479 | return tag.name; 480 | }); 481 | if (angular.isArray(scope.model)) { 482 | scope.model.length = 0; 483 | for (i = 0; i < value.length; i++) { 484 | scope.model.push(value[i]); 485 | } 486 | } 487 | if (pureStrings) { 488 | scope.model = value.join(scope.options.delimiter); 489 | } 490 | } 491 | else { 492 | scope.model.length = 0; 493 | for (i = 0; i < value.length; i++) { 494 | scope.model.push(value[i]); 495 | } 496 | } 497 | watchModel(); 498 | 499 | } 500 | }, true); 501 | }, 502 | /** 503 | * Takes a raw model value and returns something suitable 504 | * to assign to scope.tags 505 | * @param value 506 | */ 507 | format = function format(value) { 508 | var arr = []; 509 | 510 | if (angular.isUndefined(value)) { 511 | return; 512 | } 513 | if (angular.isString(value)) { 514 | arr = value 515 | .split(scope.options.delimiter) 516 | .map(function (item) { 517 | return { 518 | name: item.trim() 519 | }; 520 | }); 521 | } 522 | else if (angular.isArray(value)) { 523 | arr = value.map(function (item) { 524 | if (angular.isString(item)) { 525 | return { 526 | name: item.trim() 527 | }; 528 | } 529 | else if (item.name) { 530 | item.name = item.name.trim(); 531 | } 532 | return item; 533 | }); 534 | } 535 | else if (angular.isDefined(value)) { 536 | throw 'list of tags must be an array or delimited string'; 537 | } 538 | return arr; 539 | }, 540 | /** 541 | * Updates the source tag information. Sets a watch so we 542 | * know if the source values change. 543 | */ 544 | updateSrc = function updateSrc() { 545 | var locals, 546 | i, 547 | o, 548 | obj; 549 | // default to NOT letting users add new tags in this case. 550 | scope.options.addable = scope.options.addable || false; 551 | scope.srcTags = []; 552 | srcResult = parse(attrs.src); 553 | source = srcResult.source(scope.$parent); 554 | if (angular.isUndefined(source)) { 555 | return; 556 | } 557 | if (angular.isFunction(srcWatch)) { 558 | srcWatch(); 559 | } 560 | locals = {}; 561 | if (angular.isDefined(source)) { 562 | for (i = 0; i < source.length; i++) { 563 | locals[srcResult.itemName] = source[i]; 564 | obj = {}; 565 | obj.value = srcResult.modelMapper(scope.$parent, locals); 566 | o = {}; 567 | if (angular.isObject(obj.value)) { 568 | o = angular.extend(obj.value, { 569 | name: srcResult.viewMapper(scope.$parent, locals), 570 | value: obj.value.value, 571 | group: obj.value.group 572 | }); 573 | } 574 | else { 575 | o = { 576 | name: srcResult.viewMapper(scope.$parent, locals), 577 | value: obj.value, 578 | group: group 579 | }; 580 | } 581 | scope.srcTags.push(o); 582 | } 583 | } 584 | 585 | srcWatch = 586 | scope.$parent.$watch(srcResult.sourceName, 587 | function (newVal, oldVal) { 588 | if (newVal !== oldVal) { 589 | updateSrc(); 590 | } 591 | }, true); 592 | }; 593 | 594 | // merge options 595 | scope.options = angular.extend(defaults, 596 | angular.extend(userDefaults, scope.$eval(attrs.options))); 597 | // break out orderBy for view 598 | scope.orderBy = scope.options.orderBy; 599 | 600 | // this should be named something else since it's just a collection 601 | // of random shit. 602 | scope.toggles = { 603 | inputActive: false 604 | }; 605 | 606 | /** 607 | * When we receive this event, sort. 608 | */ 609 | scope.$on('decipher.tags.sort', function (evt, data) { 610 | scope.orderBy = data; 611 | }); 612 | 613 | // pass typeahead options through 614 | attrs.$observe('typeaheadOptions', function (newVal) { 615 | if (newVal) { 616 | scope.typeaheadOptions = $parse(newVal)(scope.$parent); 617 | } else { 618 | scope.typeaheadOptions = {}; 619 | } 620 | }); 621 | 622 | // determine what format we're in 623 | model = scope.model; 624 | if (angular.isString(model)) { 625 | pureStrings = true; 626 | } 627 | // XXX: avoid for now while fixing "empty array" bug 628 | else if (angular.isArray(model) && false) { 629 | stringArray = true; 630 | i = model.length; 631 | while (i--) { 632 | if (!angular.isString(model[i])) { 633 | stringArray = false; 634 | break; 635 | } 636 | } 637 | } 638 | 639 | // watch model for changes and update tags as appropriate 640 | scope.tags = []; 641 | scope._deletedSrcTags = []; 642 | watchTags(); 643 | watchModel(); 644 | 645 | // this stuff takes the parsed comprehension expression and 646 | // makes a srcTags array full of tag objects out of it. 647 | scope.srcTags = []; 648 | if (angular.isDefined(attrs.src)) { 649 | updateSrc(); 650 | } else { 651 | // if you didn't specify a src, you must be able to type in new tags. 652 | scope.options.addable = true; 653 | } 654 | 655 | // emit identifier 656 | scope.$id = ++id; 657 | scope.$emit('decipher.tags.initialized', { 658 | $id: scope.$id, 659 | model: scope.model 660 | }); 661 | } 662 | }; 663 | }]); 664 | 665 | })(); 666 | -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1-tpls.map.js: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"dist/angular-tags-0.3.1-tpls.min.js","sources":["generated/tags.js","generated/templates.js"],"names":["angular","module","e","tags","defaultOptions","delimiter","classes","SRC_REGEXP","kc","enter","esc","backspace","kcCompleteTag","kcRemoveTag","kcCancelInput","id","constant","controller","$scope","$timeout","$q","getClasses","tag","r","toggles","selectedTag","selected","forEach","options","klass","groupName","group","_filterSrcTags","idx","srcTags","indexOf","splice","_deletedSrcTags","push","reject","add","i","_add","inputTag","$emit","$id","fail","dfrd","defer","length","name","then","addable","resolve","promise","trust","$sce","trustAsHtml","selectArea","inputActive","remove","directive","$filter","$rootScope","restrict","require","link","scope","element","attrs","ngModel","delimiterRx","RegExp","cancel","$setViewValue","$render","addTag","value","match","addTags","removeLastTag","orderedTags","$viewValue","orderBy","bind","$$phase","$apply","evt","charCodeAt","which","isPropagationStopped","$watch","newVal","focus","$parsers","unshift","values","split","val","$formatters","$document","$parse","decipherTagsOptions","replace","template","model","srcResult","source","tagsWatch","srcWatch","modelWatch","pureStrings","stringArray","defaults","copy","userDefaults","parse","input","Error","itemName","sourceName","viewMapper","modelMapper","watchModel","deletedTag","isDefined","format","watchTags","oldValue","map","isArray","join","arr","isUndefined","isString","item","trim","updateSrc","locals","o","obj","src","$parent","isFunction","isObject","extend","oldVal","$eval","$on","data","$observe","typeaheadOptions","run","$templateCache","put"],"mappings":"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","sourceRoot":"/"} -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1-tpls.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";try{angular.module("decipher.tags.templates")}catch(a){angular.module("decipher.tags.templates",[])}var b=angular.module("decipher.tags",["ui.bootstrap.typeahead","decipher.tags.templates"]),c={delimiter:",",classes:{}},d=/^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/,e={enter:13,esc:27,backspace:8},f=[e.enter],g=[e.backspace],h=[e.esc],i=0;b.constant("decipherTagsOptions",{}),b.controller("TagsCtrl",["$scope","$timeout","$q",function(a,b,c){a.getClasses=function(b){var c={};return b===a.toggles.selectedTag&&(c.selected=!0),angular.forEach(a.options.classes,function(a,d){b.group===d&&(c[a]=!0)}),c},a._filterSrcTags=function(d){return b(function(){var b=a.srcTags.indexOf(d);return b>=0?(a.srcTags.splice(b,1),a._deletedSrcTags.push(d),void 0):c.reject()})},a.add=function(b){var d,e=function(b){a.tags.push(b),delete a.inputTag,a.$emit("decipher.tags.added",{tag:b,$id:a.$id})},f=function(){a.$emit("decipher.tags.addfailed",{tag:b,$id:a.$id}),g.reject()},g=c.defer();for(d=a.tags.length;d--;)a.tags[d].name===b.name&&f();return a._filterSrcTags(b).then(function(){e(b)},function(){a.options.addable?(e(b),g.resolve()):f()}),g.promise},a.trust=function(a){return $sce.trustAsHtml(a.name)},a.selectArea=function(){a.toggles.inputActive=!0},a.remove=function(b){var c;a.tags.splice(a.tags.indexOf(b),1),(c=a._deletedSrcTags.indexOf(b)>=0)&&(a._deletedSrcTags.splice(c,1),-1===a.srcTags.indexOf(b)&&a.srcTags.push(b)),delete a.toggles.selectedTag,a.$emit("decipher.tags.removed",{tag:b,$id:a.$id})}}]),b.directive("decipherTagsInput",["$timeout","$filter","$rootScope",function(a,b,c){return{restrict:"C",require:"ngModel",link:function(d,e,i,j){var k=new RegExp("^"+d.options.delimiter+"+$"),l=function(){j.$setViewValue(""),j.$render()},m=function(a){if(a){if(a.match(k))return l(),void 0;d.add({name:a})&&l()}},n=function(a){var b;for(b=0;b=0?m(j.$viewValue):h.indexOf(a.which)>=0&&!a.isPropagationStopped()?(l(),d.toggles.inputActive=!1):g.indexOf(a.which)>=0?o():(delete d.toggles.selectedTag,d.$emit("decipher.tags.keyup",{value:j.$viewValue,$id:d.$id}))})}),d.$watch("toggles.inputActive",function(b){b&&a(function(){e[0].focus()})}),j.$parsers.unshift(function(a){var b=a.split(d.options.delimiter);return b.length>1&&n(b),a.match(k)?(e.val(""),void 0):a}),j.$formatters.push(function(a){return a&&a.value?(e.val(""),void 0):a})}}}]),b.directive("tags",["$document","$timeout","$parse","decipherTagsOptions",function(a,b,e,f){return{controller:"TagsCtrl",restrict:"E",replace:!0,template:"
",scope:{model:"="},link:function(a,b,g){var h,j,k,l,m,n,o,p,q=!1,r=!1,s=angular.copy(c),t=angular.copy(f),u=function(a){var b=a.match(d);if(!b)throw new Error("Expected src specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_' but got '"+a+"'.");return{itemName:b[3],source:e(b[4]),sourceName:b[4],viewMapper:e(b[2]||b[1]),modelMapper:e(b[1])}},v=function(){o=a.$watch("model",function(b){var c,d;if(angular.isDefined(b)){for(m(),a.tags=x(b),l=a.tags.length;l--;)a._filterSrcTags(a.tags[l]);for(l=a._deletedSrcTags.length;l--;)c=a._deletedSrcTags[l],(d=-1===b.indexOf(c)&&-1===a.srcTags.indexOf(c))&&(a.srcTags.push(c),a._deletedSrcTags.splice(l,1));w()}},!0)},w=function(){m=a.$watch("tags",function(b,c){var d;if(b!==c){if(o(),r||q){if(b=b.map(function(a){return a.name}),angular.isArray(a.model))for(a.model.length=0,d=0;d\n\n
\n \n{{tag.name}}\n \n\n \n
\n\n \n \n \n \n\n \n\n')}]); 2 | //# sourceMappingURL=angular-tags-0.3.1-tpls.map.js -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1.css: -------------------------------------------------------------------------------- 1 | .decipher-tags { 2 | min-height: 24px; 3 | border: 1px solid #ccc; 4 | margin: 0px 10px; 5 | padding: 0px 5px 5px 5px; 6 | } 7 | .decipher-tags .decipher-tags-taglist { 8 | margin-bottom: 4px; 9 | display: inline; 10 | } 11 | .decipher-tags .decipher-tags-taglist .decipher-tags-tag { 12 | display: inline-block; 13 | border-radius: 5px; 14 | background-color: #EFEFEF; 15 | border: 1px solid #DDD; 16 | padding: 2px; 17 | margin-right: 5px; 18 | margin-top: 5px; 19 | } 20 | .decipher-tags .decipher-tags-taglist .decipher-tags-tag i { 21 | color: black; 22 | text-decoration: none; 23 | } 24 | .decipher-tags .decipher-tags-taglist .decipher-tags-tag.selected { 25 | border: 1px solid red; 26 | } 27 | .decipher-tags .decipher-tags-taglist .decipher-tags-tag:hover i { 28 | color: red; 29 | text-decoration: none; 30 | } 31 | .decipher-tags .decipher-tags-taglist .decipher-tags-tag i { 32 | cursor: pointer; 33 | } 34 | .decipher-tags .decipher-tags-input { 35 | border: none; 36 | outline: 0; 37 | } 38 | .decipher-tags .decipher-tags-invalid { 39 | color: #FFF; 40 | } 41 | .decipher-tags .decipher-tags-controls { 42 | margin-top: 8px; 43 | min-height: 28px; 44 | } 45 | -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1.js: -------------------------------------------------------------------------------- 1 | /*global angular*/ 2 | (function () { 3 | 'use strict'; 4 | 5 | try { 6 | angular.module('decipher.tags.templates'); 7 | } catch (e) { 8 | angular.module('decipher.tags.templates', []); 9 | } 10 | 11 | var tags = angular.module('decipher.tags', 12 | ['ui.bootstrap.typeahead', 'decipher.tags.templates']); 13 | 14 | var defaultOptions = { 15 | delimiter: ',', // if given a string model, it splits on this 16 | classes: {} // obj of group names to classes 17 | }, 18 | 19 | // for parsing comprehension expression 20 | SRC_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/, 21 | 22 | // keycodes 23 | kc = { 24 | enter: 13, 25 | esc: 27, 26 | backspace: 8 27 | }, 28 | kcCompleteTag = [kc.enter], 29 | kcRemoveTag = [kc.backspace], 30 | kcCancelInput = [kc.esc], 31 | id = 0; 32 | 33 | tags.constant('decipherTagsOptions', {}); 34 | 35 | /** 36 | * TODO: do we actually share functionality here? We're using this 37 | * controller on both the subdirective and its parent, but I'm not sure 38 | * if we actually use the same functions in both. 39 | */ 40 | tags.controller('TagsCtrl', 41 | ['$scope', '$timeout', '$q', function ($scope, $timeout, $q) { 42 | 43 | /** 44 | * Figures out what classes to put on the tag span. It'll add classes 45 | * if defined by group, and it'll add a selected class if the tag 46 | * is preselected to delete. 47 | * @param tag 48 | * @returns {{}} 49 | */ 50 | $scope.getClasses = function getGroupClass(tag) { 51 | var r = {}; 52 | 53 | if (tag === $scope.toggles.selectedTag) { 54 | r.selected = true; 55 | } 56 | angular.forEach($scope.options.classes, function (klass, groupName) { 57 | if (tag.group === groupName) { 58 | r[klass] = true; 59 | } 60 | }); 61 | return r; 62 | }; 63 | 64 | /** 65 | * Finds a tag in the src list and removes it. 66 | * @param tag 67 | * @returns {boolean} 68 | */ 69 | $scope._filterSrcTags = function filterSrcTags(tag) { 70 | // wrapped in timeout or typeahead becomes confused 71 | return $timeout(function () { 72 | var idx = $scope.srcTags.indexOf(tag); 73 | if (idx >= 0) { 74 | $scope.srcTags.splice(idx, 1); 75 | $scope._deletedSrcTags.push(tag); 76 | return; 77 | } 78 | return $q.reject(); 79 | }); 80 | }; 81 | 82 | /** 83 | * Adds a tag to the list of tags, and if in the typeahead list, 84 | * removes it from that list (and saves it). emits decipher.tags.added 85 | * @param tag 86 | */ 87 | $scope.add = function add(tag) { 88 | var _add = function _add(tag) { 89 | $scope.tags.push(tag); 90 | delete $scope.inputTag; 91 | $scope.$emit('decipher.tags.added', { 92 | tag: tag, 93 | $id: $scope.$id 94 | }); 95 | }, 96 | fail = function fail() { 97 | $scope.$emit('decipher.tags.addfailed', { 98 | tag: tag, 99 | $id: $scope.$id 100 | }); 101 | dfrd.reject(); 102 | }, 103 | i, 104 | dfrd = $q.defer(); 105 | 106 | // don't add dupe names 107 | i = $scope.tags.length; 108 | while (i--) { 109 | if ($scope.tags[i].name === tag.name) { 110 | fail(); 111 | } 112 | } 113 | 114 | $scope._filterSrcTags(tag) 115 | .then(function () { 116 | _add(tag); 117 | }, function () { 118 | if ($scope.options.addable) { 119 | _add(tag); 120 | dfrd.resolve(); 121 | } 122 | else { 123 | fail(); 124 | } 125 | }); 126 | 127 | return dfrd.promise; 128 | }; 129 | 130 | $scope.trust = function(tag) { 131 | return $sce.trustAsHtml(tag.name); 132 | }; 133 | /** 134 | * Toggle the input box active. 135 | */ 136 | $scope.selectArea = function selectArea() { 137 | $scope.toggles.inputActive = true; 138 | }; 139 | 140 | /** 141 | * Removes a tag. Restores stuff into srcTags if it came from there. 142 | * Kills any selected tag. Emit a decipher.tags.removed event. 143 | * @param tag 144 | */ 145 | $scope.remove = function remove(tag) { 146 | var idx; 147 | $scope.tags.splice($scope.tags.indexOf(tag), 1); 148 | 149 | if (idx = $scope._deletedSrcTags.indexOf(tag) >= 0) { 150 | $scope._deletedSrcTags.splice(idx, 1); 151 | if ($scope.srcTags.indexOf(tag) === -1) { 152 | $scope.srcTags.push(tag); 153 | } 154 | } 155 | 156 | delete $scope.toggles.selectedTag; 157 | 158 | $scope.$emit('decipher.tags.removed', { 159 | tag: tag, 160 | $id: $scope.$id 161 | }); 162 | }; 163 | 164 | }]); 165 | 166 | /** 167 | * Directive for the 'input' tag itself, which is of class 168 | * decipher-tags-input. 169 | */ 170 | tags.directive('decipherTagsInput', 171 | ['$timeout', '$filter', '$rootScope', 172 | function ($timeout, $filter, $rootScope) { 173 | return { 174 | restrict: 'C', 175 | require: 'ngModel', 176 | link: function (scope, element, attrs, ngModel) { 177 | var delimiterRx = new RegExp('^' + 178 | scope.options.delimiter + 179 | '+$'), 180 | 181 | /** 182 | * Cancels the text input box. 183 | */ 184 | cancel = function cancel() { 185 | ngModel.$setViewValue(''); 186 | ngModel.$render(); 187 | }, 188 | 189 | /** 190 | * Adds a tag you typed/pasted in unless it's a bunch of delimiters. 191 | * @param value 192 | */ 193 | addTag = function addTag(value) { 194 | if (value) { 195 | if (value.match(delimiterRx)) { 196 | cancel(); 197 | return; 198 | } 199 | if (scope.add({ 200 | name: value 201 | })) { 202 | cancel(); 203 | } 204 | } 205 | }, 206 | 207 | /** 208 | * Adds multiple tags in case you pasted them. 209 | * @param tags 210 | */ 211 | addTags = function (tags) { 212 | var i; 213 | for (i = 0; i < tags.length; 214 | i++) { 215 | addTag(tags[i]); 216 | } 217 | }, 218 | 219 | /** 220 | * Backspace one to select, and a second time to delete. 221 | */ 222 | removeLastTag = function removeLastTag() { 223 | var orderedTags; 224 | if (scope.toggles.selectedTag) { 225 | scope.remove(scope.toggles.selectedTag); 226 | delete scope.toggles.selectedTag; 227 | } 228 | // only do this if the input field is empty. 229 | else if (!ngModel.$viewValue) { 230 | orderedTags = 231 | $filter('orderBy')(scope.tags, 232 | scope.orderBy); 233 | scope.toggles.selectedTag = 234 | orderedTags[orderedTags.length - 1]; 235 | } 236 | }; 237 | 238 | /** 239 | * When we focus the text input area, drop the selected tag 240 | */ 241 | element.bind('focus', function () { 242 | // this avoids what looks like a bug in typeahead. It seems 243 | // to be calling element[0].focus() somewhere within a digest loop. 244 | if ($rootScope.$$phase) { 245 | delete scope.toggles.selectedTag; 246 | } else { 247 | scope.$apply(function () { 248 | delete scope.toggles.selectedTag; 249 | }); 250 | } 251 | }); 252 | 253 | /** 254 | * Detects the delimiter. 255 | */ 256 | element.bind('keypress', 257 | function (evt) { 258 | scope.$apply(function () { 259 | if (scope.options.delimiter.charCodeAt() === 260 | evt.which) { 261 | addTag(ngModel.$viewValue); 262 | } 263 | }); 264 | }); 265 | 266 | /** 267 | * Inspects whatever you typed to see if there were character(s) of 268 | * concern. 269 | */ 270 | element.bind('keydown', 271 | function (evt) { 272 | scope.$apply(function () { 273 | // to "complete" a tag 274 | 275 | if (kcCompleteTag.indexOf(evt.which) >= 276 | 0) { 277 | addTag(ngModel.$viewValue); 278 | 279 | // or if you want to get out of the text area 280 | } else if (kcCancelInput.indexOf(evt.which) >= 281 | 0 && !evt.isPropagationStopped()) { 282 | cancel(); 283 | scope.toggles.inputActive = 284 | false; 285 | 286 | // or if you're trying to delete something 287 | } else if (kcRemoveTag.indexOf(evt.which) >= 288 | 0) { 289 | removeLastTag(); 290 | 291 | // otherwise if we're typing in here, just drop the selected tag. 292 | } else { 293 | delete scope.toggles.selectedTag; 294 | scope.$emit('decipher.tags.keyup', 295 | { 296 | value: ngModel.$viewValue, 297 | $id: scope.$id 298 | }); 299 | } 300 | }); 301 | }); 302 | 303 | /** 304 | * When inputActive toggle changes to true, focus the input. 305 | * And no I have no idea why this has to be in a timeout. 306 | */ 307 | scope.$watch('toggles.inputActive', 308 | function (newVal) { 309 | if (newVal) { 310 | $timeout(function () { 311 | element[0].focus(); 312 | }); 313 | } 314 | }); 315 | 316 | /** 317 | * Detects a paste or someone jamming on the delimiter key. 318 | */ 319 | ngModel.$parsers.unshift(function (value) { 320 | var values = value.split(scope.options.delimiter); 321 | if (values.length > 1) { 322 | addTags(values); 323 | } 324 | if (value.match(delimiterRx)) { 325 | element.val(''); 326 | return; 327 | } 328 | return value; 329 | }); 330 | 331 | /** 332 | * Resets the input field if we selected something from typeahead. 333 | */ 334 | ngModel.$formatters.push(function (tag) { 335 | if (tag && tag.value) { 336 | element.val(''); 337 | return; 338 | } 339 | return tag; 340 | }); 341 | } 342 | }; 343 | }]); 344 | 345 | /** 346 | * Main directive 347 | */ 348 | tags.directive('tags', 349 | ['$document', '$timeout', '$parse', 'decipherTagsOptions', 350 | function ($document, $timeout, $parse, decipherTagsOptions) { 351 | 352 | return { 353 | controller: 'TagsCtrl', 354 | restrict: 'E', 355 | replace: true, 356 | // IE8 is really, really fussy about this. 357 | template: '
', 358 | scope: { 359 | model: '=' 360 | }, 361 | link: function (scope, element, attrs) { 362 | var srcResult, 363 | source, 364 | tags, 365 | group, 366 | i, 367 | tagsWatch, 368 | srcWatch, 369 | modelWatch, 370 | model, 371 | pureStrings = false, 372 | stringArray = false, 373 | defaults = angular.copy(defaultOptions), 374 | userDefaults = angular.copy(decipherTagsOptions), 375 | 376 | /** 377 | * Parses the comprehension expression and gives us interesting bits. 378 | * @param input 379 | * @returns {{itemName: *, source: *, viewMapper: *, modelMapper: *}} 380 | */ 381 | parse = function parse(input) { 382 | var match = input.match(SRC_REGEXP); 383 | if (!match) { 384 | throw new Error( 385 | "Expected src specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" + 386 | " but got '" + input + "'."); 387 | } 388 | 389 | return { 390 | itemName: match[3], 391 | source: $parse(match[4]), 392 | sourceName: match[4], 393 | viewMapper: $parse(match[2] || match[1]), 394 | modelMapper: $parse(match[1]) 395 | }; 396 | 397 | }, 398 | 399 | watchModel = function watchModel() { 400 | modelWatch = scope.$watch('model', function (newVal) { 401 | var deletedTag, idx; 402 | if (angular.isDefined(newVal)) { 403 | tagsWatch(); 404 | scope.tags = format(newVal); 405 | 406 | // remove already used tags 407 | i = scope.tags.length; 408 | while (i--) { 409 | scope._filterSrcTags(scope.tags[i]); 410 | } 411 | 412 | // restore any deleted things to the src array that happen to not 413 | // be in the new value. 414 | i = scope._deletedSrcTags.length; 415 | while (i--) { 416 | deletedTag = scope._deletedSrcTags[i]; 417 | if (idx = newVal.indexOf(deletedTag) === -1 && 418 | scope.srcTags.indexOf(deletedTag) === -1) { 419 | scope.srcTags.push(deletedTag); 420 | scope._deletedSrcTags.splice(i, 1); 421 | } 422 | } 423 | 424 | watchTags(); 425 | } 426 | }, true); 427 | 428 | }, 429 | 430 | watchTags = function watchTags() { 431 | 432 | /** 433 | * Watches tags for changes and propagates to outer model 434 | * in the format which we originally specified (see below) 435 | */ 436 | tagsWatch = scope.$watch('tags', function (value, oldValue) { 437 | var i; 438 | if (value !== oldValue) { 439 | modelWatch(); 440 | if (stringArray || pureStrings) { 441 | value = value.map(function (tag) { 442 | return tag.name; 443 | }); 444 | if (angular.isArray(scope.model)) { 445 | scope.model.length = 0; 446 | for (i = 0; i < value.length; i++) { 447 | scope.model.push(value[i]); 448 | } 449 | } 450 | if (pureStrings) { 451 | scope.model = value.join(scope.options.delimiter); 452 | } 453 | } 454 | else { 455 | scope.model.length = 0; 456 | for (i = 0; i < value.length; i++) { 457 | scope.model.push(value[i]); 458 | } 459 | } 460 | watchModel(); 461 | 462 | } 463 | }, true); 464 | }, 465 | /** 466 | * Takes a raw model value and returns something suitable 467 | * to assign to scope.tags 468 | * @param value 469 | */ 470 | format = function format(value) { 471 | var arr = []; 472 | 473 | if (angular.isUndefined(value)) { 474 | return; 475 | } 476 | if (angular.isString(value)) { 477 | arr = value 478 | .split(scope.options.delimiter) 479 | .map(function (item) { 480 | return { 481 | name: item.trim() 482 | }; 483 | }); 484 | } 485 | else if (angular.isArray(value)) { 486 | arr = value.map(function (item) { 487 | if (angular.isString(item)) { 488 | return { 489 | name: item.trim() 490 | }; 491 | } 492 | else if (item.name) { 493 | item.name = item.name.trim(); 494 | } 495 | return item; 496 | }); 497 | } 498 | else if (angular.isDefined(value)) { 499 | throw 'list of tags must be an array or delimited string'; 500 | } 501 | return arr; 502 | }, 503 | /** 504 | * Updates the source tag information. Sets a watch so we 505 | * know if the source values change. 506 | */ 507 | updateSrc = function updateSrc() { 508 | var locals, 509 | i, 510 | o, 511 | obj; 512 | // default to NOT letting users add new tags in this case. 513 | scope.options.addable = scope.options.addable || false; 514 | scope.srcTags = []; 515 | srcResult = parse(attrs.src); 516 | source = srcResult.source(scope.$parent); 517 | if (angular.isUndefined(source)) { 518 | return; 519 | } 520 | if (angular.isFunction(srcWatch)) { 521 | srcWatch(); 522 | } 523 | locals = {}; 524 | if (angular.isDefined(source)) { 525 | for (i = 0; i < source.length; i++) { 526 | locals[srcResult.itemName] = source[i]; 527 | obj = {}; 528 | obj.value = srcResult.modelMapper(scope.$parent, locals); 529 | o = {}; 530 | if (angular.isObject(obj.value)) { 531 | o = angular.extend(obj.value, { 532 | name: srcResult.viewMapper(scope.$parent, locals), 533 | value: obj.value.value, 534 | group: obj.value.group 535 | }); 536 | } 537 | else { 538 | o = { 539 | name: srcResult.viewMapper(scope.$parent, locals), 540 | value: obj.value, 541 | group: group 542 | }; 543 | } 544 | scope.srcTags.push(o); 545 | } 546 | } 547 | 548 | srcWatch = 549 | scope.$parent.$watch(srcResult.sourceName, 550 | function (newVal, oldVal) { 551 | if (newVal !== oldVal) { 552 | updateSrc(); 553 | } 554 | }, true); 555 | }; 556 | 557 | // merge options 558 | scope.options = angular.extend(defaults, 559 | angular.extend(userDefaults, scope.$eval(attrs.options))); 560 | // break out orderBy for view 561 | scope.orderBy = scope.options.orderBy; 562 | 563 | // this should be named something else since it's just a collection 564 | // of random shit. 565 | scope.toggles = { 566 | inputActive: false 567 | }; 568 | 569 | /** 570 | * When we receive this event, sort. 571 | */ 572 | scope.$on('decipher.tags.sort', function (evt, data) { 573 | scope.orderBy = data; 574 | }); 575 | 576 | // pass typeahead options through 577 | attrs.$observe('typeaheadOptions', function (newVal) { 578 | if (newVal) { 579 | scope.typeaheadOptions = $parse(newVal)(scope.$parent); 580 | } else { 581 | scope.typeaheadOptions = {}; 582 | } 583 | }); 584 | 585 | // determine what format we're in 586 | model = scope.model; 587 | if (angular.isString(model)) { 588 | pureStrings = true; 589 | } 590 | // XXX: avoid for now while fixing "empty array" bug 591 | else if (angular.isArray(model) && false) { 592 | stringArray = true; 593 | i = model.length; 594 | while (i--) { 595 | if (!angular.isString(model[i])) { 596 | stringArray = false; 597 | break; 598 | } 599 | } 600 | } 601 | 602 | // watch model for changes and update tags as appropriate 603 | scope.tags = []; 604 | scope._deletedSrcTags = []; 605 | watchTags(); 606 | watchModel(); 607 | 608 | // this stuff takes the parsed comprehension expression and 609 | // makes a srcTags array full of tag objects out of it. 610 | scope.srcTags = []; 611 | if (angular.isDefined(attrs.src)) { 612 | updateSrc(); 613 | } else { 614 | // if you didn't specify a src, you must be able to type in new tags. 615 | scope.options.addable = true; 616 | } 617 | 618 | // emit identifier 619 | scope.$id = ++id; 620 | scope.$emit('decipher.tags.initialized', { 621 | $id: scope.$id, 622 | model: scope.model 623 | }); 624 | } 625 | }; 626 | }]); 627 | 628 | })(); 629 | -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1.less: -------------------------------------------------------------------------------- 1 | .decipher-tags { 2 | 3 | min-height: 24px; 4 | border: 1px solid #ccc; 5 | margin: 0px 10px; 6 | padding: 0px 5px 5px 5px; 7 | 8 | .decipher-tags-taglist { 9 | margin-bottom: 4px; 10 | display: inline; 11 | 12 | .decipher-tags-tag { 13 | display: inline-block; 14 | border-radius: 5px; 15 | background-color: #EFEFEF; 16 | border: 1px solid #DDD; 17 | padding: 2px; 18 | margin-right: 5px; 19 | margin-top: 5px; 20 | } 21 | 22 | .decipher-tags-tag i { 23 | color: black; 24 | text-decoration: none; 25 | } 26 | 27 | .decipher-tags-tag.selected 28 | { 29 | border: 1px solid red; 30 | } 31 | 32 | .decipher-tags-tag:hover i { 33 | color: red; 34 | 35 | text-decoration: none; 36 | } 37 | 38 | .decipher-tags-tag i { 39 | cursor: pointer; 40 | } 41 | 42 | } 43 | 44 | .decipher-tags-input { 45 | border: none; 46 | outline: 0; 47 | } 48 | 49 | .decipher-tags-invalid { 50 | color: #FFF; 51 | } 52 | 53 | .decipher-tags-controls { 54 | margin-top: 8px; 55 | min-height: 28px; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1.map.js: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"dist/angular-tags-0.3.1.min.js","sources":["generated/tags.js"],"names":["angular","module","e","tags","defaultOptions","delimiter","classes","SRC_REGEXP","kc","enter","esc","backspace","kcCompleteTag","kcRemoveTag","kcCancelInput","id","constant","controller","$scope","$timeout","$q","getClasses","tag","r","toggles","selectedTag","selected","forEach","options","klass","groupName","group","_filterSrcTags","idx","srcTags","indexOf","splice","_deletedSrcTags","push","reject","add","i","_add","inputTag","$emit","$id","fail","dfrd","defer","length","name","then","addable","resolve","promise","trust","$sce","trustAsHtml","selectArea","inputActive","remove","directive","$filter","$rootScope","restrict","require","link","scope","element","attrs","ngModel","delimiterRx","RegExp","cancel","$setViewValue","$render","addTag","value","match","addTags","removeLastTag","orderedTags","$viewValue","orderBy","bind","$$phase","$apply","evt","charCodeAt","which","isPropagationStopped","$watch","newVal","focus","$parsers","unshift","values","split","val","$formatters","$document","$parse","decipherTagsOptions","replace","template","model","srcResult","source","tagsWatch","srcWatch","modelWatch","pureStrings","stringArray","defaults","copy","userDefaults","parse","input","Error","itemName","sourceName","viewMapper","modelMapper","watchModel","deletedTag","isDefined","format","watchTags","oldValue","map","isArray","join","arr","isUndefined","isString","item","trim","updateSrc","locals","o","obj","src","$parent","isFunction","isObject","extend","oldVal","$eval","$on","data","$observe","typeaheadOptions"],"mappings":"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","sourceRoot":"/"} -------------------------------------------------------------------------------- /dist/angular-tags-0.3.1.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";try{angular.module("decipher.tags.templates")}catch(a){angular.module("decipher.tags.templates",[])}var b=angular.module("decipher.tags",["ui.bootstrap.typeahead","decipher.tags.templates"]),c={delimiter:",",classes:{}},d=/^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/,e={enter:13,esc:27,backspace:8},f=[e.enter],g=[e.backspace],h=[e.esc],i=0;b.constant("decipherTagsOptions",{}),b.controller("TagsCtrl",["$scope","$timeout","$q",function(a,b,c){a.getClasses=function(b){var c={};return b===a.toggles.selectedTag&&(c.selected=!0),angular.forEach(a.options.classes,function(a,d){b.group===d&&(c[a]=!0)}),c},a._filterSrcTags=function(d){return b(function(){var b=a.srcTags.indexOf(d);return b>=0?(a.srcTags.splice(b,1),a._deletedSrcTags.push(d),void 0):c.reject()})},a.add=function(b){var d,e=function(b){a.tags.push(b),delete a.inputTag,a.$emit("decipher.tags.added",{tag:b,$id:a.$id})},f=function(){a.$emit("decipher.tags.addfailed",{tag:b,$id:a.$id}),g.reject()},g=c.defer();for(d=a.tags.length;d--;)a.tags[d].name===b.name&&f();return a._filterSrcTags(b).then(function(){e(b)},function(){a.options.addable?(e(b),g.resolve()):f()}),g.promise},a.trust=function(a){return $sce.trustAsHtml(a.name)},a.selectArea=function(){a.toggles.inputActive=!0},a.remove=function(b){var c;a.tags.splice(a.tags.indexOf(b),1),(c=a._deletedSrcTags.indexOf(b)>=0)&&(a._deletedSrcTags.splice(c,1),-1===a.srcTags.indexOf(b)&&a.srcTags.push(b)),delete a.toggles.selectedTag,a.$emit("decipher.tags.removed",{tag:b,$id:a.$id})}}]),b.directive("decipherTagsInput",["$timeout","$filter","$rootScope",function(a,b,c){return{restrict:"C",require:"ngModel",link:function(d,e,i,j){var k=new RegExp("^"+d.options.delimiter+"+$"),l=function(){j.$setViewValue(""),j.$render()},m=function(a){if(a){if(a.match(k))return l(),void 0;d.add({name:a})&&l()}},n=function(a){var b;for(b=0;b=0?m(j.$viewValue):h.indexOf(a.which)>=0&&!a.isPropagationStopped()?(l(),d.toggles.inputActive=!1):g.indexOf(a.which)>=0?o():(delete d.toggles.selectedTag,d.$emit("decipher.tags.keyup",{value:j.$viewValue,$id:d.$id}))})}),d.$watch("toggles.inputActive",function(b){b&&a(function(){e[0].focus()})}),j.$parsers.unshift(function(a){var b=a.split(d.options.delimiter);return b.length>1&&n(b),a.match(k)?(e.val(""),void 0):a}),j.$formatters.push(function(a){return a&&a.value?(e.val(""),void 0):a})}}}]),b.directive("tags",["$document","$timeout","$parse","decipherTagsOptions",function(a,b,e,f){return{controller:"TagsCtrl",restrict:"E",replace:!0,template:"
",scope:{model:"="},link:function(a,b,g){var h,j,k,l,m,n,o,p,q=!1,r=!1,s=angular.copy(c),t=angular.copy(f),u=function(a){var b=a.match(d);if(!b)throw new Error("Expected src specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_' but got '"+a+"'.");return{itemName:b[3],source:e(b[4]),sourceName:b[4],viewMapper:e(b[2]||b[1]),modelMapper:e(b[1])}},v=function(){o=a.$watch("model",function(b){var c,d;if(angular.isDefined(b)){for(m(),a.tags=x(b),l=a.tags.length;l--;)a._filterSrcTags(a.tags[l]);for(l=a._deletedSrcTags.length;l--;)c=a._deletedSrcTags[l],(d=-1===b.indexOf(c)&&-1===a.srcTags.indexOf(c))&&(a.srcTags.push(c),a._deletedSrcTags.splice(l,1));w()}},!0)},w=function(){m=a.$watch("tags",function(b,c){var d;if(b!==c){if(o(),r||q){if(b=b.map(function(a){return a.name}),angular.isArray(a.model))for(a.model.length=0,d=0;d 2 | 3 |
4 | 6 | {{tag.name}} 7 | 8 | 9 | 10 |
11 | 12 | 13 | 15 | 17 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /less/tags.less: -------------------------------------------------------------------------------- 1 | .decipher-tags { 2 | 3 | min-height: 24px; 4 | border: 1px solid #ccc; 5 | margin: 0px 10px; 6 | padding: 0px 5px 5px 5px; 7 | 8 | .decipher-tags-taglist { 9 | margin-bottom: 4px; 10 | display: inline; 11 | 12 | .decipher-tags-tag { 13 | display: inline-block; 14 | border-radius: 5px; 15 | background-color: #EFEFEF; 16 | border: 1px solid #DDD; 17 | padding: 2px; 18 | margin-right: 5px; 19 | margin-top: 5px; 20 | } 21 | 22 | .decipher-tags-tag i { 23 | color: black; 24 | text-decoration: none; 25 | } 26 | 27 | .decipher-tags-tag.selected 28 | { 29 | border: 1px solid red; 30 | } 31 | 32 | .decipher-tags-tag:hover i { 33 | color: red; 34 | 35 | text-decoration: none; 36 | } 37 | 38 | .decipher-tags-tag i { 39 | cursor: pointer; 40 | } 41 | 42 | } 43 | 44 | .decipher-tags-input { 45 | border: none; 46 | outline: 0; 47 | } 48 | 49 | .decipher-tags-invalid { 50 | color: #FFF; 51 | } 52 | 53 | .decipher-tags-controls { 54 | margin-top: 8px; 55 | min-height: 28px; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-tags", 3 | "version": "0.3.1", 4 | "repository": "git://github.com/decipherinc/angular-tags.git", 5 | "devDependencies": { 6 | "grunt-contrib-connect": "~0.4.1", 7 | "grunt-contrib-qunit": "~0.2.2", 8 | "grunt-bower-task": "~0.3.1", 9 | "grunt-contrib-watch": "~0.5.3", 10 | "grunt-cli": "~0.1.9", 11 | "grunt-html2js": "~0.1.7", 12 | "grunt-contrib-less": "~0.7.0", 13 | "grunt-contrib-uglify": "~0.2.4", 14 | "grunt-ngmin": "0.0.3", 15 | "grunt-contrib-concat": "~0.3.0", 16 | "grunt-contrib-copy": "~0.4.1" 17 | }, 18 | "scripts": { 19 | "test": "grunt test" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/tags.js: -------------------------------------------------------------------------------- 1 | /*global angular*/ 2 | (function () { 3 | 'use strict'; 4 | 5 | try { 6 | angular.module('decipher.tags.templates'); 7 | } catch (e) { 8 | angular.module('decipher.tags.templates', []); 9 | } 10 | 11 | var tags = angular.module('decipher.tags', 12 | ['ui.bootstrap.typeahead', 'decipher.tags.templates']); 13 | 14 | var defaultOptions = { 15 | delimiter: ',', // if given a string model, it splits on this 16 | classes: {} // obj of group names to classes 17 | }, 18 | 19 | // for parsing comprehension expression 20 | SRC_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/, 21 | 22 | // keycodes 23 | kc = { 24 | enter: 13, 25 | esc: 27, 26 | backspace: 8 27 | }, 28 | kcCompleteTag = [kc.enter], 29 | kcRemoveTag = [kc.backspace], 30 | kcCancelInput = [kc.esc], 31 | id = 0; 32 | 33 | tags.constant('decipherTagsOptions', {}); 34 | 35 | /** 36 | * TODO: do we actually share functionality here? We're using this 37 | * controller on both the subdirective and its parent, but I'm not sure 38 | * if we actually use the same functions in both. 39 | */ 40 | tags.controller('TagsCtrl', 41 | ['$scope', '$timeout', '$q', function ($scope, $timeout, $q) { 42 | 43 | /** 44 | * Figures out what classes to put on the tag span. It'll add classes 45 | * if defined by group, and it'll add a selected class if the tag 46 | * is preselected to delete. 47 | * @param tag 48 | * @returns {{}} 49 | */ 50 | $scope.getClasses = function getGroupClass(tag) { 51 | var r = {}; 52 | 53 | if (tag === $scope.toggles.selectedTag) { 54 | r.selected = true; 55 | } 56 | angular.forEach($scope.options.classes, function (klass, groupName) { 57 | if (tag.group === groupName) { 58 | r[klass] = true; 59 | } 60 | }); 61 | return r; 62 | }; 63 | 64 | /** 65 | * Finds a tag in the src list and removes it. 66 | * @param tag 67 | * @returns {boolean} 68 | */ 69 | $scope._filterSrcTags = function filterSrcTags(tag) { 70 | // wrapped in timeout or typeahead becomes confused 71 | return $timeout(function () { 72 | var idx = $scope.srcTags.indexOf(tag); 73 | if (idx >= 0) { 74 | $scope.srcTags.splice(idx, 1); 75 | $scope._deletedSrcTags.push(tag); 76 | return; 77 | } 78 | return $q.reject(); 79 | }); 80 | }; 81 | 82 | /** 83 | * Adds a tag to the list of tags, and if in the typeahead list, 84 | * removes it from that list (and saves it). emits decipher.tags.added 85 | * @param tag 86 | */ 87 | $scope.add = function add(tag) { 88 | var _add = function _add(tag) { 89 | $scope.tags.push(tag); 90 | delete $scope.inputTag; 91 | $scope.$emit('decipher.tags.added', { 92 | tag: tag, 93 | $id: $scope.$id 94 | }); 95 | }, 96 | fail = function fail() { 97 | $scope.$emit('decipher.tags.addfailed', { 98 | tag: tag, 99 | $id: $scope.$id 100 | }); 101 | dfrd.reject(); 102 | }, 103 | i, 104 | dfrd = $q.defer(); 105 | 106 | // don't add dupe names 107 | i = $scope.tags.length; 108 | while (i--) { 109 | if ($scope.tags[i].name === tag.name) { 110 | fail(); 111 | } 112 | } 113 | 114 | $scope._filterSrcTags(tag) 115 | .then(function () { 116 | _add(tag); 117 | }, function () { 118 | if ($scope.options.addable) { 119 | _add(tag); 120 | dfrd.resolve(); 121 | } 122 | else { 123 | fail(); 124 | } 125 | }); 126 | 127 | return dfrd.promise; 128 | }; 129 | 130 | $scope.trust = function(tag) { 131 | return $sce.trustAsHtml(tag.name); 132 | }; 133 | /** 134 | * Toggle the input box active. 135 | */ 136 | $scope.selectArea = function selectArea() { 137 | $scope.toggles.inputActive = true; 138 | }; 139 | 140 | /** 141 | * Removes a tag. Restores stuff into srcTags if it came from there. 142 | * Kills any selected tag. Emit a decipher.tags.removed event. 143 | * @param tag 144 | */ 145 | $scope.remove = function remove(tag) { 146 | var idx; 147 | $scope.tags.splice($scope.tags.indexOf(tag), 1); 148 | 149 | if (idx = $scope._deletedSrcTags.indexOf(tag) >= 0) { 150 | $scope._deletedSrcTags.splice(idx, 1); 151 | if ($scope.srcTags.indexOf(tag) === -1) { 152 | $scope.srcTags.push(tag); 153 | } 154 | } 155 | 156 | delete $scope.toggles.selectedTag; 157 | 158 | $scope.$emit('decipher.tags.removed', { 159 | tag: tag, 160 | $id: $scope.$id 161 | }); 162 | }; 163 | 164 | }]); 165 | 166 | /** 167 | * Directive for the 'input' tag itself, which is of class 168 | * decipher-tags-input. 169 | */ 170 | tags.directive('decipherTagsInput', 171 | ['$timeout', '$filter', '$rootScope', 172 | function ($timeout, $filter, $rootScope) { 173 | return { 174 | restrict: 'C', 175 | require: 'ngModel', 176 | link: function (scope, element, attrs, ngModel) { 177 | var delimiterRx = new RegExp('^' + 178 | scope.options.delimiter + 179 | '+$'), 180 | 181 | /** 182 | * Cancels the text input box. 183 | */ 184 | cancel = function cancel() { 185 | ngModel.$setViewValue(''); 186 | ngModel.$render(); 187 | }, 188 | 189 | /** 190 | * Adds a tag you typed/pasted in unless it's a bunch of delimiters. 191 | * @param value 192 | */ 193 | addTag = function addTag(value) { 194 | if (value) { 195 | if (value.match(delimiterRx)) { 196 | cancel(); 197 | return; 198 | } 199 | if (scope.add({ 200 | name: value 201 | })) { 202 | cancel(); 203 | } 204 | } 205 | }, 206 | 207 | /** 208 | * Adds multiple tags in case you pasted them. 209 | * @param tags 210 | */ 211 | addTags = function (tags) { 212 | var i; 213 | for (i = 0; i < tags.length; 214 | i++) { 215 | addTag(tags[i]); 216 | } 217 | }, 218 | 219 | /** 220 | * Backspace one to select, and a second time to delete. 221 | */ 222 | removeLastTag = function removeLastTag() { 223 | var orderedTags; 224 | if (scope.toggles.selectedTag) { 225 | scope.remove(scope.toggles.selectedTag); 226 | delete scope.toggles.selectedTag; 227 | } 228 | // only do this if the input field is empty. 229 | else if (!ngModel.$viewValue) { 230 | orderedTags = 231 | $filter('orderBy')(scope.tags, 232 | scope.orderBy); 233 | scope.toggles.selectedTag = 234 | orderedTags[orderedTags.length - 1]; 235 | } 236 | }; 237 | 238 | /** 239 | * When we focus the text input area, drop the selected tag 240 | */ 241 | element.bind('focus', function () { 242 | // this avoids what looks like a bug in typeahead. It seems 243 | // to be calling element[0].focus() somewhere within a digest loop. 244 | if ($rootScope.$$phase) { 245 | delete scope.toggles.selectedTag; 246 | } else { 247 | scope.$apply(function () { 248 | delete scope.toggles.selectedTag; 249 | }); 250 | } 251 | }); 252 | 253 | /** 254 | * Detects the delimiter. 255 | */ 256 | element.bind('keypress', 257 | function (evt) { 258 | scope.$apply(function () { 259 | if (scope.options.delimiter.charCodeAt() === 260 | evt.which) { 261 | addTag(ngModel.$viewValue); 262 | } 263 | }); 264 | }); 265 | 266 | /** 267 | * Inspects whatever you typed to see if there were character(s) of 268 | * concern. 269 | */ 270 | element.bind('keydown', 271 | function (evt) { 272 | scope.$apply(function () { 273 | // to "complete" a tag 274 | 275 | if (kcCompleteTag.indexOf(evt.which) >= 276 | 0) { 277 | addTag(ngModel.$viewValue); 278 | 279 | // or if you want to get out of the text area 280 | } else if (kcCancelInput.indexOf(evt.which) >= 281 | 0 && !evt.isPropagationStopped()) { 282 | cancel(); 283 | scope.toggles.inputActive = 284 | false; 285 | 286 | // or if you're trying to delete something 287 | } else if (kcRemoveTag.indexOf(evt.which) >= 288 | 0) { 289 | removeLastTag(); 290 | 291 | // otherwise if we're typing in here, just drop the selected tag. 292 | } else { 293 | delete scope.toggles.selectedTag; 294 | scope.$emit('decipher.tags.keyup', 295 | { 296 | value: ngModel.$viewValue, 297 | $id: scope.$id 298 | }); 299 | } 300 | }); 301 | }); 302 | 303 | /** 304 | * When inputActive toggle changes to true, focus the input. 305 | * And no I have no idea why this has to be in a timeout. 306 | */ 307 | scope.$watch('toggles.inputActive', 308 | function (newVal) { 309 | if (newVal) { 310 | $timeout(function () { 311 | element[0].focus(); 312 | }); 313 | } 314 | }); 315 | 316 | /** 317 | * Detects a paste or someone jamming on the delimiter key. 318 | */ 319 | ngModel.$parsers.unshift(function (value) { 320 | var values = value.split(scope.options.delimiter); 321 | if (values.length > 1) { 322 | addTags(values); 323 | } 324 | if (value.match(delimiterRx)) { 325 | element.val(''); 326 | return; 327 | } 328 | return value; 329 | }); 330 | 331 | /** 332 | * Resets the input field if we selected something from typeahead. 333 | */ 334 | ngModel.$formatters.push(function (tag) { 335 | if (tag && tag.value) { 336 | element.val(''); 337 | return; 338 | } 339 | return tag; 340 | }); 341 | } 342 | }; 343 | }]); 344 | 345 | /** 346 | * Main directive 347 | */ 348 | tags.directive('tags', 349 | ['$document', '$timeout', '$parse', 'decipherTagsOptions', 350 | function ($document, $timeout, $parse, decipherTagsOptions) { 351 | 352 | return { 353 | controller: 'TagsCtrl', 354 | restrict: 'E', 355 | replace: true, 356 | // IE8 is really, really fussy about this. 357 | template: '
', 358 | scope: { 359 | model: '=' 360 | }, 361 | link: function (scope, element, attrs) { 362 | var srcResult, 363 | source, 364 | tags, 365 | group, 366 | i, 367 | tagsWatch, 368 | srcWatch, 369 | modelWatch, 370 | model, 371 | pureStrings = false, 372 | stringArray = false, 373 | defaults = angular.copy(defaultOptions), 374 | userDefaults = angular.copy(decipherTagsOptions), 375 | 376 | /** 377 | * Parses the comprehension expression and gives us interesting bits. 378 | * @param input 379 | * @returns {{itemName: *, source: *, viewMapper: *, modelMapper: *}} 380 | */ 381 | parse = function parse(input) { 382 | var match = input.match(SRC_REGEXP); 383 | if (!match) { 384 | throw new Error( 385 | "Expected src specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" + 386 | " but got '" + input + "'."); 387 | } 388 | 389 | return { 390 | itemName: match[3], 391 | source: $parse(match[4]), 392 | sourceName: match[4], 393 | viewMapper: $parse(match[2] || match[1]), 394 | modelMapper: $parse(match[1]) 395 | }; 396 | 397 | }, 398 | 399 | watchModel = function watchModel() { 400 | modelWatch = scope.$watch('model', function (newVal) { 401 | var deletedTag, idx; 402 | if (angular.isDefined(newVal)) { 403 | tagsWatch(); 404 | scope.tags = format(newVal); 405 | 406 | // remove already used tags 407 | i = scope.tags.length; 408 | while (i--) { 409 | scope._filterSrcTags(scope.tags[i]); 410 | } 411 | 412 | // restore any deleted things to the src array that happen to not 413 | // be in the new value. 414 | i = scope._deletedSrcTags.length; 415 | while (i--) { 416 | deletedTag = scope._deletedSrcTags[i]; 417 | if (idx = newVal.indexOf(deletedTag) === -1 && 418 | scope.srcTags.indexOf(deletedTag) === -1) { 419 | scope.srcTags.push(deletedTag); 420 | scope._deletedSrcTags.splice(i, 1); 421 | } 422 | } 423 | 424 | watchTags(); 425 | } 426 | }, true); 427 | 428 | }, 429 | 430 | watchTags = function watchTags() { 431 | 432 | /** 433 | * Watches tags for changes and propagates to outer model 434 | * in the format which we originally specified (see below) 435 | */ 436 | tagsWatch = scope.$watch('tags', function (value, oldValue) { 437 | var i; 438 | if (value !== oldValue) { 439 | modelWatch(); 440 | if (stringArray || pureStrings) { 441 | value = value.map(function (tag) { 442 | return tag.name; 443 | }); 444 | if (angular.isArray(scope.model)) { 445 | scope.model.length = 0; 446 | for (i = 0; i < value.length; i++) { 447 | scope.model.push(value[i]); 448 | } 449 | } 450 | if (pureStrings) { 451 | scope.model = value.join(scope.options.delimiter); 452 | } 453 | } 454 | else { 455 | scope.model.length = 0; 456 | for (i = 0; i < value.length; i++) { 457 | scope.model.push(value[i]); 458 | } 459 | } 460 | watchModel(); 461 | 462 | } 463 | }, true); 464 | }, 465 | /** 466 | * Takes a raw model value and returns something suitable 467 | * to assign to scope.tags 468 | * @param value 469 | */ 470 | format = function format(value) { 471 | var arr = []; 472 | 473 | if (angular.isUndefined(value)) { 474 | return; 475 | } 476 | if (angular.isString(value)) { 477 | arr = value 478 | .split(scope.options.delimiter) 479 | .map(function (item) { 480 | return { 481 | name: item.trim() 482 | }; 483 | }); 484 | } 485 | else if (angular.isArray(value)) { 486 | arr = value.map(function (item) { 487 | if (angular.isString(item)) { 488 | return { 489 | name: item.trim() 490 | }; 491 | } 492 | else if (item.name) { 493 | item.name = item.name.trim(); 494 | } 495 | return item; 496 | }); 497 | } 498 | else if (angular.isDefined(value)) { 499 | throw 'list of tags must be an array or delimited string'; 500 | } 501 | return arr; 502 | }, 503 | /** 504 | * Updates the source tag information. Sets a watch so we 505 | * know if the source values change. 506 | */ 507 | updateSrc = function updateSrc() { 508 | var locals, 509 | i, 510 | o, 511 | obj; 512 | // default to NOT letting users add new tags in this case. 513 | scope.options.addable = scope.options.addable || false; 514 | scope.srcTags = []; 515 | srcResult = parse(attrs.src); 516 | source = srcResult.source(scope.$parent); 517 | if (angular.isUndefined(source)) { 518 | return; 519 | } 520 | if (angular.isFunction(srcWatch)) { 521 | srcWatch(); 522 | } 523 | locals = {}; 524 | if (angular.isDefined(source)) { 525 | for (i = 0; i < source.length; i++) { 526 | locals[srcResult.itemName] = source[i]; 527 | obj = {}; 528 | obj.value = srcResult.modelMapper(scope.$parent, locals); 529 | o = {}; 530 | if (angular.isObject(obj.value)) { 531 | o = angular.extend(obj.value, { 532 | name: srcResult.viewMapper(scope.$parent, locals), 533 | value: obj.value.value, 534 | group: obj.value.group 535 | }); 536 | } 537 | else { 538 | o = { 539 | name: srcResult.viewMapper(scope.$parent, locals), 540 | value: obj.value, 541 | group: group 542 | }; 543 | } 544 | scope.srcTags.push(o); 545 | } 546 | } 547 | 548 | srcWatch = 549 | scope.$parent.$watch(srcResult.sourceName, 550 | function (newVal, oldVal) { 551 | if (newVal !== oldVal) { 552 | updateSrc(); 553 | } 554 | }, true); 555 | }; 556 | 557 | // merge options 558 | scope.options = angular.extend(defaults, 559 | angular.extend(userDefaults, scope.$eval(attrs.options))); 560 | // break out orderBy for view 561 | scope.orderBy = scope.options.orderBy; 562 | 563 | // this should be named something else since it's just a collection 564 | // of random shit. 565 | scope.toggles = { 566 | inputActive: false 567 | }; 568 | 569 | /** 570 | * When we receive this event, sort. 571 | */ 572 | scope.$on('decipher.tags.sort', function (evt, data) { 573 | scope.orderBy = data; 574 | }); 575 | 576 | // pass typeahead options through 577 | attrs.$observe('typeaheadOptions', function (newVal) { 578 | if (newVal) { 579 | scope.typeaheadOptions = $parse(newVal)(scope.$parent); 580 | } else { 581 | scope.typeaheadOptions = {}; 582 | } 583 | }); 584 | 585 | // determine what format we're in 586 | model = scope.model; 587 | if (angular.isString(model)) { 588 | pureStrings = true; 589 | } 590 | // XXX: avoid for now while fixing "empty array" bug 591 | else if (angular.isArray(model) && false) { 592 | stringArray = true; 593 | i = model.length; 594 | while (i--) { 595 | if (!angular.isString(model[i])) { 596 | stringArray = false; 597 | break; 598 | } 599 | } 600 | } 601 | 602 | // watch model for changes and update tags as appropriate 603 | scope.tags = []; 604 | scope._deletedSrcTags = []; 605 | watchTags(); 606 | watchModel(); 607 | 608 | // this stuff takes the parsed comprehension expression and 609 | // makes a srcTags array full of tag objects out of it. 610 | scope.srcTags = []; 611 | if (angular.isDefined(attrs.src)) { 612 | updateSrc(); 613 | } else { 614 | // if you didn't specify a src, you must be able to type in new tags. 615 | scope.options.addable = true; 616 | } 617 | 618 | // emit identifier 619 | scope.$id = ++id; 620 | scope.$emit('decipher.tags.initialized', { 621 | $id: scope.$id, 622 | model: scope.model 623 | }); 624 | } 625 | }; 626 | }]); 627 | 628 | })(); 629 | -------------------------------------------------------------------------------- /templates/tags.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 6 | {{tag.name}} 7 | 8 | 9 | 10 |
11 | 12 | 13 | 15 | 17 | 28 | 29 | 30 |
31 | -------------------------------------------------------------------------------- /test/test-tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angular-tags tests 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /test/test-tags.js: -------------------------------------------------------------------------------- 1 | /*global angular, sinon, QUnit, $*/ 2 | (function () { 3 | 'use strict'; 4 | 5 | var Q = QUnit; 6 | 7 | function getArgs(spy, num) { 8 | return JSON.stringify(spy.getCall(num).args); 9 | } 10 | 11 | var init = { 12 | setup: function () { 13 | var $injector = angular.injector( 14 | ['ng', 15 | 'decipher.tags', 16 | 'ngMock', 17 | 'decipher.tags.templates', 18 | 'template/typeahead/typeahead-popup.html']); 19 | 20 | this.$rootScope = $injector.get('$rootScope'); 21 | this.$log = $injector.get('$log'); 22 | this.$compile = $injector.get('$compile'); 23 | this.$templateCache = $injector.get('$templateCache'); 24 | this.scope = this.$rootScope.$new(); 25 | this.$timeout = $injector.get('$timeout'); 26 | 27 | this.sandbox = sinon.sandbox.create('taglist'); 28 | 29 | }, 30 | teardown: function () { 31 | 32 | this.sandbox.restore(); 33 | } 34 | }; 35 | 36 | Q.module('tags directive', init); 37 | 38 | Q.test('taglist', function () { 39 | var scope = this.scope, 40 | $compile = this.$compile, 41 | markup, 42 | tpl, 43 | $timeout = this.$timeout; 44 | 45 | markup = '' 46 | scope.$apply(function () { 47 | scope.foo = 'lizards, people'; 48 | tpl = $compile(markup)(scope); 49 | }); 50 | 51 | Q.equal(tpl.find('.decipher-tags-taglist').children().length, 2, 52 | 'formatter works; we have two child tags shown'); 53 | 54 | // convert to json via angular to omit $$hashKey 55 | Q.equal(angular.toJson(tpl.scope().tags), angular.toJson([ 56 | {name: 'lizards'}, 57 | {name: 'people'} 58 | ]), 'tags are as expected'); 59 | 60 | Q.deepEqual(tpl.scope().srcTags, [], 'no src tags'); 61 | 62 | Q.equal('lizards, people', scope.foo, 'model is untouched'); 63 | 64 | scope.$apply(function () { 65 | scope.foo = ['frogs', 'geese']; 66 | tpl = $compile(markup)(scope); 67 | }); 68 | 69 | Q.equal(tpl.find('.decipher-tags-taglist').children().length, 2, 70 | 'an array of strings works fine'); 71 | 72 | Q.equal(angular.toJson(tpl.scope().tags), angular.toJson([ 73 | {name: 'frogs'}, 74 | {name: 'geese'} 75 | ]), 'tags are as expected'); 76 | 77 | 78 | scope.$apply(function () { 79 | scope.foo = [ 80 | {name: 'mice'}, 81 | {name: 'ampers&'} 82 | ]; 83 | tpl = $compile(markup)(scope); 84 | }); 85 | 86 | Q.equal(tpl.find('.decipher-tags-taglist').children().length, 2, 87 | 'an array of objects works as well'); 88 | 89 | Q.equal(angular.toJson(tpl.scope().tags), angular.toJson([ 90 | {name: 'mice'}, 91 | {name: 'ampers&'} 92 | ]), 'tags are as expected'); 93 | 94 | markup = 95 | '' 96 | scope.$apply(function () { 97 | scope.foo = [ 98 | {name: 'owls', group: 'group'}, 99 | {name: 'cheese', group: 'group'} 100 | ]; 101 | tpl = $compile(markup)(scope); 102 | }); 103 | 104 | Q.equal(tpl.find('.groupClass').length, 105 | 2, 'group classes get set'); 106 | 107 | Q.equal(angular.toJson(tpl.scope().tags), angular.toJson([ 108 | {name: 'owls', group: 'group'}, 109 | {name: 'cheese', group: 'group'} 110 | ]), 'tags are as expected'); 111 | 112 | tpl.find('.icon-remove').click(); 113 | 114 | Q.equal(tpl.find('.decipher-tags-taglist').children().length, 0, 115 | 'remove button works'); 116 | 117 | // assert sorting works 118 | scope.$apply(function () { 119 | scope.foo = [ 120 | {name: 'owls', group: 'group'}, 121 | {name: 'cheese', group: 'group'} 122 | ]; 123 | tpl = $compile(markup)(scope); 124 | 125 | scope.$broadcast('decipher.tags.sort', 'name'); 126 | }); 127 | 128 | Q.equal(tpl.find('.decipher-tags-tag:first').text().trim(), 'cheese', 129 | 'we sorted since "cheese" is first'); 130 | 131 | scope.$apply(function() { 132 | tpl.scope().tags = [scope.foo[1]]; 133 | }); 134 | 135 | Q.strictEqual(scope.foo[0], tpl.scope().tags[0], 'tags updates the model'); 136 | Q.equal(scope.foo.length, 1, 'only "cheese" in the model'); 137 | 138 | // let's play with src 139 | markup = ''; 140 | Q.raises(function () { 141 | $compile(markup)(scope); 142 | }, 'error thrown if bad src'); 143 | 144 | chickens = {value: 1, name: 'chickens', foo: 'bar'}; 145 | markup = 146 | ''; 147 | scope.$apply(function () { 148 | scope.foo = [chickens]; 149 | scope.stuff = [ 150 | chickens, 151 | {value: 2, name: 'steer', foo: 'baz'} 152 | ]; 153 | tpl = $compile(markup)(scope); 154 | }); 155 | $timeout.flush(); 156 | 157 | Q.equal(tpl.scope().srcTags.indexOf(chickens), -1, 158 | 'srctags have no "chickens"'); 159 | $timeout.verifyNoPendingTasks(); 160 | 161 | var chickens = {value: 1, name: 'chickens', foo: 'bar'}; 162 | var frogs = {value: 3, name: 'frogs', foo: 'spam'}; 163 | markup = 164 | ''; 165 | scope.$apply(function () { 166 | scope.foo = [chickens, frogs]; 167 | scope.stuff = [ 168 | chickens, 169 | {value: 2, name: 'steer', foo: 'baz'} 170 | ]; 171 | tpl = $compile(markup)(scope); 172 | }); 173 | $timeout.flush(); 174 | Q.strictEqual(tpl.scope()._deletedSrcTags[0], chickens, 175 | 'assert chickens wound up in deletedSrcTags'); 176 | 177 | Q.strictEqual(tpl.scope().tags[0], chickens, 178 | 'tags is just "chickens"'); 179 | scope.$apply(function () { 180 | scope.foo = [frogs]; 181 | }); 182 | 183 | Q.strictEqual(tpl.scope().tags[0], frogs, 'tags contains only "frogs"'); 184 | Q.equal(JSON.stringify(tpl.scope()._deletedSrcTags), JSON.stringify([]), 185 | 'assert deletedSrcTags is empty'); 186 | 187 | Q.equal(angular.toJson(tpl.scope().srcTags), angular.toJson([ 188 | {value: 2, name: 'steer', foo: 'baz'}, 189 | {value: 1, name: 'chickens', foo: 'bar'} 190 | ]), 'srcTags have all the things'); 191 | 192 | scope.$apply(function () { 193 | scope.foo = [chickens]; 194 | }); 195 | $timeout.flush(); 196 | Q.equal(angular.toJson(tpl.scope().srcTags), angular.toJson([ 197 | {value: 2, name: 'steer', foo: 'baz'} 198 | ]), 'srcTags has no "chickens"'); 199 | 200 | scope.$apply('stuff = []'); 201 | 202 | Q.deepEqual(tpl.scope().srcTags, [], 'srcTags is now empty'); 203 | 204 | markup = 205 | ''; 206 | scope.$apply(function () { 207 | scope.minLength = 3; 208 | scope.foo = [ 209 | {name: 'owls', group: 'group'}, 210 | {name: 'cheese', group: 'group'} 211 | ]; 212 | scope.stuff = [ 213 | {value: 1, name: 'chickens', foo: 'bar'}, 214 | {value: 2, name: 'steer', foo: 'baz'} 215 | ]; 216 | tpl = $compile(markup)(scope); 217 | }); 218 | 219 | Q.deepEqual(tpl.scope().srcTags, [ 220 | { 221 | "group": undefined, 222 | "name": "chickens", 223 | "value": 1, 224 | "foo": "bar" 225 | }, 226 | { 227 | "group": undefined, 228 | "name": "steer", 229 | "value": 2, 230 | "foo": "baz" 231 | } 232 | ], 'src tags are parsed correctly'); 233 | 234 | 235 | Q.equal(tpl.find('.typeahead').length, 1, 236 | 'typeahead popup is injected into DOM'); 237 | 238 | Q.equal(tpl.find('input').attr('data-typeahead-min-length'), '3', 239 | 'assert min length made it into typeahead options'); 240 | 241 | // since it's a bitch to try and use jquery to work with typeahead 242 | // just pretend it's there and run the cb. 243 | 244 | // reset this since selectarea should make it true 245 | scope.$apply('toggles.inputActive = false'); 246 | 247 | scope.$apply(function () { 248 | tpl.scope().add(tpl.scope().srcTags[0]); 249 | tpl.scope().selectArea(); 250 | 251 | }); 252 | $timeout.flush(); 253 | 254 | Q.deepEqual(angular.toJson(tpl.scope().tags), angular.toJson([ 255 | { 256 | "name": "owls", 257 | "group": "group" 258 | }, 259 | { 260 | "name": "cheese", 261 | "group": "group" 262 | }, 263 | { 264 | "value": 1, 265 | "name": "chickens", 266 | "foo": "bar" 267 | } 268 | ]), 'tags now include "chickens"'); 269 | 270 | Q.equal(tpl.scope().srcTags.length, 1, '"chickens" removed from srcTags'); 271 | Q.ok(tpl.scope().toggles.inputActive, 'input is active'); 272 | 273 | scope.$apply(function() { 274 | tpl.scope().remove(tpl.scope().tags[2]); 275 | }); 276 | 277 | Q.deepEqual(angular.toJson(tpl.scope().srcTags), angular.toJson([ 278 | 279 | { 280 | "value": 2, 281 | "name": "steer", 282 | "foo": "baz" 283 | }, 284 | { 285 | "value": 1, 286 | "name": "chickens", 287 | "foo": "bar" 288 | } 289 | ]), 'src tags are restored correctly'); 290 | 291 | 292 | 293 | 294 | }); 295 | 296 | 297 | })(); 298 | --------------------------------------------------------------------------------