├── templates ├── auto-complete-match.html ├── tag-item.html ├── auto-complete.html └── tags-input.html ├── .gitignore ├── grunt ├── options │ ├── changelog.js │ ├── clean.js │ ├── coveralls.js │ ├── open.js │ ├── bump.js │ ├── cssmin.js │ ├── uglify.js │ ├── replace.js │ ├── eslint.js │ ├── sass.js │ ├── remapIstanbul.js │ ├── ngtemplates.js │ ├── karma.js │ ├── rollup.js │ ├── shell.js │ ├── copy.js │ └── compress.js └── tasks │ ├── dgeni.js │ ├── update-bower-version.js │ ├── pack.js │ ├── update-website-version.js │ └── sauce.js ├── test ├── init.js ├── matchers.js ├── helpers.js ├── bind-attrs.spec.js ├── transclude-append.spec.js ├── autosize.spec.js ├── lib │ └── ng-stats.min.js ├── test-page.html ├── configuration.spec.js ├── util.spec.js └── auto-complete.spec.js ├── src ├── constants.js ├── transclude-append.js ├── bind-attrs.js ├── tag-item.js ├── auto-complete-match.js ├── init.js ├── autosize.js ├── util.js ├── configuration.js ├── auto-complete.js └── tags-input.js ├── scss ├── main.scss ├── mixins.scss ├── bootstrap │ ├── input-groups.scss │ ├── forms.scss │ ├── main.scss │ └── variables.scss ├── bootstrap.scss ├── auto-complete.scss ├── variables.scss └── tags-input.scss ├── docs ├── templates │ ├── macros.html │ ├── service.html │ └── directive.html └── dgeni-config.js ├── .travis.yml ├── sauce.launchers.json ├── .eslintrc.js ├── LICENSE ├── karma.conf.js ├── package.json ├── Gruntfile.js ├── README.md ├── CONTRIBUTING.md └── CHANGELOG.md /templates/auto-complete-match.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .project 3 | .settings/ 4 | node_modules/ 5 | coverage/ 6 | *.log 7 | build/ 8 | sauce.json -------------------------------------------------------------------------------- /grunt/options/changelog.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | github: 'mbenford/ngTagsInput' 4 | } 5 | }; -------------------------------------------------------------------------------- /grunt/options/clean.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | build: ['build/'], 3 | tmp: ['build/tmp/'], 4 | coverage: ['coverage/'] 5 | }; -------------------------------------------------------------------------------- /templates/tag-item.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /grunt/options/coveralls.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | debug: true, 4 | coverageDir: 'coverage/lcov', 5 | force: true 6 | } 7 | }; -------------------------------------------------------------------------------- /grunt/options/open.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => ({ 2 | coverage: { 3 | path: grunt.file.expand('coverage/**/html-report/index.html')[0] 4 | } 5 | }); -------------------------------------------------------------------------------- /grunt/options/bump.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | files: ['package.json'], 4 | commit: false, 5 | createTag: false, 6 | push: false 7 | } 8 | }; -------------------------------------------------------------------------------- /grunt/options/cssmin.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | build: { 3 | files: { 4 | '<%= files.css.main.outMin %>': ['<%= files.css.main.out %>'], 5 | '<%= files.css.bootstrap.outMin %>': ['<%= files.css.bootstrap.out %>'] 6 | } 7 | } 8 | }; -------------------------------------------------------------------------------- /grunt/options/uglify.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | build: { 3 | options: { 4 | banner: '<%= banners.minified %>', 5 | sourceMap: true, 6 | }, 7 | files: { 8 | '<%= files.js.outMin %>': ['<%= files.js.out %>'] 9 | } 10 | } 11 | }; -------------------------------------------------------------------------------- /grunt/options/replace.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | changelog: { 3 | src: ['CHANGELOG.md'], 4 | overwrite: true, 5 | replacements: [ 6 | { from: ', closes [', to: ', [' }, 7 | { from: /\n\n\s*\n/g, to: '\n\n' }, 8 | { from: /\n/g, to: '' } 9 | ] 10 | } 11 | }; -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | // Patches the inject function so that all tests run with the strict dependency injection flag on. 2 | let originalInject = window.inject; 3 | window.inject = (...args) => { 4 | if (module.$$currentSpec) { 5 | originalInject.strictDi(true); 6 | } 7 | originalInject.call(window, ...args); 8 | }; -------------------------------------------------------------------------------- /grunt/options/eslint.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => ({ 2 | options: { 3 | configFile: '.eslintrc.js', 4 | format: 'codeframe' 5 | }, 6 | build: [ 7 | grunt.file.expand('./grunt/*'), 8 | 'Gruntfile.js', 9 | 'karma.conf.js', 10 | ['<%= files.js.src %>'], 11 | ['<%= files.spec.src %>'] 12 | ] 13 | }); 14 | -------------------------------------------------------------------------------- /grunt/tasks/dgeni.js: -------------------------------------------------------------------------------- 1 | /* global require, process */ 2 | 3 | const Dgeni = require('dgeni'); 4 | 5 | module.exports = grunt => { 6 | grunt.registerTask('dgeni', () => { 7 | let done = this.async(); 8 | let dgeni = new Dgeni([require(process.cwd() + '/docs/dgeni-config.js')]); 9 | 10 | dgeni.generate().then(done); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /grunt/options/sass.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | build: { 3 | options: { 4 | style: 'expanded', 5 | noCache: true, 6 | sourcemap: 'none' 7 | }, 8 | files: { 9 | '<%= files.css.main.out %>': ['<%= files.css.main.src %>'], 10 | '<%= files.css.bootstrap.out %>': ['<%= files.css.bootstrap.src %>'] 11 | } 12 | } 13 | }; -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export default { 2 | KEYS: { 3 | backspace: 8, 4 | tab: 9, 5 | enter: 13, 6 | escape: 27, 7 | space: 32, 8 | up: 38, 9 | down: 40, 10 | left: 37, 11 | right: 39, 12 | delete: 46, 13 | comma: 188 14 | }, 15 | MAX_SAFE_INTEGER: 9007199254740991, 16 | SUPPORTED_INPUT_TYPES: ['text', 'email', 'url'] 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /grunt/options/remapIstanbul.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => { 2 | let coverageFile = grunt.file.expand('coverage/json/coverage-final.json')[0]; 3 | return { 4 | build: { 5 | src: coverageFile, 6 | options: { 7 | reports: { 8 | html: 'coverage/html-report', 9 | json: coverageFile 10 | } 11 | } 12 | } 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /grunt/tasks/update-bower-version.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => { 2 | grunt.registerTask('update-bower-version', () => { 3 | let pkg = grunt.config('pkg'); 4 | let filename = grunt.config('bowerFile'); 5 | let file = grunt.file.readJSON(filename); 6 | 7 | file.version = pkg.version; 8 | grunt.file.write(filename, JSON.stringify(file, null, ' ')); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /grunt/tasks/pack.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => { 2 | grunt.registerTask('pack', output => { 3 | let tasks = []; 4 | 5 | if (!output || output === 'js') { 6 | tasks.push('javascript-only'); 7 | } 8 | if (!output || output === 'css') { 9 | tasks.push('css-only'); 10 | } 11 | 12 | tasks.push('clean:tmp'); 13 | 14 | grunt.task.run(tasks); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /test/matchers.js: -------------------------------------------------------------------------------- 1 | const customMatchers = { 2 | toHaveClass() { 3 | return { 4 | compare(actual, expected) { 5 | let result = {}; 6 | result.pass = actual.hasClass(expected); 7 | result.message = `Expected element ${result.pass ? ' not ' : ' '} to have class '${expected}' but found '${actual.attr('class')}'`; 8 | return result; 9 | } 10 | }; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /grunt/tasks/update-website-version.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => { 2 | grunt.registerTask('update-website-version', () => { 3 | let pkg = grunt.config('pkg'); 4 | let filename = grunt.config('websiteConfigFile'); 5 | let file = grunt.file.read(filename); 6 | 7 | file = file.replace(/stable_version:.*/, 'stable_version: ' + pkg.version); 8 | grunt.file.write(filename, file); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/transclude-append.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngdoc directive 3 | * @name tiTranscludeAppend 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Re-creates the old behavior of ng-transclude. Used internally by tagsInput directive. 8 | */ 9 | export default function TranscludeAppendDirective() { 10 | return (scope, element, attrs, ctrl, transcludeFn) => { 11 | transcludeFn(clone => { 12 | element.append(clone); 13 | }); 14 | }; 15 | } -------------------------------------------------------------------------------- /scss/main.scss: -------------------------------------------------------------------------------- 1 | tags-input { 2 | display: block; 3 | 4 | *, *:before, *:after { 5 | -moz-box-sizing: border-box; 6 | -webkit-box-sizing: border-box; 7 | box-sizing: border-box; 8 | } 9 | 10 | .host { 11 | position: relative; 12 | margin-top: 5px; 13 | margin-bottom: 5px; 14 | height: 100%; 15 | 16 | &:active { 17 | outline: none; 18 | } 19 | } 20 | } 21 | 22 | @import "tags-input"; 23 | @import "auto-complete"; -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | function range(count, callback) { 2 | let array = []; 3 | for (let i = 0; i < count; i++) { 4 | array.push(callback ? callback(i) : i); 5 | } 6 | return array; 7 | } 8 | 9 | function changeElementValue(input, value) { 10 | input.val(value); 11 | if ('oninput' in input) { 12 | input.trigger('input'); 13 | } 14 | else { 15 | // 'input' doesn't work in Opera, so 'change' is used instead 16 | input.trigger('change'); 17 | } 18 | } -------------------------------------------------------------------------------- /src/bind-attrs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngdoc directive 3 | * @name tiBindAttrs 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Binds attributes to expressions. Used internally by tagsInput directive. 8 | */ 9 | export default function BindAttributesDirective() { 10 | return (scope, element, attrs) => { 11 | scope.$watch(attrs.tiBindAttrs, value => { 12 | angular.forEach(value, (value, key) => { 13 | attrs.$set(key, value); 14 | }); 15 | }, true); 16 | }; 17 | } -------------------------------------------------------------------------------- /templates/auto-complete.html: -------------------------------------------------------------------------------- 1 |
2 | 11 |
-------------------------------------------------------------------------------- /docs/templates/macros.html: -------------------------------------------------------------------------------- 1 | {%- macro directiveParam(name, type, join, sep) %} 2 | {%- if type.optional %}[{% endif -%} 3 | {$ name | dashCase $}{$ join $}{$ type.name $}{$ sep $} 4 | {%- if type.optional %}]{% endif -%} 5 | {% endmacro -%} 6 | 7 | {%- macro functionSignature(fn) %} 8 | {%- set sep = joiner(', ') -%} 9 | {$ fn.name $}({%- for param in fn.params %}{$ sep() $} 10 | {%- if param.type.optional %}[{% endif -%} 11 | {$ param.name $} 12 | {%- if param.type.optional %}]{% endif -%} 13 | {% endfor -%}) 14 | {%- endmacro -%} -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | before_install: 5 | - rvm install 2.3.0 6 | - gem install sass 7 | script: npm run travis 8 | sudo: false 9 | cache: 10 | directories: 11 | - node_modules 12 | deploy: 13 | provider: s3 14 | access_key_id: AKIAI55AELZBPQKVIAXQ 15 | secret_access_key: 16 | secure: fDp97fUYc19sXmMnnp4OZL8MQYyTXRkxTVJj9NSdFI4xFCu34igwlcJXwHt9uuzV2wVIQ3R9GsAVfSwZxD+XWz1qrzWRt3PIsR5J7I9ZiqE0DaCK9IBVepsz5BR/A/q9v3VWGW7o+wy419EKVuXGHCJMAUoHeQCGeb8gOKYpI3g= 17 | bucket: ng-tags-input 18 | local-dir: build/travis 19 | skip_cleanup: true 20 | on: 21 | repo: mbenford/ngTagsInput 22 | branch: master 23 | -------------------------------------------------------------------------------- /grunt/options/ngtemplates.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | build: { 3 | files: { 4 | '<%= files.html.out %>': ['<%= files.html.src %>'] 5 | }, 6 | options: { 7 | url(url) { 8 | return `ngTagsInput/${url.replace('templates/', '')}`; 9 | }, 10 | bootstrap(module, script) { 11 | script = script.replace(/'use strict';\n\n/g, '').replace(/\n\n\s*\n/g, '\n'); 12 | return `/*@ngInject*/\nexport default function TemplateCacheRegister($templateCache) {\n${script}}`; 13 | }, 14 | htmlmin: { 15 | collapseWhitespace: true, 16 | removeRedundantAttributes: true 17 | } 18 | } 19 | } 20 | }; -------------------------------------------------------------------------------- /grunt/options/karma.js: -------------------------------------------------------------------------------- 1 | /* global process: false */ 2 | 3 | module.exports = grunt => ({ 4 | options: { 5 | configFile: 'karma.conf.js' 6 | }, 7 | local: { 8 | singleRun: true, 9 | browsers: ['PhantomJS'], 10 | reporters: ['mocha', 'coverage'], 11 | mochaReporter: { 12 | output: process.env.TRAVIS ? 'full' : 'minimal' 13 | } 14 | }, 15 | remote: { 16 | singleRun: true, 17 | captureTimeout: 120000, 18 | sauceLabs: { 19 | testName: 'ngTagsInput' 20 | }, 21 | recordVideo: false, 22 | recordScreenshots: false, 23 | customLaunchers: grunt.file.readJSON('sauce.launchers.json'), 24 | reporters: ['mocha', 'saucelabs'] 25 | } 26 | }); -------------------------------------------------------------------------------- /scss/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin gradient($steps...) { 2 | background: -webkit-linear-gradient(top, $steps); 3 | background: linear-gradient(to bottom, $steps); 4 | } 5 | 6 | @mixin box-shadow($args...) { 7 | -webkit-box-shadow: $args; 8 | -moz-box-shadow: $args; 9 | box-shadow: $args; 10 | } 11 | 12 | @mixin transition($args...) { 13 | -webkit-transition: $args; 14 | -moz-transition: $args; 15 | transition: $args; 16 | } 17 | 18 | @mixin border-left-radius($radius) { 19 | border-top-left-radius: $radius; 20 | border-bottom-left-radius: $radius; 21 | } 22 | 23 | @mixin border-right-radius($radius) { 24 | border-top-right-radius: $radius; 25 | border-bottom-right-radius: $radius; 26 | } -------------------------------------------------------------------------------- /grunt/tasks/sauce.js: -------------------------------------------------------------------------------- 1 | /* global process: false */ 2 | 3 | module.exports = grunt => { 4 | grunt.registerTask('sauce', browsers => { 5 | let sauce, config; 6 | 7 | if (!process.env.SAUCE_USERNAME) { 8 | if (!grunt.file.exists('sauce.json')) { 9 | grunt.fail.fatal('sauce.json not found', 3); 10 | } 11 | else { 12 | sauce = grunt.file.readJSON('sauce.json'); 13 | process.env.SAUCE_USERNAME = sauce.username; 14 | process.env.SAUCE_ACCESS_KEY = sauce.accessKey; 15 | } 16 | } 17 | 18 | config = grunt.config.get('karma.remote'); 19 | config.browsers = browsers ? browsers.split(',') : Object.keys(config.customLaunchers); 20 | grunt.config.set('karma.remote', config); 21 | grunt.task.run('karma:remote'); 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /grunt/options/rollup.js: -------------------------------------------------------------------------------- 1 | const includePaths = require('rollup-plugin-includepaths'); 2 | const babel = require('rollup-plugin-babel'); 3 | 4 | module.exports = { 5 | options: { 6 | format: 'iife', 7 | globals: { 8 | angular: 'angular' 9 | }, 10 | indent: false, 11 | sourceMap: true, 12 | banner: '<%= banners.unminified %>', 13 | plugins: [ 14 | includePaths({ 15 | paths: ['build/tmp'] 16 | }), 17 | babel({ 18 | presets: [['es2015', { modules: false }]], 19 | plugins: [ 20 | 'external-helpers', 21 | ['angularjs-annotate', { explicitOnly: true }] 22 | ] 23 | }), 24 | ] 25 | }, 26 | build: { 27 | files: { 28 | '<%= files.js.out %>': ['<%= files.js.src %>'] 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /scss/bootstrap/input-groups.scss: -------------------------------------------------------------------------------- 1 | @import "../mixins"; 2 | @import "variables"; 3 | 4 | .input-group tags-input { 5 | padding: 0; 6 | display: table-cell; 7 | 8 | &:not(:first-child) .tags { 9 | @include border-left-radius(0) 10 | } 11 | 12 | &:not(:last-child) .tags { 13 | @include border-right-radius(0) 14 | } 15 | } 16 | 17 | .input-group-lg tags-input { 18 | @extend .ti-input-lg; 19 | 20 | &:first-child .tags { 21 | @include border-left-radius($tag-border-radius-lg); 22 | } 23 | 24 | &:last-child .tags { 25 | @include border-right-radius($tag-border-radius-lg); 26 | } 27 | } 28 | 29 | .input-group-sm tags-input { 30 | @extend .ti-input-sm; 31 | 32 | &:first-child .tags { 33 | @include border-left-radius($tag-border-radius-sm); 34 | } 35 | 36 | &:last-child .tags { 37 | @include border-right-radius($tag-border-radius-sm); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /grunt/options/shell.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | git: { 3 | command: [ 4 | 'git add .', 5 | 'git commit -m "chore(release): Release v<%= pkg.version %>"', 6 | 'git tag -a v<%= pkg.version %> -m "v<%= pkg.version %>"', 7 | ].join('&&'), 8 | options: { 9 | stdout: true 10 | } 11 | }, 12 | git_bower: { 13 | command: [ 14 | 'git add .', 15 | 'git commit -m "Updated to v<%= pkg.version %>"', 16 | 'git tag -a v<%= pkg.version %> -m "v<%= pkg.version %>"' 17 | ].join('&&'), 18 | options: { 19 | stdout: true, 20 | execOptions: { cwd: '<%= bowerDirectory %>' } 21 | } 22 | }, 23 | git_website: { 24 | command: [ 25 | 'git add .', 26 | 'git commit -m "Updated to v<%= pkg.version %>"' 27 | ].join('&&'), 28 | options: { 29 | stdout: true, 30 | execOptions: { cwd: '<%= websiteDirectory %>' } 31 | } 32 | } 33 | }; -------------------------------------------------------------------------------- /sauce.launchers.json: -------------------------------------------------------------------------------- 1 | { 2 | "SL_Chrome": { 3 | "base": "SauceLabs", 4 | "browserName": "Chrome", 5 | "version": "31" 6 | }, 7 | "SL_Firefox": { 8 | "base": "SauceLabs", 9 | "browserName": "Firefox", 10 | "version": "29" 11 | }, 12 | "SL_Safari": { 13 | "base": "SauceLabs", 14 | "browserName": "Safari", 15 | "platform": "OS X 10.9", 16 | "version": "7" 17 | }, 18 | "SL_Opera": { 19 | "base": "SauceLabs", 20 | "browserName": "Opera", 21 | "version": "12" 22 | }, 23 | "SL_IE_10": { 24 | "base": "SauceLabs", 25 | "browserName": "Internet Explorer", 26 | "platform": "Windows 8", 27 | "version": "10" 28 | }, 29 | "SL_IE_11": { 30 | "base": "SauceLabs", 31 | "browserName": "Internet explorer", 32 | "platform": "Windows 8.1", 33 | "version": "11" 34 | } 35 | } -------------------------------------------------------------------------------- /docs/dgeni-config.js: -------------------------------------------------------------------------------- 1 | const Package = require('dgeni').Package; 2 | 3 | module.exports = new Package('dgeni-example', [ 4 | require('dgeni-packages/ngdoc'), 5 | require('dgeni-packages/nunjucks') 6 | ]) 7 | .config(function(log, readFilesProcessor, writeFilesProcessor, templateFinder, templateEngine) { 8 | const basePath = process.cwd(); 9 | 10 | log.level = 'error'; 11 | 12 | readFilesProcessor.basePath = basePath; 13 | readFilesProcessor.sourceFiles = [{ 14 | include: [ 15 | 'src/tags-input.js', 16 | 'src/auto-complete.js', 17 | 'src/configuration.js' 18 | ], 19 | basePath: 'src' 20 | }]; 21 | 22 | // Nunjucks and Angular conflict in their template bindings so change the Nunjucks 23 | templateEngine.config.tags = { 24 | variableStart: '{$', 25 | variableEnd: '$}' 26 | }; 27 | 28 | templateFinder.templateFolders.unshift(basePath + '/docs/templates'); 29 | templateFinder.templatePatterns.unshift('${ doc.docType }.html'); 30 | 31 | writeFilesProcessor.outputFolder = 'build/docs'; 32 | }); 33 | -------------------------------------------------------------------------------- /src/tag-item.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngdoc directive 3 | * @name tiTagItem 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Represents a tag item. Used internally by the tagsInput directive. 8 | */ 9 | export default function TagItemDirective(tiUtil) { 10 | 'ngInject'; 11 | 12 | return { 13 | restrict: 'E', 14 | require: '^tagsInput', 15 | template: '', 16 | scope: { 17 | $scope: '=scope', 18 | data: '=' 19 | }, 20 | link(scope, element, attrs, tagsInputCtrl) { 21 | let tagsInput = tagsInputCtrl.registerTagItem(); 22 | let options = tagsInput.getOptions(); 23 | 24 | scope.$$template = options.template; 25 | scope.$$removeTagSymbol = options.removeTagSymbol; 26 | 27 | scope.$getDisplayText = () => tiUtil.safeToString(scope.data[options.displayProperty]); 28 | scope.$removeTag = () => { 29 | tagsInput.removeTag(scope.$index); 30 | }; 31 | 32 | scope.$watch('$parent.$index', value => { 33 | scope.$index = value; 34 | }); 35 | } 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /grunt/options/copy.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bower: { 3 | files: [{ 4 | expand: true, 5 | flatten: true, 6 | src: ['build/*.js', 'build/*.css'], 7 | dest: '<%= bowerDirectory %>', 8 | filter: 'isFile' 9 | }] 10 | }, 11 | travis: { 12 | files: [{ 13 | expand: true, 14 | flatten: true, 15 | src: ['build/*.zip'], 16 | dest: 'build/travis', 17 | filter: 'isFile' 18 | }] 19 | }, 20 | website: { 21 | files: [ 22 | { 23 | expand: true, 24 | flatten: true, 25 | src: ['build/docs/**/*.html'], 26 | dest: '<%= websiteDirectory %>/_includes/api', 27 | filter: 'isFile' 28 | }, 29 | { 30 | expand: true, 31 | flatten: true, 32 | src: ['build/*.min.js'], 33 | dest: '<%= websiteDirectory %>/js', 34 | filter: 'isFile' 35 | }, 36 | { 37 | expand: true, 38 | flatten: true, 39 | src: ['build/*.min.css'], 40 | dest: '<%= websiteDirectory %>/css', 41 | filter: 'isFile' 42 | } 43 | ] 44 | } 45 | }; -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es6: true, 6 | mocha: true, 7 | jasmine: true, 8 | jquery: true, 9 | }, 10 | extends: 'eslint:recommended', 11 | parserOptions: { 12 | sourceType: 'module', 13 | impliedStrict: true 14 | }, 15 | rules: { 16 | 'indent': ['error', 2], 17 | 'linebreak-style': ['error', 'unix'], 18 | 'quotes': ['error', 'single'], 19 | 'semi': ['error', 'always'], 20 | 'curly': 'error', 21 | 'eqeqeq': ['error', 'always'], 22 | 'no-empty': 'error', 23 | 'no-undef': 'error', 24 | 'no-eq-null': 'error', 25 | 'no-extend-native': 'error', 26 | 'no-caller': 'error', 27 | 'new-cap': ['error', { capIsNew: false }] 28 | }, 29 | globals: { 30 | angular: true, 31 | module: true, 32 | inject: true, 33 | tagsInput: true, 34 | range: true, 35 | changeElementValue: true, 36 | customMatchers: true, 37 | KEYS: true, 38 | MAX_SAFE_INTEGER: true, 39 | SUPPORTED_INPUT_TYPES: true 40 | } 41 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 - 2016, Michael Benford and ngTagsInput contributors. 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 | -------------------------------------------------------------------------------- /templates/tags-input.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |
  • 8 | 9 |
  • 10 |
11 | 24 |
25 |
-------------------------------------------------------------------------------- /src/auto-complete-match.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngdoc directive 3 | * @name tiAutocompleteMatch 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Represents an autocomplete match. Used internally by the autoComplete directive. 8 | */ 9 | export default function AutocompleteMatchDirective($sce, tiUtil) { 10 | 'ngInject'; 11 | 12 | return { 13 | restrict: 'E', 14 | require: '^autoComplete', 15 | template: '', 16 | scope: { 17 | $scope: '=scope', 18 | data: '=' 19 | }, 20 | link(scope, element, attrs, autoCompleteCtrl) { 21 | let autoComplete = autoCompleteCtrl.registerAutocompleteMatch(); 22 | let options = autoComplete.getOptions(); 23 | 24 | scope.$$template = options.template; 25 | scope.$index = scope.$parent.$index; 26 | 27 | scope.$highlight = text => { 28 | if (options.highlightMatchedText) { 29 | text = tiUtil.safeHighlight(text, autoComplete.getQuery()); 30 | } 31 | return $sce.trustAsHtml(text); 32 | }; 33 | 34 | scope.$getDisplayText = () => tiUtil.safeToString(scope.data[options.displayProperty || options.tagsInput.displayProperty]); 35 | } 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | import * as angular from 'angular'; 2 | import Constants from './constants'; 3 | import TagsInputDirective from './tags-input'; 4 | import TagItemDirective from './tag-item'; 5 | import AutocompleteDirective from './auto-complete'; 6 | import AutocompleteMatchDirective from './auto-complete-match'; 7 | import AutosizeDirective from './autosize'; 8 | import BindAttributesDirective from './bind-attrs'; 9 | import TranscludeAppendDirective from './transclude-append'; 10 | import TagsInputConfigurationProvider from './configuration'; 11 | import UtilService from './util'; 12 | import TemplateCacheRegister from 'compiled-templates'; 13 | 14 | angular.module('ngTagsInput', []) 15 | .directive('tagsInput', TagsInputDirective) 16 | .directive('tiTagItem', TagItemDirective) 17 | .directive('autoComplete', AutocompleteDirective) 18 | .directive('tiAutocompleteMatch', AutocompleteMatchDirective) 19 | .directive('tiAutosize', AutosizeDirective) 20 | .directive('tiBindAttrs', BindAttributesDirective) 21 | .directive('tiTranscludeAppend', TranscludeAppendDirective) 22 | .factory('tiUtil', UtilService) 23 | .constant('tiConstants', Constants) 24 | .provider('tagsInputConfig', TagsInputConfigurationProvider) 25 | .run(TemplateCacheRegister); -------------------------------------------------------------------------------- /scss/bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "main"; 2 | 3 | tags-input { 4 | .tags { 5 | background-color: #fff; 6 | border: 1px solid #ccc; 7 | border-radius: 4px; 8 | box-shadow: inset 0 1px 1px rgba(0,0,0,0.075); 9 | transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; 10 | &.focused { 11 | border-color: #66afe9; 12 | box-shadow: inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6); 13 | } 14 | .tag-item { 15 | background: #428bca; 16 | border: 1px solid #357ebd; 17 | border-radius: 4px; 18 | color: #fff; 19 | &.selected { 20 | background: #d9534f; 21 | border: 1px solid #d43f3a; 22 | border-radius: 4px; 23 | color: #fff; 24 | } 25 | button { 26 | background: transparent; 27 | color: #000; 28 | opacity: .4; 29 | } 30 | } 31 | } 32 | .autocomplete { 33 | border-radius: 4px; 34 | .suggestion-item { 35 | &.selected { 36 | color: #262626; 37 | background-color: #e9e9e9; 38 | em { 39 | color: #262626; 40 | background-color: #ffff00; 41 | } 42 | } 43 | em { 44 | font-weight: normal; 45 | background-color: #ffff00; 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /test/bind-attrs.spec.js: -------------------------------------------------------------------------------- 1 | describe('bind-attrs directive', () => { 2 | let $scope, $compile, 3 | element; 4 | 5 | beforeEach(() => { 6 | module('ngTagsInput'); 7 | 8 | inject(($rootScope, _$compile_) => { 9 | $scope = $rootScope; 10 | $compile = _$compile_; 11 | }); 12 | }); 13 | 14 | function compile(value) { 15 | element = $compile('')($scope); 16 | $scope.$digest(); 17 | } 18 | 19 | it('sets the element attributes according to the provided parameters', () => { 20 | // Arrange 21 | $scope.prop1 = 'Foobar'; 22 | $scope.prop2 = 42; 23 | 24 | // Act 25 | compile('{attr1: prop1, attr2: prop2}'); 26 | 27 | // Assert 28 | expect(element.attr('attr1')).toBe('Foobar'); 29 | expect(element.attr('attr2')).toBe('42'); 30 | }); 31 | 32 | it('updates the element attributes when provided parameters change', () => { 33 | // Arrange 34 | $scope.prop1 = 'Foobar'; 35 | $scope.prop2 = 42; 36 | compile('{attr1: prop1, attr2: prop2}'); 37 | 38 | // Act 39 | $scope.prop1 = 'Barfoo'; 40 | $scope.prop2 = 24; 41 | $scope.$digest(); 42 | 43 | // Assert 44 | expect(element.attr('attr1')).toBe('Barfoo'); 45 | expect(element.attr('attr2')).toBe('24'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /scss/auto-complete.scss: -------------------------------------------------------------------------------- 1 | @import "mixins"; 2 | @import "variables"; 3 | 4 | tags-input .autocomplete { 5 | margin-top: 5px; 6 | position: absolute; 7 | padding: 5px 0; 8 | z-index: $suggestions-z-index; 9 | width: $suggestions-width; 10 | background-color: $suggestions-bgcolor; 11 | border: $suggestions-border; 12 | @include box-shadow($suggestions-border-shadow); 13 | 14 | .suggestion-list { 15 | margin: 0; 16 | padding: 0; 17 | list-style-type: none; 18 | max-height: 280px; 19 | overflow-y: auto; 20 | position: relative; 21 | } 22 | 23 | .suggestion-item { 24 | padding: 5px 10px; 25 | cursor: pointer; 26 | white-space: nowrap; 27 | overflow: hidden; 28 | text-overflow: ellipsis; 29 | font: $suggestion-font; 30 | color: $suggestion-color; 31 | background-color: $suggestion-bgcolor; 32 | 33 | &.selected { 34 | color: $suggestion-color-selected; 35 | background-color: $suggestion-bgcolor-selected; 36 | 37 | em { 38 | color: $suggestion-highlight-color-selected; 39 | background-color: $suggestion-highlight-bgcolor-selected; 40 | } 41 | } 42 | 43 | em { 44 | font: $suggestion-highlight-font; 45 | color: $suggestion-highlight-color; 46 | background-color: $suggestion-highlight-bgcolor; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /docs/templates/service.html: -------------------------------------------------------------------------------- 1 | {% include "macros.html" -%} 2 |
3 |

{$ doc.name $} (directive in module {$ doc.module $})

4 |
5 |

Description

6 |

{$ doc.description | replace("\n", "") | replace(" ", " ") $}

7 |
8 | 9 |
10 |

Methods

11 |
    12 | {%- for method in doc.methods %} 13 |
  • 14 |

    {$ functionSignature(method) $}

    15 |
    16 |

    {$ method.description $}

    17 |
    Parameters
    18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {%- for param in method.params %} 28 | 29 | 30 | 31 | 32 | 33 | {%- endfor %} 34 | 35 |
    NameTypeDescription
    {$ param.name $}{$ param.type.name $}{$ param.description $}
    36 |
    Returns
    37 |

    {$ method.returns.description $}

    38 |
    39 |
  • 40 | {%- endfor %} 41 |
42 |
43 |
-------------------------------------------------------------------------------- /test/transclude-append.spec.js: -------------------------------------------------------------------------------- 1 | describe('transclude-append-directive', () => { 2 | let $scope, $compile, directive, element; 3 | 4 | beforeEach(() => { 5 | module('ngTagsInput'); 6 | 7 | module($compileProvider => { 8 | directive = $compileProvider.directive; 9 | }); 10 | 11 | inject(($rootScope, _$compile_) => { 12 | $scope = $rootScope; 13 | $compile = _$compile_; 14 | }); 15 | }); 16 | 17 | function compile(template) { 18 | element = $compile(template)($scope); 19 | } 20 | 21 | function createDirective(template) { 22 | directive('foobar', () => ({ 23 | restrict: 'E', 24 | transclude: true, 25 | template: template 26 | })); 27 | } 28 | 29 | it('appends the transcluded content to the end of an empty target element', () => { 30 | // Arrange 31 | createDirective('
'); 32 | 33 | // Act 34 | compile('

transcluded content

'); 35 | 36 | // Assert 37 | expect(element.find('p').html()).toBe('transcluded content'); 38 | }); 39 | 40 | it('appends the transcluded content to the end of a non-empty target element', () => { 41 | // Arrange 42 | createDirective('

existing content

'); 43 | 44 | // Act 45 | compile('

transcluded content

'); 46 | 47 | // Assert 48 | let content = $.map(element.find('p'), e => $(e).html()); 49 | expect(content).toEqual(['existing content', 'transcluded content']); 50 | }); 51 | }); -------------------------------------------------------------------------------- /docs/templates/directive.html: -------------------------------------------------------------------------------- 1 | {% include "macros.html" -%} 2 |
3 |

{$ doc.name $} (directive in module {$ doc.module $})

4 |
5 |

Description

6 |

{$ doc.description $}

7 |
8 | 9 |
10 |

Usage

11 | {% raw %}{% highlight html %}{% endraw %} 12 | <{$ doc.name | dashCase $} 13 | {%- for param in doc.params %} 14 | {$ directiveParam(param.name, param.type, '="{', '}"') $} 15 | {%- endfor %}> 16 | 17 | {% raw %}{% endhighlight %}{% endraw %} 18 |
19 | 20 |
21 |

Parameters

22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {%- for param in doc.params %} 33 | 34 | 35 | 36 | 37 | 38 | 39 | {%- endfor %} 40 | 41 |
NameTypeDescriptionDefaults to
{$ param.name $}{$ " (required)" if not param.type.optional $}{$ param.type.name $}{$ param.description | replace("\n", "") | replace(" ", " ") $}{$ param.defaultValue if param.defaultValue and param.defaultValue != "NA" else "–" $}
42 |
43 |
-------------------------------------------------------------------------------- /src/autosize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngdoc directive 3 | * @name tiAutosize 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Automatically sets the input's width so its content is always visible. Used internally by tagsInput directive. 8 | */ 9 | export default function AutosizeDirective(tagsInputConfig) { 10 | 'ngInject'; 11 | 12 | return { 13 | restrict: 'A', 14 | require: 'ngModel', 15 | link(scope, element, attrs, ctrl) { 16 | let threshold = tagsInputConfig.getTextAutosizeThreshold(); 17 | let span = angular.element(''); 18 | 19 | span.css('display', 'none') 20 | .css('visibility', 'hidden') 21 | .css('width', 'auto') 22 | .css('white-space', 'pre'); 23 | 24 | element.parent().append(span); 25 | 26 | let resize = originalValue => { 27 | let value = originalValue; 28 | let width; 29 | 30 | if (angular.isString(value) && value.length === 0) { 31 | value = attrs.placeholder; 32 | } 33 | 34 | if (value) { 35 | span.text(value); 36 | span.css('display', ''); 37 | width = span.prop('offsetWidth'); 38 | span.css('display', 'none'); 39 | } 40 | 41 | element.css('width', width ? width + threshold + 'px' : ''); 42 | 43 | return originalValue; 44 | }; 45 | 46 | ctrl.$parsers.unshift(resize); 47 | ctrl.$formatters.unshift(resize); 48 | 49 | attrs.$observe('placeholder', value => { 50 | if (!ctrl.$modelValue) { 51 | resize(value); 52 | } 53 | }); 54 | } 55 | }; 56 | } -------------------------------------------------------------------------------- /grunt/options/compress.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | mode: 'zip' 4 | }, 5 | minified: { 6 | options: { 7 | archive: '<%= files.zip.minified %>' 8 | }, 9 | files : [ 10 | { 11 | expand: true, 12 | src : [ 13 | '<%= files.js.outMin %>', 14 | '<%= files.js.outMin %>.map', 15 | '<%= files.css.main.outMin %>', 16 | '<%= files.css.bootstrap.outMin %>' 17 | ], 18 | flatten: true 19 | } 20 | ] 21 | }, 22 | unminified: { 23 | options: { 24 | archive: '<%= files.zip.unminified %>' 25 | }, 26 | files : [ 27 | { 28 | expand: true, 29 | src : [ 30 | '<%= files.js.out %>', 31 | '<%= files.js.out %>.map', 32 | '<%= files.css.main.out %>', 33 | '<%= files.css.bootstrap.out %>' 34 | ], 35 | flatten: true 36 | } 37 | ] 38 | }, 39 | npm: { 40 | options: { 41 | archive: '<%= files.tgz.npm %>', 42 | mode: 'tgz' 43 | }, 44 | files : [ 45 | { 46 | expand: true, 47 | src : [ 48 | '<%= files.js.out %>', 49 | '<%= files.js.out %>.map', 50 | '<%= files.css.main.out %>', 51 | '<%= files.css.bootstrap.out %>', 52 | '<%= files.js.outMin %>.map', 53 | '<%= files.css.main.outMin %>', 54 | '<%= files.css.bootstrap.outMin %>', 55 | ], 56 | dest: 'package/build', 57 | flatten: true 58 | }, 59 | { 60 | expand: true, 61 | src: [ 62 | 'README.md', 63 | 'package.json' 64 | ], 65 | dest: 'package/', 66 | flatten: true 67 | }, 68 | 69 | ] 70 | } 71 | }; -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = config => { 2 | config.set({ 3 | // base path, that will be used to resolve files and exclude 4 | basePath: '', 5 | 6 | // frameworks to use 7 | frameworks: ['jasmine'], 8 | 9 | // list of files / patterns to load in the browser 10 | files: [ 11 | 'test/lib/jquery-1.10.2.min.js', 12 | 'test/lib/angular.js', 13 | 'test/lib/angular-mocks.js', 14 | 'test/helpers.js', 15 | 'test/matchers.js', 16 | 'test/init.js', 17 | 'test/*.spec.js', 18 | 'build/ng-tags-input.js' 19 | ], 20 | 21 | preprocessors: { 22 | 'build/ng-tags-input.js': ['coverage'], 23 | 'test/*.js': ['babel'] 24 | }, 25 | 26 | coverageReporter: { 27 | dir: 'coverage/', 28 | reporters: [ 29 | { type: 'json', subdir: 'json', file: 'coverage-final.json' }, 30 | { type: 'lcov', subdir: 'lcov' } 31 | ] 32 | }, 33 | 34 | babelPreprocessor: { 35 | options: { 36 | presets: ['es2015'], 37 | sourceMap: 'inline' 38 | } 39 | }, 40 | 41 | // list of files to exclude 42 | exclude: [], 43 | 44 | // test results reporter to use 45 | reporters: ['mocha'], 46 | 47 | // web server port 48 | port: 9876, 49 | 50 | // enable / disable colors in the output (reporters and logs) 51 | colors: true, 52 | 53 | // level of logging 54 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 55 | logLevel: config.LOG_WARN, 56 | 57 | // enable / disable watching file and executing tests whenever any file changes 58 | autoWatch: true, 59 | 60 | // Start these browsers, currently available: 61 | // - Chrome 62 | // - ChromeCanary 63 | // - Firefox 64 | // - Opera 65 | // - Safari (only Mac) 66 | // - PhantomJS 67 | // - IE (only Windows) 68 | browsers: ['PhantomJS'], 69 | 70 | // If browser does not capture in given timeout [ms], kill it 71 | captureTimeout: 60000, 72 | 73 | // Continuous Integration mode 74 | // if true, it capture browsers, run tests and exit 75 | singleRun: false 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /scss/bootstrap/forms.scss: -------------------------------------------------------------------------------- 1 | @import "../mixins"; 2 | @import "variables"; 3 | 4 | tags-input { 5 | &.ti-input-lg { 6 | min-height: $tags-height-lg; 7 | 8 | .tags { 9 | border-radius: $tag-border-radius-lg; 10 | 11 | .tag-item { 12 | height: $tag-height-lg; 13 | line-height: $tag-height-lg - 1px; 14 | font-size: $tag-font-size-lg; 15 | border-radius: $tag-border-radius-lg; 16 | 17 | .remove-button { 18 | font-size: $remove-button-font-size-lg; 19 | } 20 | } 21 | 22 | .input { 23 | height: $tag-height-lg; 24 | font-size: $input-font-size-lg; 25 | } 26 | } 27 | } 28 | 29 | &.ti-input-sm { 30 | min-height: $tags-height-sm; 31 | 32 | .tags { 33 | border-radius: $tag-border-radius-sm; 34 | 35 | .tag-item { 36 | height: $tag-height-sm; 37 | line-height: $tag-height-sm - 1px; 38 | font-size: $tag-font-size-sm; 39 | border-radius: $tag-border-radius-sm; 40 | 41 | .remove-button { 42 | font-size: $remove-button-font-size-sm; 43 | } 44 | } 45 | 46 | .input { 47 | height: $tag-height-sm; 48 | font-size: $input-font-size-sm; 49 | } 50 | } 51 | } 52 | } 53 | 54 | .has-feedback tags-input { 55 | .tags { 56 | padding-right: 30px; 57 | } 58 | } 59 | 60 | .has-success tags-input { 61 | .tags { 62 | border-color: $tags-border-color-success; 63 | 64 | &.focused { 65 | border-color: $tags-outline-border-color-success; 66 | @include box-shadow($tags-outline-border-shadow-success) 67 | } 68 | } 69 | } 70 | 71 | .has-error tags-input { 72 | .tags { 73 | border-color: $tags-border-color-error; 74 | 75 | &.focused { 76 | border-color: $tags-outline-border-color-error; 77 | @include box-shadow($tags-outline-border-shadow-error) 78 | } 79 | } 80 | } 81 | 82 | .has-warning tags-input { 83 | .tags { 84 | border-color: $tags-border-color-warning; 85 | 86 | &.focused { 87 | border-color: $tags-outline-border-color-warning; 88 | @include box-shadow($tags-outline-border-shadow-warning) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /scss/bootstrap/main.scss: -------------------------------------------------------------------------------- 1 | @import "../mixins"; 2 | @import "variables"; 3 | 4 | tags-input { 5 | box-shadow: none; 6 | border: none; 7 | padding: 0; 8 | min-height: $tags-height; 9 | 10 | .host { 11 | margin: 0; 12 | } 13 | 14 | .tags { 15 | -moz-appearance: none; 16 | -webkit-appearance: none; 17 | border: $tags-border; 18 | border-radius: $tags-border-radius; 19 | @include box-shadow($tags-border-shadow); 20 | @include transition($tags-transition); 21 | 22 | .tag-item { 23 | color: $tag-color; 24 | background: $tag-bgcolor; 25 | border: $tag-border; 26 | border-radius: $tag-border-radius; 27 | 28 | &.selected { 29 | color: $tag-color-selected; 30 | background: $tag-bgcolor-selected; 31 | border: $tag-border-selected; 32 | } 33 | 34 | .remove-button:hover { 35 | text-decoration: none; 36 | } 37 | } 38 | } 39 | 40 | .tags.focused { 41 | border: $tags-outline-border; 42 | @include box-shadow($tags-outline-border-shadow); 43 | } 44 | 45 | .autocomplete { 46 | border-radius: $suggestions-border-radius; 47 | 48 | .suggestion-item { 49 | &.selected { 50 | color: $suggestion-color-selected; 51 | background-color: $suggestion-bgcolor-selected; 52 | 53 | em { 54 | color: $suggestion-highlight-color-selected; 55 | background-color: $suggestion-highlight-bgcolor-selected; 56 | } 57 | } 58 | 59 | em { 60 | color: $suggestion-highlight-color; 61 | background-color: $suggestion-highlight-bgcolor; 62 | } 63 | } 64 | } 65 | 66 | &.ng-invalid .tags { 67 | border-color: #843534; 68 | @include box-shadow(inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483) 69 | } 70 | 71 | &[disabled] { 72 | .tags { 73 | background-color: $tags-bgcolor-disabled; 74 | 75 | .tag-item { 76 | background: $tag-bgcolor-disabled; 77 | opacity: $tag-opacity-disabled; 78 | } 79 | 80 | .input { 81 | background-color: $tags-bgcolor-disabled; 82 | } 83 | } 84 | } 85 | } 86 | 87 | @import "input-groups"; 88 | @import "forms"; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Michael Benford", 4 | "email": "michael@michaelbenford.net" 5 | }, 6 | "name": "ng-tags-input", 7 | "prettyName": "ngTagsInput", 8 | "version": "3.2.0", 9 | "description": "Tags input directive for AngularJS", 10 | "license": "MIT", 11 | "homepage": "http://mbenford.github.io/ngTagsInput", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/mbenford/ngTagsInput.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/mbenford/ngTagsInput/issues" 18 | }, 19 | "keywords": [ 20 | "angular", 21 | "tags", 22 | "tags-input" 23 | ], 24 | "scripts": { 25 | "test": "grunt test", 26 | "travis": "grunt travis" 27 | }, 28 | "main": "build/ng-tags-input.min.js", 29 | "dependencies": {}, 30 | "devDependencies": { 31 | "babel-plugin-angularjs-annotate": "0.7.0", 32 | "babel-plugin-external-helpers": "6.22.0", 33 | "babel-preset-es2015": "6.24.1", 34 | "dgeni": "0.4.7", 35 | "dgeni-packages": "0.17.1", 36 | "glob": "7.1.1", 37 | "grunt": "1.0.1", 38 | "grunt-angular-templates": "1.1.0", 39 | "grunt-bump": "0.8.0", 40 | "grunt-contrib-clean": "1.1.0", 41 | "grunt-contrib-compress": "1.4.1", 42 | "grunt-contrib-copy": "1.0.0", 43 | "grunt-contrib-cssmin": "2.1.0", 44 | "grunt-contrib-sass": "1.0.0", 45 | "grunt-contrib-uglify": "2.3.0", 46 | "grunt-conventional-changelog": "6.1.0", 47 | "grunt-eslint": "19.0.0", 48 | "grunt-karma": "2.0.0", 49 | "grunt-karma-coveralls": "2.5.4", 50 | "grunt-open": "0.2.3", 51 | "grunt-rollup": "1.0.1", 52 | "grunt-shell": "2.1.0", 53 | "grunt-text-replace": "0.4.0", 54 | "jasmine-core": "2.5.2", 55 | "karma": "1.6.0", 56 | "karma-babel-preprocessor": "6.0.1", 57 | "karma-chrome-launcher": "2.0.0", 58 | "karma-coverage": "1.1.1", 59 | "karma-firefox-launcher": "1.0.1", 60 | "karma-jasmine": "1.1.0", 61 | "karma-mocha-reporter": "2.2.3", 62 | "karma-ng-html2js-preprocessor": "1.0.0", 63 | "karma-opera-launcher": "1.0.0", 64 | "karma-phantomjs-launcher": "1.0.0", 65 | "karma-sauce-launcher": "1.1.0", 66 | "load-grunt-tasks": "3.5.2", 67 | "phantomjs-prebuilt": "2.1.7", 68 | "remap-istanbul": "^0.9.5", 69 | "rollup-plugin-babel": "2.7.1", 70 | "rollup-plugin-includepaths": "0.2.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scss/variables.scss: -------------------------------------------------------------------------------- 1 | // general 2 | $base-font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | 4 | // tagsInput 5 | $tags-bgcolor: #fff; 6 | $tags-border: 1px solid darkgray; 7 | $tags-border-shadow: 1px 1px 1px 0 lightgray inset; 8 | $tags-outline-box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6); 9 | $tags-outline-box-shadow-invalid: 0 0 3px 1px rgba(255, 0, 0, 0.6); 10 | $tags-bgcolor-disabled: #eee; 11 | 12 | $tag-height: 26px; 13 | $tag-font: 14px $base-font-family; 14 | $tag-color: rgba(240, 249, 255, 1) 0%, rgba(203, 235, 255, 1) 47%, rgba(161, 219, 255, 1) 100%; 15 | $tag-border: 1px solid rgb(172, 172, 172); 16 | $tag-border-radius: 3px; 17 | $tag-color-selected: rgba(254, 187, 187, 1) 0%, rgba(254, 144, 144, 1) 45%, rgba(255, 92, 92, 1) 100%; 18 | $tag-color-invalid: #ff0000; 19 | $tag-color-disabled: rgba(240, 249, 255, 1) 0%, rgba(203, 235, 255, 0.75) 47%, rgba(161, 219, 255, 0.62) 100%; 20 | $tag-opacity-disabled: 0.65; 21 | 22 | $remove-button-color: #585858; 23 | $remove-button-color-active: #ff0000; 24 | $remove-button-font: bold 16px Arial, sans-serif; 25 | 26 | $input-font: $tag-font; 27 | 28 | // autoComplete 29 | $suggestions-z-index: 999; 30 | $suggestions-width: 100%; 31 | $suggestions-bgcolor: #fff; 32 | $suggestions-border: 1px solid rgba(0, 0, 0, 0.2); 33 | $suggestions-border-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 34 | 35 | $suggestion-font: 16px $base-font-family; 36 | $suggestion-color: #000; 37 | $suggestion-bgcolor: #fff; 38 | $suggestion-color-selected: #fff; 39 | $suggestion-bgcolor-selected: #0097cf; 40 | 41 | $suggestion-highlight-font: normal bold $suggestion-font; 42 | $suggestion-highlight-color: $suggestion-color; 43 | $suggestion-highlight-bgcolor: $suggestion-bgcolor; 44 | $suggestion-highlight-color-selected: $suggestion-color-selected; 45 | $suggestion-highlight-bgcolor-selected: $suggestion-bgcolor-selected; -------------------------------------------------------------------------------- /scss/bootstrap/variables.scss: -------------------------------------------------------------------------------- 1 | // tagsInput 2 | $tags-border: 1px solid #ccc; 3 | $tags-border-radius: 4px; 4 | $tags-border-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); 5 | $tags-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; 6 | $tags-outline-border: 1px solid #66afe9; 7 | $tags-outline-border-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6); 8 | $tags-bgcolor-disabled: #eee; 9 | 10 | $tag-color: #fff; 11 | $tag-bgcolor: #428bca; 12 | $tag-border: 1px solid #357ebd; 13 | $tag-border-radius: 4px; 14 | $tag-bgcolor-disabled: #337ab7; 15 | $tag-opacity-disabled: 0.65; 16 | 17 | $tag-color-selected: #fff; 18 | $tag-bgcolor-selected: #d9534f; 19 | $tag-border-selected: 1px solid #d43f3a; 20 | 21 | $tags-height: 34px; 22 | 23 | $tags-height-lg: 46px; 24 | $tag-height-lg: 38px; 25 | $tag-font-size-lg: 18px; 26 | $tag-border-radius-lg: 6px; 27 | $remove-button-font-size-lg: 20px; 28 | $input-font-size-lg: 18px; 29 | 30 | $tags-height-sm: 30px; 31 | $tag-height-sm: 22px; 32 | $tag-font-size-sm: 12px; 33 | $tag-border-radius-sm: 3px; 34 | $remove-button-font-size-sm: 16px; 35 | $input-font-size-sm: 12px; 36 | 37 | $tags-border-color-success: #3c763d; 38 | $tags-outline-border-color-success: #2b542c; 39 | $tags-outline-border-shadow-success: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; 40 | 41 | $tags-border-color-error: #a94442; 42 | $tags-outline-border-color-error: #843534; 43 | $tags-outline-border-shadow-error: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; 44 | 45 | $tags-border-color-warning: #8a6d3b; 46 | $tags-outline-border-color-warning: #66512c; 47 | $tags-outline-border-shadow-warning: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; 48 | 49 | // autoComplete 50 | $suggestions-border-radius: 4px; 51 | 52 | $suggestion-color-selected: #262626; 53 | $suggestion-bgcolor-selected: #f5f5f5; 54 | 55 | $suggestion-highlight-color: #000; 56 | $suggestion-highlight-bgcolor: #fff; 57 | $suggestion-highlight-color-selected: $suggestion-color-selected; 58 | $suggestion-highlight-bgcolor-selected: $suggestion-bgcolor-selected; 59 | -------------------------------------------------------------------------------- /scss/tags-input.scss: -------------------------------------------------------------------------------- 1 | @import "mixins"; 2 | @import "variables"; 3 | 4 | tags-input { 5 | .tags { 6 | -moz-appearance: textfield; 7 | -webkit-appearance: textfield; 8 | padding: 1px; 9 | overflow: hidden; 10 | word-wrap: break-word; 11 | cursor: text; 12 | background-color: $tags-bgcolor; 13 | border: $tags-border; 14 | box-shadow: $tags-border-shadow; 15 | height: 100%; 16 | 17 | &.focused { 18 | outline: none; 19 | @include box-shadow($tags-outline-box-shadow); 20 | } 21 | 22 | .tag-list { 23 | margin: 0; 24 | padding: 0; 25 | list-style-type: none; 26 | } 27 | 28 | .tag-item { 29 | margin: 2px; 30 | padding: 0 5px; 31 | display: inline-block; 32 | float: left; 33 | font: $tag-font; 34 | height: $tag-height; 35 | line-height: $tag-height - 1px; 36 | border: $tag-border; 37 | border-radius: $tag-border-radius; 38 | @include gradient($tag-color); 39 | 40 | &.selected { 41 | @include gradient($tag-color-selected); 42 | } 43 | 44 | .remove-button { 45 | margin: 0 0 0 5px; 46 | padding: 0; 47 | border: none; 48 | background: none; 49 | cursor: pointer; 50 | vertical-align: middle; 51 | font: $remove-button-font; 52 | color: $remove-button-color; 53 | 54 | &:active { 55 | color: $remove-button-color-active; 56 | } 57 | } 58 | } 59 | 60 | .input { 61 | border: 0; 62 | outline: none; 63 | margin: 2px; 64 | padding: 0; 65 | padding-left: 5px; 66 | float: left; 67 | height: $tag-height; 68 | font: $input-font; 69 | 70 | &.invalid-tag { 71 | color: $tag-color-invalid; 72 | } 73 | 74 | &::-ms-clear { 75 | display: none; 76 | } 77 | } 78 | 79 | } 80 | 81 | &.ng-invalid .tags { 82 | @include box-shadow($tags-outline-box-shadow-invalid); 83 | } 84 | 85 | &[disabled] { 86 | .host:focus { 87 | outline: none; 88 | } 89 | 90 | .tags { 91 | background-color: $tags-bgcolor-disabled; 92 | cursor: default; 93 | 94 | .tag-item { 95 | opacity: $tag-opacity-disabled; 96 | @include gradient($tag-color-disabled); 97 | 98 | .remove-button { 99 | cursor: default; 100 | 101 | &:active { 102 | color: $remove-button-color; 103 | } 104 | } 105 | } 106 | 107 | .input { 108 | background-color: $tags-bgcolor-disabled; 109 | cursor: default; 110 | } 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = grunt => { 2 | function loadConfig(path) { 3 | let glob = require('glob'); 4 | let object = {}; 5 | 6 | glob.sync('*', { cwd: path }).forEach(option => { 7 | let key = option.replace(/\.js$/, ''); 8 | let data = require(path + option); 9 | object[key] = typeof data === 'function' ? data(grunt) : data; 10 | }); 11 | 12 | return object; 13 | } 14 | 15 | const config = { 16 | pkg: grunt.file.readJSON('package.json'), 17 | bowerDirectory: '../ngTagsInput-bower', 18 | bowerFile: '<%= bowerDirectory %>/bower.json', 19 | websiteDirectory: '../ngTagsInput-website', 20 | websiteConfigFile: '<%= websiteDirectory %>/_config.yml', 21 | 22 | files: { 23 | js: { 24 | src: 'src/init.js', 25 | out: 'build/<%= pkg.name %>.js', 26 | outMin: 'build/<%= pkg.name %>.min.js' 27 | }, 28 | css: { 29 | main: { 30 | src: 'scss/main.scss', 31 | out: 'build/<%= pkg.name %>.css', 32 | outMin: 'build/<%= pkg.name %>.min.css' 33 | }, 34 | bootstrap: { 35 | src: 'scss/bootstrap/main.scss', 36 | out: 'build/<%= pkg.name %>.bootstrap.css', 37 | outMin: 'build/<%= pkg.name %>.bootstrap.min.css' 38 | } 39 | }, 40 | html: { 41 | src: 'templates/*.html', 42 | out: 'build/tmp/compiled-templates.js' 43 | }, 44 | zip: { 45 | unminified: 'build/<%= pkg.name %>.zip', 46 | minified: 'build/<%= pkg.name %>.min.zip' 47 | }, 48 | tgz: { 49 | npm: 'build/<%= pkg.name %>.tgz' 50 | }, 51 | spec: { 52 | src: 'test/*.spec.js' 53 | } 54 | }, 55 | banners: { 56 | unminified: 57 | `/*! 58 | * <%= pkg.prettyName %> v<%= pkg.version %> 59 | * <%= pkg.homepage %> 60 | * 61 | * Copyright (c) 2013-<%= grunt.template.today("yyyy") %> <%= pkg.author.name %> 62 | * License: <%= pkg.license %> 63 | * 64 | * Generated at <%= grunt.template.today("yyyy-mm-dd HH:MM:ss o") %> 65 | */`, 66 | minified: '/*! <%= pkg.prettyName %> v<%= pkg.version %> License: <%= pkg.license %> */' 67 | } 68 | }; 69 | 70 | grunt.util._.extend(config, loadConfig('./grunt/options/')); 71 | grunt.initConfig(config); 72 | 73 | require('load-grunt-tasks')(grunt); 74 | grunt.loadTasks('grunt/tasks'); 75 | grunt.loadNpmTasks('remap-istanbul'); 76 | 77 | grunt.registerTask('lint', ['eslint']); 78 | grunt.registerTask('test', ['lint', 'clean', 'ngtemplates', 'rollup', 'karma:local']); 79 | grunt.registerTask('coverage', ['test', 'remapIstanbul', 'open:coverage']); 80 | grunt.registerTask('docs', ['clean:build', 'dgeni']); 81 | grunt.registerTask('travis', ['pack', 'compress', 'copy:travis', 'coveralls']); 82 | grunt.registerTask('javascript-only', ['test', 'uglify']); 83 | grunt.registerTask('css-only', ['sass', 'cssmin']); 84 | grunt.registerTask('release', [ 85 | 'pack', 86 | 'compress', 87 | 'changelog', 88 | 'replace:changelog', 89 | 'shell:git', 90 | 'copy:bower', 91 | 'update-bower-version', 92 | 'shell:git_bower', 93 | 'dgeni', 94 | 'copy:website', 95 | 'update-website-version', 96 | 'shell:git_website' 97 | ]); 98 | grunt.registerTask('default', ['pack']); 99 | }; 100 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /*** 2 | * @ngdoc service 3 | * @name tiUtil 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Helper methods used internally by the directive. Should not be called directly from user code. 8 | */ 9 | export default function UtilService($timeout, $q) { 10 | 'ngInject'; 11 | 12 | let self = {}; 13 | 14 | self.debounce = (fn, delay) => { 15 | let timeoutId; 16 | return function(...args) { 17 | $timeout.cancel(timeoutId); 18 | timeoutId = $timeout(function() { fn.apply(null, args); }, delay); 19 | }; 20 | }; 21 | 22 | self.makeObjectArray = (array, key) => { 23 | if (!angular.isArray(array) || array.length === 0 || angular.isObject(array[0])) { 24 | return array; 25 | } 26 | 27 | return array.map(item => ({ [key]: item })); 28 | }; 29 | 30 | self.findInObjectArray = (array, obj, key, comparer) => { 31 | let item = null; 32 | comparer = comparer || self.defaultComparer; 33 | 34 | array.some(element => { 35 | if (comparer(element[key], obj[key])) { 36 | item = element; 37 | return true; 38 | } 39 | }); 40 | 41 | return item; 42 | }; 43 | 44 | self.defaultComparer = (a, b) => { 45 | // I'm aware of the internationalization issues regarding toLowerCase() 46 | // but I couldn't come up with a better solution right now 47 | return self.safeToString(a).toLowerCase() === self.safeToString(b).toLowerCase(); 48 | }; 49 | 50 | self.safeHighlight = (str, value) => { 51 | str = self.encodeHTML(str); 52 | value = self.encodeHTML(value); 53 | 54 | if (!value) { 55 | return str; 56 | } 57 | 58 | let escapeRegexChars = str => str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); 59 | let expression = new RegExp('&[^;]+;|' + escapeRegexChars(value), 'gi'); 60 | 61 | return str.replace(expression, match => match.toLowerCase() === value.toLowerCase() ? '' + match + '' : match); 62 | }; 63 | 64 | self.safeToString = value => angular.isUndefined(value) || value === null ? '' : value.toString().trim(); 65 | 66 | self.encodeHTML = value => self.safeToString(value).replace(/&/g, '&').replace(//g, '>'); 67 | 68 | self.handleUndefinedResult = (fn, valueIfUndefined) => { 69 | return function () { 70 | let result = fn.apply(null, arguments); 71 | return angular.isUndefined(result) ? valueIfUndefined : result; 72 | }; 73 | }; 74 | 75 | self.replaceSpacesWithDashes = str => self.safeToString(str).replace(/\s/g, '-'); 76 | 77 | self.isModifierOn = event => event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; 78 | 79 | self.promisifyValue = value => { 80 | value = angular.isUndefined(value) ? true : value; 81 | return $q[value ? 'when' : 'reject'](); 82 | }; 83 | 84 | self.simplePubSub = function() { 85 | let events = {}; 86 | return { 87 | on(names, handler, first) { 88 | names.split(' ').forEach(name => { 89 | if (!events[name]) { 90 | events[name] = []; 91 | } 92 | let method = first ? [].unshift : [].push; 93 | method.call(events[name], handler); 94 | }); 95 | return this; 96 | }, 97 | trigger(name, args) { 98 | let handlers = events[name] || []; 99 | handlers.every(handler => self.handleUndefinedResult(handler, true)(args)); 100 | return this; 101 | } 102 | }; 103 | }; 104 | 105 | return self; 106 | } 107 | -------------------------------------------------------------------------------- /test/autosize.spec.js: -------------------------------------------------------------------------------- 1 | describe('autosize directive', () => { 2 | let $scope, $compile, tagsInputConfigMock, 3 | element, style, container; 4 | 5 | beforeEach(() => { 6 | module('ngTagsInput'); 7 | 8 | tagsInputConfigMock = jasmine.createSpyObj('tagsInputConfig', ['getTextAutosizeThreshold']); 9 | tagsInputConfigMock.getTextAutosizeThreshold.and.returnValue(3); 10 | 11 | module($provide => { 12 | $provide.value('tagsInputConfig', tagsInputConfigMock); 13 | }); 14 | 15 | inject(($rootScope, _$compile_) => { 16 | $scope = $rootScope; 17 | $compile = _$compile_; 18 | }); 19 | 20 | style = angular.element('').appendTo('head'); 21 | container = angular.element('
').appendTo('body'); 22 | }); 23 | 24 | afterEach(() => { 25 | style.remove(); 26 | container.remove(); 27 | }); 28 | 29 | function compile(...args) { 30 | let attributes = args.join(' '); 31 | element = angular.element(''); 32 | container.append(element); 33 | 34 | $compile(element)($scope); 35 | $scope.$digest(); 36 | } 37 | 38 | function getTextWidth(text, threshold) { 39 | let width, span = angular.element(''); 40 | threshold = threshold || 3; 41 | 42 | span.css('white-space', 'pre'); 43 | span.text(text); 44 | container.append(span); 45 | width = parseInt(span.prop('offsetWidth'), 10) + threshold; 46 | 47 | span.remove(); 48 | 49 | return width + 'px'; 50 | } 51 | 52 | it('re-sizes the input width when its view content changes', () => { 53 | // Arrange 54 | let text = 'AAAAAAAAAA'; 55 | compile(); 56 | 57 | // Act 58 | changeElementValue(element, text); 59 | 60 | // Arrange 61 | expect(element.css('width')).toBe(getTextWidth(text)); 62 | }); 63 | 64 | it('re-sizes the input width when its model value changes', () => { 65 | // Arrange 66 | let text = 'AAAAAAAAAAAAAAAAAAAA'; 67 | compile(); 68 | 69 | // Act 70 | $scope.model = text; 71 | $scope.$digest(); 72 | 73 | // Arrange 74 | expect(element.css('width')).toBe(getTextWidth(text)); 75 | }); 76 | 77 | it('sets the input width as the placeholder width when the input is empty', () => { 78 | // Arrange 79 | $scope.placeholder = 'Some placeholder'; 80 | compile('placeholder="{{placeholder}}"'); 81 | 82 | // Act 83 | $scope.model = ''; 84 | $scope.$digest(); 85 | 86 | // Assert 87 | expect(element.css('width')).toBe(getTextWidth('Some placeholder')); 88 | }); 89 | 90 | it('sets the input width as the placeholder width when the input is empty and the placeholder changes', () => { 91 | // Arrange 92 | $scope.placeholder = 'Some placeholder'; 93 | compile('placeholder="{{placeholder}}"'); 94 | $scope.model = ''; 95 | $scope.$digest(); 96 | 97 | // Act 98 | $scope.placeholder = 'Some very lengthy placeholder'; 99 | $scope.$digest(); 100 | 101 | // Assert 102 | expect(element.css('width')).toBe(getTextWidth('Some very lengthy placeholder')); 103 | }); 104 | 105 | it('clears the input width when it cannot be calculated', () => { 106 | // Arrange 107 | container.hide(); 108 | compile(); 109 | 110 | // Act 111 | changeElementValue(element, 'AAAAAAAAAAAAAA'); 112 | 113 | // Assert 114 | expect(element.prop('style').width).toBe(''); 115 | }); 116 | 117 | it('overrides the threshold', () => { 118 | // Arrange 119 | let text = 'AAAAAAAAAAAAAA'; 120 | tagsInputConfigMock.getTextAutosizeThreshold.and.returnValue(20); 121 | compile(); 122 | 123 | // Act 124 | changeElementValue(element, text); 125 | 126 | // Assert 127 | expect(element.css('width')).toBe(getTextWidth(text, 20)); 128 | }); 129 | }); -------------------------------------------------------------------------------- /src/configuration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @ngdoc service 3 | * @name tagsInputConfig 4 | * @module ngTagsInput 5 | * 6 | * @description 7 | * Sets global configuration settings for both tagsInput and autoComplete directives. It's also used internally to parse and 8 | * initialize options from HTML attributes. 9 | */ 10 | export default function TagsInputConfigurationProvider() { 11 | 'ngInject'; 12 | 13 | let globalDefaults = {}; 14 | let interpolationStatus = {}; 15 | let autosizeThreshold = 3; 16 | 17 | /** 18 | * @ngdoc method 19 | * @name tagsInputConfig#setDefaults 20 | * @description Sets the default configuration option for a directive. 21 | * 22 | * @param {string} directive Name of the directive to be configured. Must be either 'tagsInput' or 'autoComplete'. 23 | * @param {object} defaults Object containing options and their values. 24 | * 25 | * @returns {object} The service itself for chaining purposes. 26 | */ 27 | this.setDefaults = (directive, defaults) => { 28 | globalDefaults[directive] = defaults; 29 | return this; 30 | }; 31 | 32 | /** 33 | * @ngdoc method 34 | * @name tagsInputConfig#setActiveInterpolation 35 | * @description Sets active interpolation for a set of options. 36 | * 37 | * @param {string} directive Name of the directive to be configured. Must be either 'tagsInput' or 'autoComplete'. 38 | * @param {object} options Object containing which options should have interpolation turned on at all times. 39 | * 40 | * @returns {object} The service itself for chaining purposes. 41 | */ 42 | this.setActiveInterpolation = (directive, options) => { 43 | interpolationStatus[directive] = options; 44 | return this; 45 | }; 46 | 47 | /** 48 | * @ngdoc method 49 | * @name tagsInputConfig#setTextAutosizeThreshold 50 | * @description Sets the threshold used by the tagsInput directive to re-size the inner input field element based on its contents. 51 | * 52 | * @param {number} threshold Threshold value, in pixels. 53 | * 54 | * @returns {object} The service itself for chaining purposes. 55 | */ 56 | this.setTextAutosizeThreshold = threshold => { 57 | autosizeThreshold = threshold; 58 | return this; 59 | }; 60 | 61 | this.$get = $interpolate => { 62 | 'ngInject'; 63 | 64 | let converters = { 65 | [String]: value => value.toString(), 66 | [Number]: value => parseInt(value, 10), 67 | [Boolean]: value => value.toLowerCase() === 'true', 68 | [RegExp]: value => new RegExp(value) 69 | }; 70 | 71 | return { 72 | load(directive, element, attrs, events, optionDefinitions) { 73 | let defaultValidator = () => true; 74 | let options = {}; 75 | 76 | angular.forEach(optionDefinitions, (value, key) => { 77 | let type = value[0]; 78 | let localDefault = value[1]; 79 | let validator = value[2] || defaultValidator; 80 | let converter = converters[type]; 81 | 82 | let getDefault = () => { 83 | let globalValue = globalDefaults[directive] && globalDefaults[directive][key]; 84 | return angular.isDefined(globalValue) ? globalValue : localDefault; 85 | }; 86 | 87 | let updateValue = value => { 88 | options[key] = value && validator(value) ? converter(value) : getDefault(); 89 | }; 90 | 91 | if (interpolationStatus[directive] && interpolationStatus[directive][key]) { 92 | attrs.$observe(key, value => { 93 | updateValue(value); 94 | events.trigger('option-change', { name: key, newValue: value }); 95 | }); 96 | } 97 | else { 98 | updateValue(attrs[key] && $interpolate(attrs[key])(element.scope())); 99 | } 100 | }); 101 | 102 | return options; 103 | }, 104 | getTextAutosizeThreshold() { 105 | return autosizeThreshold; 106 | } 107 | }; 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /test/lib/ng-stats.min.js: -------------------------------------------------------------------------------- 1 | /*! ng-stats 2.1.3 created by Kent C. Dodds | 2015-03-03 */ 2 | !function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"undefined"!=typeof module&&module.exports?module.exports=b:a.showAngularStats=b()}(window,function(){"use strict";function a(){if(!z){z=!0;var a=d(),b=Object.getPrototypeOf(a),c=b.$digest;b.$digest=function(){var a=u();c.apply(this,arguments);var b=u()-a;i(e(),b)}}}function b(a){window.angular&&d()?c(a):window.setTimeout(function(){b(a)},200)}function c(b){function c(a,c,d){var e=a.charAt(0).toUpperCase()+a.slice(1);b["track"+e]&&(h[a]=[],c["track + capThingToTrack"]=function(b){d&&h[a][h.length-1]===b||(h[a][h.length-1]=b,h[a].push(b))})}function d(a,c,d){var f=a.charAt(0).toUpperCase()+a.slice(1);if(b["log"+f]){var g;c["log"+f]=function(b){if(!d||g!==b){g=b;var c=e(a,b);c?console.log("%c"+a+":",c,b):console.log(a+":",b)}}}}function e(a,c){var d;return"digest"===a&&(d=c>b.digestTimeThreshold?"color:red":"color:green"),d}function f(a,c){var d=c||y,e=d>b.digestTimeThreshold?"red":"green";if(x=j(a)?x:a,y=j(c)?y:c,m.text(x+" | "+y.toFixed(2)).css({color:e}),c){var f=o.getContext("2d");l>0&&(l=0,f.fillStyle="#333",f.fillRect(n.width-1,0,1,n.height)),f.fillStyle=e,f.fillRect(n.width-1,Math.max(0,n.height-d),2,2)}}function g(){if(i.active){window.setTimeout(g,250);var a=o.getContext("2d"),b=a.getImageData(1,0,n.width-1,n.height);a.putImageData(b,0,0),a.fillStyle=l++>2?"black":"#333",a.fillRect(n.width-1,0,1,n.height)}}b=b||{};var h={listeners:A};if(t&&(t.$el&&t.$el.remove(),t.active=!1,t=null),b===!1)return void sessionStorage.removeItem(s);b.position=b.position||"top-left",b=angular.extend({digestTimeThreshold:16,autoload:!1,trackDigest:!1,trackWatches:!1,logDigest:!1,logWatches:!1,styles:{position:"fixed",background:"black",borderBottom:"1px solid #666",borderRight:"1px solid #666",color:"red",fontFamily:"Courier",width:130,zIndex:9999,textAlign:"right",top:-1==b.position.indexOf("top")?null:0,bottom:-1==b.position.indexOf("bottom")?null:0,right:-1==b.position.indexOf("right")?null:0,left:-1==b.position.indexOf("left")?null:0}},b||{}),a();var i=t={active:!0};b.autoload?sessionStorage.setItem(s,JSON.stringify(b)):sessionStorage.removeItem(s);var k=angular.element(document.body),l=0;i.$el=angular.element("
").css(b.styles),k.append(i.$el);var m=i.$el.find("div"),n={width:130,height:40},o=i.$el.find("canvas").attr(n)[0];return A.digestLength.ngStatsAddToCanvas=function(a){f(null,a)},A.watchCount.ngStatsAddToCanvas=function(a){f(a)},c("digest",A.digestLength),c("watches",A.watchCount,!0),d("digest",A.digestLength),d("watches",A.watchCount,!0),g(),r.$$phase||r.$digest(),h}function d(){if(r)return r;var a=document.querySelector(".ng-scope");return a?r=angular.element(a).scope().$root:null}function e(){window.clearTimeout(w);var a=u();return a-v>300?(v=a,x=k()):w=window.setTimeout(function(){i(e())},350),x}function f(a){var b=g(a);return k(b)}function g(a){a=angular.element(a);var b=a.scope();return b||(a=angular.element(a.querySelector(".ng-scope")),b=a.scope()),b}function h(a){return a&&a.$$watchers?a.$$watchers:[]}function i(a,b){j(a)||angular.forEach(A.watchCount,function(b){b(a)}),j(b)||angular.forEach(A.digestLength,function(a){a(b)})}function j(a){return null===a||void 0===a}function k(a){var b=0;return l(a,function(a){b+=h(a).length}),b}function l(a,b){if("function"==typeof a&&(b=a,a=null),a=a||d(),a=p(a)){var c=b(a);return c===!1?c:n(a,b)}}function m(a,b){for(var c;(a=a.$$nextSibling)&&(c=b(a),c!==!1)&&(c=n(a,b),c!==!1););return c}function n(a,b){for(var c;(a=a.$$childHead)&&(c=b(a),c!==!1)&&(c=m(a,b),c!==!1););return c}function o(a){var b=null;return l(function(c){return c.$id===a?(b=c,!1):void 0}),b}function p(a){return q(a)&&(a=o(a)),a}function q(a){return"string"==typeof a||"number"==typeof a}var r,s="showAngularStats_autoload",t=null,u=window.performance&&window.performance.now?function(){return window.performance.now()}:function(){return Date.now()},v=u(),w=null,x=e()||0,y=0,z=!1,A={watchCount:{},digestLength:{}},B=sessionStorage[s];return B&&b(JSON.parse(B)),angular.module("angularStats",[]).directive("angularStats",function(){function b(a){for(var b=a[0];b.parentElement;)b=b.parentElement;return b}var c=1;return{scope:{digestLength:"@",watchCount:"@",watchCountRoot:"@",onDigestLengthUpdate:"&?",onWatchCountUpdate:"&?"},link:function(d,e,g){a();var h=c++;if(g.hasOwnProperty("digestLength")){var i=e;g.digestLength&&(i=angular.element(e[0].querySelector(g.digestLength))),A.digestLength["ngStatsDirective"+h]=function(a){i.text((a||0).toFixed(2))}}if(g.hasOwnProperty("watchCount")){var j,k=e;if(d.watchCount&&(k=angular.element(e[0].querySelector(g.watchCount))),d.watchCountRoot)if("this"===d.watchCountRoot)j=e;else{var l;if(l=g.hasOwnProperty("watchCountOfChild")?e[0]:b(e),j=angular.element(l.querySelector(d.watchCountRoot)),!j.length)throw new Error("no element at selector: "+d.watchCountRoot)}A.watchCount["ngStatsDirective"+h]=function(a){var b=a;j&&(b=f(j)),k.text(b)}}d.onWatchCountUpdate&&(A.watchCount["ngStatsDirectiveUpdate"+h]=function(a){d.onWatchCountUpdate({watchCount:a})}),d.onDigestLengthUpdate&&(A.digestLength["ngStatsDirectiveUpdate"+h]=function(a){d.onDigestLengthUpdate({digestLength:a})}),d.$on("$destroy",function(){delete A.digestLength["ngStatsDirectiveUpdate"+h],delete A.watchCount["ngStatsDirectiveUpdate"+h],delete A.digestLength["ngStatsDirective"+h],delete A.watchCount["ngStatsDirective"+h]})}}}),c}); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠ This project is no longer maintained 2 | 3 | # ngTagsInput 4 | 5 | [![Travis](https://img.shields.io/travis/mbenford/ngTagsInput.svg?style=flat)](https://travis-ci.org/mbenford/ngTagsInput) 6 | [![Coveralls](https://img.shields.io/coveralls/mbenford/ngTagsInput.svg?style=flat)](https://coveralls.io/r/mbenford/ngTagsInput?branch=master) 7 | [![David](https://img.shields.io/david/dev/mbenford/ngTagsInput.svg?style=flat)](https://david-dm.org/mbenford/ngTagsInput#info=devDependencies) 8 | [![GitHub release](https://img.shields.io/github/release/mbenford/ngTagsInput.svg)](https://github.com/mbenford/ngTagsInput/releases) 9 | 10 | Tags input directive for AngularJS. Check out the [ngTagsInput website](http://mbenford.github.io/ngTagsInput) for more information. 11 | 12 | ## Requirements 13 | 14 | - AngularJS 1.3+ 15 | - A modern browser (Chrome 31+, Firefox 29+, Safari 7+, Opera 12+, IE 10+) 16 | 17 | ## Installing 18 | 19 | All files are available from a variety of sources. Choose the one that best fits your needs: 20 | 21 | - Direct download (https://github.com/mbenford/ngTagsInput/releases) 22 | - NPM (`npm install ng-tags-input --save`) 23 | - Bower (`bower install ng-tags-input --save`) 24 | - CDNJS (http://cdnjs.com/libraries/ng-tags-input) 25 | 26 | You can also grab the [latest build](#latest-build) generated by Travis. It's fully functional and may contain new features and bugfixes not yet published to the services listed above. 27 | 28 | Now all you have to do is add the scripts to your application. Just make sure the `ng-tags-input.js` file is inserted **after** the `angular.js` script: 29 | 30 | ```html 31 | 32 | 33 | 34 | ``` 35 | 36 | ## Usage 37 | 38 | 1. Add the `ngTagsInput` module as a dependency in your AngularJS app; 39 | 2. Add the custom element `` to the HTML file where you want to use an input tag control and bind it to a property of your model. That property, if it exists, must be an array of objects and each object must have a property named `text` containing the tag text; 40 | 3. Set up the options that make sense to your application; 41 | 4. Enable autocomplete, if you want to use it, by adding the directive `` inside the `` tag, and bind it to a function of your model. That function must return either an array of objects or a promise that eventually resolves to an array of objects (same rule from step 2 applies here); 42 | 5. Customize the CSS classes, if you want to. 43 | 6. You're done! 44 | 45 | **Note:** There's a more detailed [getting started](http://mbenford.github.io/ngTagsInput/gettingstarted) guide on the ngTagsInput website. 46 | 47 | ## Example 48 | 49 | ```html 50 | 51 | 52 | 53 | 54 | 55 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ``` 77 | 78 | ## Options 79 | 80 | Check out the [documentation](http://mbenford.github.io/ngTagsInput/documentation/api) page for a detailed view of all available options. 81 | 82 | ## Demo 83 | 84 | You can see the directive in action in the [demo page](http://mbenford.github.io/ngTagsInput/demos). 85 | 86 | ## Contributing 87 | 88 | Before posting an issue or sending a pull request, make sure to read the [CONTRIBUTING](https://github.com/mbenford/ngTagsInput/blob/master/CONTRIBUTING.md) file. 89 | 90 | ## License 91 | 92 | See the [LICENSE](https://github.com/mbenford/ngTagsInput/blob/master/LICENSE) file. 93 | 94 | ## Changelog 95 | 96 | See the [CHANGELOG](https://github.com/mbenford/ngTagsInput/blob/master/CHANGELOG.md) page. 97 | 98 | ## Alternatives 99 | 100 | The following are some alternatives to ngTagsInput you may want to check out: 101 | 102 | - [angular-tags](http://decipherinc.github.io/angular-tags): Pure AngularJS tagging widget with typeahead support courtesy of ui-bootstrap 103 | - [angular-tagger](https://github.com/monterail/angular-tagger): Pure Angular autocomplete with tags, no jQuery 104 | - [jsTag](https://github.com/eranhirs/jstag): Open source project for editing tags (aka tokenizer) based on AngularJS 105 | - [bootstrap-tagsinput](http://timschlechter.github.io/bootstrap-tagsinput/examples): jQuery plugin providing a Twitter Bootstrap user interface for managing tags (provides Angular support) 106 | 107 | ## Latest build 108 | 109 | - Compressed: [ng-tags-input.min.zip](https://s3.amazonaws.com/ng-tags-input/ng-tags-input.min.zip) 110 | - Uncompressed: [ng-tags-input.zip](https://s3.amazonaws.com/ng-tags-input/ng-tags-input.zip) 111 | -------------------------------------------------------------------------------- /test/test-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ngTagsInput Test Page 5 | 6 | 7 | 8 | 9 | 10 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 39 | 40 | 54 | 55 |
56 | $watch :
57 | $digest: ms 58 |
59 | 60 | 136 | 137 | -------------------------------------------------------------------------------- /test/configuration.spec.js: -------------------------------------------------------------------------------- 1 | describe('configuration service', () => { 2 | let element, attrs, events, provider, service; 3 | 4 | beforeEach(() => { 5 | module('ngTagsInput', tagsInputConfigProvider => { 6 | provider = tagsInputConfigProvider; 7 | }); 8 | 9 | inject(($rootScope, $compile, tagsInputConfig) => { 10 | element = $compile('')($rootScope.$new()); 11 | service = tagsInputConfig; 12 | }); 13 | 14 | attrs = {}; 15 | events = { trigger: angular.noop }; 16 | }); 17 | 18 | it('loads literal values from attributes', () => { 19 | // Arrange 20 | attrs.prop1 = 'foobar'; 21 | attrs.prop2 = '42'; 22 | attrs.prop3 = 'true'; 23 | attrs.prop4 = '.*'; 24 | 25 | // Act 26 | let options = service.load('foo', element, attrs, events, { 27 | prop1: [String], 28 | prop2: [Number], 29 | prop3: [Boolean], 30 | prop4: [RegExp] 31 | }); 32 | 33 | // Assert 34 | expect(options).toEqual({ 35 | prop1: 'foobar', 36 | prop2: 42, 37 | prop3: true, 38 | prop4: /.*/ 39 | }); 40 | }); 41 | 42 | it('loads interpolated values from attributes', () => { 43 | // Arrange 44 | let scope = element.scope(); 45 | scope.prop1 = 'barfoo'; 46 | scope.prop2 = 24; 47 | scope.prop3 = false; 48 | scope.prop4 = '.+'; 49 | 50 | attrs.prop1 = '{{ prop1 }}'; 51 | attrs.prop2 = '{{ prop2 }}'; 52 | attrs.prop3 = '{{ prop3 }}'; 53 | attrs.prop4 = '{{ prop4 }}'; 54 | 55 | // Act 56 | let options = service.load('foo', element, attrs, events, { 57 | prop1: [String], 58 | prop2: [Number], 59 | prop3: [Boolean], 60 | prop4: [RegExp] 61 | }); 62 | 63 | // Assert 64 | expect(options).toEqual({ 65 | prop1: 'barfoo', 66 | prop2: 24, 67 | prop3: false, 68 | prop4: /.+/ 69 | }); 70 | }); 71 | 72 | it('loads interpolated values from attributes as they change', () => { 73 | // Arrange 74 | let scope = element.scope(); 75 | scope.$parent.prop1 = 'barfoo'; 76 | scope.$parent.prop3 = false; 77 | 78 | attrs.prop1 = '{{ prop1 }}'; 79 | attrs.prop3 = '{{ prop3 }}'; 80 | 81 | let callbacks = []; 82 | attrs.$observe = jasmine.createSpy().and.callFake(function(name, cb) { 83 | callbacks.push(cb); 84 | }); 85 | 86 | provider.setActiveInterpolation('foo', { prop2: true, prop4: true }); 87 | 88 | // Act 89 | let options = service.load('foo', element, attrs, events, { 90 | prop1: [String], 91 | prop2: [Number], 92 | prop3: [Boolean], 93 | prop4: [RegExp, /.*/] 94 | }); 95 | 96 | callbacks[0](42); 97 | callbacks[1](null); 98 | 99 | // Assert 100 | expect(options).toEqual({ 101 | prop1: 'barfoo', 102 | prop2: 42, 103 | prop3: false, 104 | prop4: /.*/ 105 | }); 106 | }); 107 | 108 | it('triggers an event when an interpolated value change', function() { 109 | // Arrange 110 | let scope = element.scope(); 111 | scope.prop1 = 'foobar'; 112 | attrs.prop1 = '{{ prop1 }}'; 113 | 114 | events = jasmine.createSpyObj('events', ['trigger']); 115 | 116 | let callback; 117 | attrs.$observe = jasmine.createSpy().and.callFake(function(name, cb) { 118 | callback = cb; 119 | }); 120 | 121 | provider.setActiveInterpolation('foo', { prop1: true }); 122 | 123 | // Act 124 | service.load('foo', scope, attrs, events, { 125 | prop1: [String] 126 | }); 127 | 128 | callback('barfoo'); 129 | 130 | // Assert 131 | expect(events.trigger).toHaveBeenCalledWith('option-change', { name: 'prop1', newValue: 'barfoo' }); 132 | }); 133 | 134 | it('loads default values when attributes are missing', function() { 135 | // Act 136 | let options = service.load('foo', element, attrs, events, { 137 | prop1: [String, 'foobaz'], 138 | prop2: [Number, 84], 139 | prop3: [Boolean, true], 140 | prop4: [RegExp, /.?/] 141 | }); 142 | 143 | // Assert 144 | expect(options).toEqual({ 145 | prop1: 'foobaz', 146 | prop2: 84, 147 | prop3: true, 148 | prop4: /.?/ 149 | }); 150 | }); 151 | 152 | it('overrides default values with global ones', function() { 153 | // Arrange 154 | provider.setDefaults('foo', { 155 | prop1: 'foobar', 156 | prop3: false 157 | }); 158 | 159 | // Act 160 | let options = service.load('foo', element, attrs, events, { 161 | prop1: [String, 'foobaz'], 162 | prop2: [Number, 84], 163 | prop3: [Boolean, true], 164 | prop4: [RegExp, /.?/] 165 | }); 166 | 167 | // Assert 168 | expect(options).toEqual({ 169 | prop1: 'foobar', 170 | prop2: 84, 171 | prop3: false, 172 | prop4: /.?/ 173 | }); 174 | }); 175 | 176 | it('overrides global configuration with local values', function() { 177 | // Arrange 178 | provider.setDefaults('foo', { 179 | prop1: 'foobar', 180 | prop2: 42, 181 | prop3: true, 182 | prop4: /.*/ 183 | }); 184 | 185 | attrs.prop1 = 'foobaz'; 186 | attrs.prop2 = '84'; 187 | attrs.prop3 = 'false'; 188 | attrs.prop4 = '.?'; 189 | 190 | // Act 191 | let options = service.load('foo', element, attrs, events, { 192 | prop1: [String], 193 | prop2: [Number], 194 | prop3: [Boolean], 195 | prop4: [RegExp] 196 | }); 197 | 198 | // Assert 199 | expect(options).toEqual({ 200 | prop1: 'foobaz', 201 | prop2: 84, 202 | prop3: false, 203 | prop4: /.?/ 204 | }); 205 | }); 206 | 207 | it('falls back to default values when invalid values are provided', function() { 208 | // Arrange 209 | provider.setDefaults('foo', { 210 | prop1: 'foobar' 211 | }); 212 | 213 | attrs.prop1 = 'foo-bar'; 214 | attrs.prop2 = 'foo-bar'; 215 | attrs.prop3 = 'foo-bar'; 216 | attrs.prop4 = 'foo-bar'; 217 | 218 | // Act 219 | let options = service.load('foo', element, attrs, events, { 220 | prop1: [String, 'barfoo', function(value) { return !value; }], 221 | prop2: [String, 'foobar', function(value) { return !value; }], 222 | prop3: [String, 'foobaz', function(value) { return value; }], 223 | prop4: [String, 'bazfoo'] 224 | }); 225 | 226 | // Assert 227 | expect(options).toEqual({ 228 | prop1: 'foobar', 229 | prop2: 'foobar', 230 | prop3: 'foo-bar', 231 | prop4: 'foo-bar' 232 | }); 233 | }); 234 | 235 | it('returns the same object so calls can be chained', function() { 236 | expect(provider.setDefaults('foo', {})).toBe(provider); 237 | expect(provider.setActiveInterpolation('foo', {})).toBe(provider); 238 | expect(provider.setTextAutosizeThreshold(10)).toBe(provider); 239 | }); 240 | 241 | it('sets the threshold used to calculate the size of the input element', function() { 242 | // Act 243 | provider.setTextAutosizeThreshold(10); 244 | 245 | // Assert 246 | expect(service.getTextAutosizeThreshold()).toBe(10); 247 | }); 248 | 249 | it('defaults the threshold used to calculate the size of the input element to 3', function() { 250 | expect(service.getTextAutosizeThreshold()).toBe(3); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ngTagsInput 2 | 3 | So you want to contribute to ngTagsInput... that's awesome! I really appreciate the help in making this project better. Here you will find everything you need to know in order to solve problems, report bugs, request features and implement your contribution and send it as a pull request. 4 | 5 | ## Asking a question 6 | 7 | If you have any questions on how to use ngTagsInput or how to solve a problem involving the directive, please create a question on [Stackoverflow](http://www.stackoverflow.com) using the `ng-tags-input` tag. **DO NOT** post a question as an issue on GitHub. The issue tracker should be used for reporting bugs only. 8 | 9 | ## Reporting a bug 10 | 11 | If you find a bug in the code or an error in the documentation and want to report it, check whether it's already been reported by searching the issue tracker. In case it hasn't yet, create a new issue and describe the problem you've found. Please be sure to include the following information along with your message: 12 | 13 | - Version of ngTagsInput you are using. 14 | - Version of Angular you are using. 15 | - Browser (name and version) on which the bug occurs. 16 | - Plunker showing the bug. You can use [this template](plnkr.co/edit/tpl:93P2qxOjYmlcYSqDmo39). 17 | 18 | ## Setting up your environment 19 | 20 | Here's what you need to do before start working on the code: 21 | 22 | 1. Install Node.js (0.10.22 or higher) 23 | 2. Install `grunt-cli` and `karma-cli` globally 24 | 25 | npm install -g grunt-cli karma-cli 26 | 27 | 3. Install Ruby and the `sass` gem if you want to compile the SCSS files 28 | 4. Clone your repository 29 | 30 | git clone https://github.com//ngTagsInput 31 | 32 | 5. Go to the ngTagsInput directory 33 | 34 | cd ngTagsInput 35 | 36 | 6. Add the main ngTagsInput repo as an upstream remote 37 | 38 | git remote add upstream https://github.com/mbenford/ngTagsInput 39 | 40 | 7. Install the development dependencies 41 | 42 | npm install 43 | 44 | ## Building from the source code 45 | 46 | You can build ngTagsInput with a single command: 47 | 48 | grunt pack 49 | 50 | That performs all tasks needed to produce the final JavaScript and CSS files. After the build completes, a folder named `build` will be generated containing the following files: 51 | 52 | ng-tags-input.js 53 | ng-tags-input.css 54 | ng-tags-input.min.js 55 | ng-tags-input.min.css 56 | 57 | In addition to `pack` there are other useful tasks you might want to use: 58 | 59 | - `pack:js`: Generates only the Javascript files. 60 | - `test`: Runs all tests using PhantomJS (you can use `karma start` as well, of course). 61 | - `watch`: Runs all tests automatically every time the source code files change. 62 | - `coverage`: Generates the code coverage report. This may help you be sure nothing is left untested. 63 | 64 | # Guidelines 65 | 66 | Even though ngTagsInput isn't a big project, there are a few guidelines I'd like you to follow so everything remains organized and consistent. I can't stress enough how important following theses guidelines is. Failing to do so will slow down the review process of your pull request and might prevent it from being accepted. 67 | 68 | ## TL;DR 69 | 70 | The following checklist should help you be sure you have covered all the bases. You should answer *yes* to all questions 71 | before sending your pull request: 72 | 73 | - Have you written tests for all changes you made? 74 | - Have you updated the docs? (in case you have changed the directive's public API) 75 | - Have you built the directive by running `grunt pack`? 76 | - Have you squashed multiple commits into one? 77 | - Does your commit message comply with the [commit message guidelines](#commit-message-guidelines)? 78 | - Have you rebased your branch on top of the master branch? 79 | 80 | ## Coding guidelines 81 | 82 | No endless list of conventions and standards here; just three simple guidelines: 83 | 84 | - All code **must** follow the rules defined in the [.jshintrc](/jshintrc) file (Grunt gets you covered here. Just run `grunt jshint`). 85 | - All features or bug fixes **must** be covered by one or more tests. 86 | - All public API changes (e.g. new options for directives) **must** be documented with ngdoc-like tags. 87 | 88 | ## Commit message guidelines* 89 | 90 | \* Heavily inspired on AngularJS commit guidelines 91 | 92 | Good, neat commit messages are very important to keep the project history organized. In addition to that, each release changelog is generated out of the commits messages, so they should be readable and concise. 93 | 94 | ### Message Format 95 | 96 | Each commit consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type**, a **scope** and a **subject**: 97 | 98 | (): 99 | 100 | 101 | 102 |