/*! * ngTagsInput v2.2.0 * http://mbenford.github.io/ngTagsInput * * Copyright (c) 2013-2015 Michael Benford * License: MIT * * Generated at 2015-03-02 01:54:50 -0300 */ (function() { 'use strict'; var KEYS = { backspace: 8, tab: 9, enter: 13, escape: 27, space: 32, up: 38, down: 40, comma: 188 }; var MAX_SAFE_INTEGER = 9007199254740991; var SUPPORTED_INPUT_TYPES = ['text', 'email', 'url']; var tagsInput = angular.module('ngTagsInput', []); /** * @ngdoc directive * @name tagsInput * @module ngTagsInput * * @description * Renders an input box with tag editing support. * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} [displayProperty=text] Property to be rendered as the tag label. * @param {string=} [type=text] Type of the input element. Only 'text', 'email' and 'url' are supported values. * @param {number=} tabindex Tab order of the control. * @param {string=} [placeholder=Add a tag] Placeholder text for the control. * @param {number=} [minLength=3] Minimum length for a new tag. * @param {number=} [maxLength=MAX_SAFE_INTEGER] Maximum length allowed for a new tag. * @param {number=} [minTags=0] Sets minTags validation error key if the number of tags added is less than minTags. * @param {number=} [maxTags=MAX_SAFE_INTEGER] Sets maxTags validation error key if the number of tags added is greater than maxTags. * @param {boolean=} [allowLeftoverText=false] Sets leftoverText validation error key if there is any leftover text in * the input element when the directive loses focus. * @param {string=} [removeTagSymbol=×] Symbol character for the remove tag button. * @param {boolean=} [addOnEnter=true] Flag indicating that a new tag will be added on pressing the ENTER key. * @param {boolean=} [addOnSpace=false] Flag indicating that a new tag will be added on pressing the SPACE key. * @param {boolean=} [addOnComma=true] Flag indicating that a new tag will be added on pressing the COMMA key. * @param {boolean=} [addOnBlur=true] Flag indicating that a new tag will be added when the input field loses focus. * @param {boolean=} [addOnPaste=false] Flag indicating that the text pasted into the input field will be split into tags. * @param {string=} [pasteSplitPattern=,] Regular expression used to split the pasted text into tags. * @param {boolean=} [replaceSpacesWithDashes=true] Flag indicating that spaces will be replaced with dashes. * @param {string=} [allowedTagsPattern=.+] Regular expression that determines whether a new tag is valid. * @param {boolean=} [enableEditingLastTag=false] Flag indicating that the last tag will be moved back into * the new tag input box instead of being removed when the backspace key * is pressed and the input box is empty. * @param {boolean=} [addFromAutocompleteOnly=false] Flag indicating that only tags coming from the autocomplete list will be allowed. * When this flag is true, addOnEnter, addOnComma, addOnSpace, addOnBlur and * allowLeftoverText values are ignored. * @param {boolean=} [spellcheck=true] Flag indicating whether the browser's spellcheck is enabled for the input field or not. * @param {expression} onTagAdded Expression to evaluate upon adding a new tag. The new tag is available as $tag. * @param {expression} onInvalidTag Expression to evaluate when a tag is invalid. The invalid tag is available as $tag. * @param {expression} onTagRemoved Expression to evaluate upon removing an existing tag. The removed tag is available as $tag. */ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig","tiUtil", function($timeout, $document, tagsInputConfig, tiUtil) { function TagList(options, events) { var self = {}, getTagText, setTagText, tagIsValid; getTagText = function(tag) { return tiUtil.safeToString(tag[options.displayProperty]); }; setTagText = function(tag, text) { tag[options.displayProperty] = text; }; tagIsValid = function(tag) { var tagText = getTagText(tag); return tagText && tagText.length >= options.minLength && tagText.length <= options.maxLength && options.allowedTagsPattern.test(tagText) && !tiUtil.findInObjectArray(self.items, tag, options.displayProperty); }; self.items = []; self.addText = function(text) { var tag = {}; setTagText(tag, text); return self.add(tag); }; self.add = function(tag) { var tagText = getTagText(tag); if (options.replaceSpacesWithDashes) { tagText = tagText.replace(/\s/g, '-'); } setTagText(tag, tagText); if (tagIsValid(tag)) { self.items.push(tag); events.trigger('tag-added', { $tag: tag }); } else if (tagText) { events.trigger('invalid-tag', { $tag: tag }); } return tag; }; self.remove = function(index) { var tag = self.items.splice(index, 1)[0]; events.trigger('tag-removed', { $tag: tag }); return tag; }; self.removeLast = function() { var tag, lastTagIndex = self.items.length - 1; if (options.enableEditingLastTag || self.selected) { self.selected = null; tag = self.remove(lastTagIndex); } else if (!self.selected) { self.selected = self.items[lastTagIndex]; } return tag; }; return self; } function validateType(type) { return SUPPORTED_INPUT_TYPES.indexOf(type) !== -1; } return { restrict: 'E', require: 'ngModel', scope: { tags: '=ngModel', onTagAdded: '&', onInvalidTag: '&', onTagRemoved: '&' }, replace: false, transclude: true, templateUrl: 'ngTagsInput/tags-input.html', controller: ["$scope","$attrs","$element", function($scope, $attrs, $element) { $scope.events = tiUtil.simplePubSub(); tagsInputConfig.load('tagsInput', $scope, $attrs, { type: [String, 'text', validateType], placeholder: [String, 'Add a tag'], tabindex: [Number, null], removeTagSymbol: [String, String.fromCharCode(215)], replaceSpacesWithDashes: [Boolean, true], minLength: [Number, 3], maxLength: [Number, MAX_SAFE_INTEGER], addOnEnter: [Boolean, true], addOnSpace: [Boolean, false], addOnComma: [Boolean, true], addOnBlur: [Boolean, true], addOnPaste: [Boolean, false], pasteSplitPattern: [RegExp, /,/], allowedTagsPattern: [RegExp, /.+/], enableEditingLastTag: [Boolean, false], minTags: [Number, 0], maxTags: [Number, MAX_SAFE_INTEGER], displayProperty: [String, 'text'], allowLeftoverText: [Boolean, false], addFromAutocompleteOnly: [Boolean, false], spellcheck: [Boolean, true] }); $scope.tagList = new TagList($scope.options, $scope.events); this.registerAutocomplete = function() { var input = $element.find('input'); return { addTag: function(tag) { return $scope.tagList.add(tag); }, focusInput: function() { input[0].focus(); }, getTags: function() { return $scope.tags; }, getCurrentTagText: function() { return $scope.newTag.text; }, getOptions: function() { return $scope.options; }, on: function(name, handler) { $scope.events.on(name, handler); return this; } }; }; }], link: function(scope, element, attrs, ngModelCtrl) { var hotkeys = [KEYS.enter, KEYS.comma, KEYS.space, KEYS.backspace], tagList = scope.tagList, events = scope.events, options = scope.options, input = element.find('input'), validationOptions = ['minTags', 'maxTags', 'allowLeftoverText'], setElementValidity; setElementValidity = function() { ngModelCtrl.$setValidity('maxTags', scope.tags.length <= options.maxTags); ngModelCtrl.$setValidity('minTags', scope.tags.length >= options.minTags); ngModelCtrl.$setValidity('leftoverText', options.allowLeftoverText ? true : !scope.newTag.text); }; scope.newTag = { text: '', invalid: null, setText: function(value) { this.text = value; events.trigger('input-change', value); } }; scope.getDisplayText = function(tag) { return tiUtil.safeToString(tag[options.displayProperty]); }; scope.track = function(tag) { return tag[options.displayProperty]; }; scope.$watch('tags', function(value) { scope.tags = tiUtil.makeObjectArray(value, options.displayProperty); tagList.items = scope.tags; }); scope.$watch('tags.length', function() { setElementValidity(); }); scope.eventHandlers = { input: { change: function(text) { events.trigger('input-change', text); }, keydown: function($event) { events.trigger('input-keydown', $event); }, focus: function() { if (scope.hasFocus) { return; } scope.hasFocus = true; events.trigger('input-focus'); }, blur: function() { $timeout(function() { var activeElement = $document.prop('activeElement'), lostFocusToBrowserWindow = activeElement === input[0], lostFocusToChildElement = element[0].contains(activeElement); if (lostFocusToBrowserWindow || !lostFocusToChildElement) { scope.hasFocus = false; events.trigger('input-blur'); } }); }, paste: function($event) { events.trigger('input-paste', $event); } }, host: { click: function() { input[0].focus(); } } }; events .on('tag-added', scope.onTagAdded) .on('invalid-tag', scope.onInvalidTag) .on('tag-removed', scope.onTagRemoved) .on('tag-added', function() { scope.newTag.setText(''); }) .on('tag-added tag-removed', function() { // Sets the element to its dirty state // In Angular 1.3 this will be replaced with $setDirty. ngModelCtrl.$setViewValue(scope.tags); }) .on('invalid-tag', function() { scope.newTag.invalid = true; }) .on('option-change', function(e) { if (validationOptions.indexOf(e.name) !== -1) { setElementValidity(); } }) .on('input-change', function() { tagList.selected = null; scope.newTag.invalid = null; }) .on('input-focus', function() { element.triggerHandler('focus'); ngModelCtrl.$setValidity('leftoverText', true); }) .on('input-blur', function() { if (options.addOnBlur && !options.addFromAutocompleteOnly) { tagList.addText(scope.newTag.text); } element.triggerHandler('blur'); setElementValidity(); }) .on('input-keydown', function(event) { var key = event.keyCode, isModifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey, addKeys = {}, shouldAdd, shouldRemove; if (isModifier || hotkeys.indexOf(key) === -1) { return; } addKeys[KEYS.enter] = options.addOnEnter; addKeys[KEYS.comma] = options.addOnComma; addKeys[KEYS.space] = options.addOnSpace; shouldAdd = !options.addFromAutocompleteOnly && addKeys[key]; shouldRemove = !shouldAdd && key === KEYS.backspace && scope.newTag.text.length === 0; if (shouldAdd) { tagList.addText(scope.newTag.text); event.preventDefault(); } else if (shouldRemove) { var tag = tagList.removeLast(); if (tag && options.enableEditingLastTag) { scope.newTag.setText(tag[options.displayProperty]); } event.preventDefault(); } }) .on('input-paste', function(event) { if (options.addOnPaste) { var data = event.clipboardData.getData('text/plain'); var tags = data.split(options.pasteSplitPattern); if (tags.length > 1) { tags.forEach(function(tag) { tagList.addText(tag); }); event.preventDefault(); } } }); } }; }]); /** * @ngdoc directive * @name autoComplete * @module ngTagsInput * * @description * Provides autocomplete support for the tagsInput directive. * * @param {expression} source Expression to evaluate upon changing the input content. The input value is available as * $query. The result of the expression must be a promise that eventually resolves to an * array of strings. * @param {number=} [debounceDelay=100] Amount of time, in milliseconds, to wait before evaluating the expression in * the source option after the last keystroke. * @param {number=} [minLength=3] Minimum number of characters that must be entered before evaluating the expression * in the source option. * @param {boolean=} [highlightMatchedText=true] Flag indicating that the matched text will be highlighted in the * suggestions list. * @param {number=} [maxResultsToShow=10] Maximum number of results to be displayed at a time. * @param {boolean=} [loadOnDownArrow=false] Flag indicating that the source option will be evaluated when the down arrow * key is pressed and the suggestion list is closed. The current input value * is available as $query. * @param {boolean=} {loadOnEmpty=false} Flag indicating that the source option will be evaluated when the input content * becomes empty. The $query variable will be passed to the expression as an empty string. * @param {boolean=} {loadOnFocus=false} Flag indicating that the source option will be evaluated when the input element * gains focus. The current input value is available as $query. * @param {boolean=} [selectFirstMatch=true] Flag indicating that the first match will be automatically selected once * the suggestion list is shown. */ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","$q","tagsInputConfig","tiUtil", function($document, $timeout, $sce, $q, tagsInputConfig, tiUtil) { function SuggestionList(loadFn, options) { var self = {}, getDifference, lastPromise; getDifference = function(array1, array2) { return array1.filter(function(item) { return !tiUtil.findInObjectArray(array2, item, options.tagsInput.displayProperty); }); }; self.reset = function() { lastPromise = null; self.items = []; self.visible = false; self.index = -1; self.selected = null; self.query = null; }; self.show = function() { if (options.selectFirstMatch) { self.select(0); } else { self.selected = null; } self.visible = true; }; self.load = tiUtil.debounce(function(query, tags) { self.query = query; var promise = $q.when(loadFn({ $query: query })); lastPromise = promise; promise.then(function(items) { if (promise !== lastPromise) { return; } items = tiUtil.makeObjectArray(items.data || items, options.tagsInput.displayProperty); items = getDifference(items, tags); self.items = items.slice(0, options.maxResultsToShow); if (self.items.length > 0) { self.show(); } else { self.reset(); } }); }, options.debounceDelay); self.selectNext = function() { self.select(++self.index); }; self.selectPrior = function() { self.select(--self.index); }; self.select = function(index) { if (index < 0) { index = self.items.length - 1; } else if (index >= self.items.length) { index = 0; } self.index = index; self.selected = self.items[index]; }; self.reset(); return self; } return { restrict: 'E', require: '^tagsInput', scope: { source: '&' }, templateUrl: 'ngTagsInput/auto-complete.html', link: function(scope, element, attrs, tagsInputCtrl) { var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down], suggestionList, tagsInput, options, getItem, getDisplayText, shouldLoadSuggestions; tagsInputConfig.load('autoComplete', scope, attrs, { debounceDelay: [Number, 100], minLength: [Number, 3], highlightMatchedText: [Boolean, true], maxResultsToShow: [Number, 10], loadOnDownArrow: [Boolean, false], loadOnEmpty: [Boolean, false], loadOnFocus: [Boolean, false], selectFirstMatch: [Boolean, true] }); options = scope.options; tagsInput = tagsInputCtrl.registerAutocomplete(); options.tagsInput = tagsInput.getOptions(); suggestionList = new SuggestionList(scope.source, options); getItem = function(item) { return item[options.tagsInput.displayProperty]; }; getDisplayText = function(item) { return tiUtil.safeToString(getItem(item)); }; shouldLoadSuggestions = function(value) { return value && value.length >= options.minLength || !value && options.loadOnEmpty; }; scope.suggestionList = suggestionList; scope.addSuggestionByIndex = function(index) { suggestionList.select(index); scope.addSuggestion(); }; scope.addSuggestion = function() { var added = false; if (suggestionList.selected) { tagsInput.addTag(suggestionList.selected); suggestionList.reset(); tagsInput.focusInput(); added = true; } return added; }; scope.highlight = function(item) { var text = getDisplayText(item); text = tiUtil.encodeHTML(text); if (options.highlightMatchedText) { text = tiUtil.safeHighlight(text, tiUtil.encodeHTML(suggestionList.query)); } return $sce.trustAsHtml(text); }; scope.track = function(item) { return getItem(item); }; tagsInput .on('tag-added invalid-tag input-blur', function() { suggestionList.reset(); }) .on('input-change', function(value) { if (shouldLoadSuggestions(value)) { suggestionList.load(value, tagsInput.getTags()); } else { suggestionList.reset(); } }) .on('input-focus', function() { var value = tagsInput.getCurrentTagText(); if (options.loadOnFocus && shouldLoadSuggestions(value)) { suggestionList.load(value, tagsInput.getTags()); } }) .on('input-keydown', function(event) { var key = event.keyCode, handled = false; if (hotkeys.indexOf(key) === -1) { return; } if (suggestionList.visible) { if (key === KEYS.down) { suggestionList.selectNext(); handled = true; } else if (key === KEYS.up) { suggestionList.selectPrior(); handled = true; } else if (key === KEYS.escape) { suggestionList.reset(); handled = true; } else if (key === KEYS.enter || key === KEYS.tab) { handled = scope.addSuggestion(); } } else { if (key === KEYS.down && scope.options.loadOnDownArrow) { suggestionList.load(tagsInput.getCurrentTagText(), tagsInput.getTags()); handled = true; } } if (handled) { event.preventDefault(); event.stopImmediatePropagation(); return false; } }); } }; }]); /** * @ngdoc directive * @name tiTranscludeAppend * @module ngTagsInput * * @description * Re-creates the old behavior of ng-transclude. Used internally by tagsInput directive. */ tagsInput.directive('tiTranscludeAppend', function() { return function(scope, element, attrs, ctrl, transcludeFn) { transcludeFn(function(clone) { element.append(clone); }); }; }); /** * @ngdoc directive * @name tiAutosize * @module ngTagsInput * * @description * Automatically sets the input's width so its content is always visible. Used internally by tagsInput directive. */ tagsInput.directive('tiAutosize', ["tagsInputConfig", function(tagsInputConfig) { return { restrict: 'A', require: 'ngModel', link: function(scope, element, attrs, ctrl) { var threshold = tagsInputConfig.getTextAutosizeThreshold(), span, resize; span = angular.element(''); span.css('display', 'none') .css('visibility', 'hidden') .css('width', 'auto') .css('white-space', 'pre'); element.parent().append(span); resize = function(originalValue) { var value = originalValue, width; if (angular.isString(value) && value.length === 0) { value = attrs.placeholder; } if (value) { span.text(value); span.css('display', ''); width = span.prop('offsetWidth'); span.css('display', 'none'); } element.css('width', width ? width + threshold + 'px' : ''); return originalValue; }; ctrl.$parsers.unshift(resize); ctrl.$formatters.unshift(resize); attrs.$observe('placeholder', function(value) { if (!ctrl.$modelValue) { resize(value); } }); } }; }]); /** * @ngdoc directive * @name tiBindAttrs * @module ngTagsInput * * @description * Binds attributes to expressions. Used internally by tagsInput directive. */ tagsInput.directive('tiBindAttrs', function() { return function(scope, element, attrs) { scope.$watch(attrs.tiBindAttrs, function(value) { angular.forEach(value, function(value, key) { attrs.$set(key, value); }); }, true); }; }); /** * @ngdoc service * @name tagsInputConfig * @module ngTagsInput * * @description * Sets global configuration settings for both tagsInput and autoComplete directives. It's also used internally to parse and * initialize options from HTML attributes. */ tagsInput.provider('tagsInputConfig', function() { var globalDefaults = {}, interpolationStatus = {}, autosizeThreshold = 3; /** * @ngdoc method * @name setDefaults * @description Sets the default configuration option for a directive. * @methodOf tagsInputConfig * * @param {string} directive Name of the directive to be configured. Must be either 'tagsInput' or 'autoComplete'. * @param {object} defaults Object containing options and their values. * * @returns {object} The service itself for chaining purposes. */ this.setDefaults = function(directive, defaults) { globalDefaults[directive] = defaults; return this; }; /*** * @ngdoc method * @name setActiveInterpolation * @description Sets active interpolation for a set of options. * @methodOf tagsInputConfig * * @param {string} directive Name of the directive to be configured. Must be either 'tagsInput' or 'autoComplete'. * @param {object} options Object containing which options should have interpolation turned on at all times. * * @returns {object} The service itself for chaining purposes. */ this.setActiveInterpolation = function(directive, options) { interpolationStatus[directive] = options; return this; }; /*** * @ngdoc method * @name setTextAutosizeThreshold * @methodOf tagsInputConfig * * @param {number} threshold Threshold to be used by the tagsInput directive to re-size the input element based on its contents. * * @returns {object} The service itself for chaining purposes. */ this.setTextAutosizeThreshold = function(threshold) { autosizeThreshold = threshold; return this; }; this.$get = ["$interpolate", function($interpolate) { var converters = {}; converters[String] = function(value) { return value; }; converters[Number] = function(value) { return parseInt(value, 10); }; converters[Boolean] = function(value) { return value.toLowerCase() === 'true'; }; converters[RegExp] = function(value) { return new RegExp(value); }; return { load: function(directive, scope, attrs, options) { var defaultValidator = function() { return true; }; scope.options = {}; angular.forEach(options, function(value, key) { var type, localDefault, validator, converter, getDefault, updateValue; type = value[0]; localDefault = value[1]; validator = value[2] || defaultValidator; converter = converters[type]; getDefault = function() { var globalValue = globalDefaults[directive] && globalDefaults[directive][key]; return angular.isDefined(globalValue) ? globalValue : localDefault; }; updateValue = function(value) { scope.options[key] = value && validator(value) ? converter(value) : getDefault(); }; if (interpolationStatus[directive] && interpolationStatus[directive][key]) { attrs.$observe(key, function(value) { updateValue(value); scope.events.trigger('option-change', { name: key, newValue: value }); }); } else { updateValue(attrs[key] && $interpolate(attrs[key])(scope.$parent)); } }); }, getTextAutosizeThreshold: function() { return autosizeThreshold; } }; }]; }); /*** * @ngdoc factory * @name tiUtil * @module ngTagsInput * * @description * Helper methods used internally by the directive. Should not be used directly from user code. */ tagsInput.factory('tiUtil', ["$timeout", function($timeout) { var self = {}; self.debounce = function(fn, delay) { var timeoutId; return function() { var args = arguments; $timeout.cancel(timeoutId); timeoutId = $timeout(function() { fn.apply(null, args); }, delay); }; }; self.makeObjectArray = function(array, key) { array = array || []; if (array.length > 0 && !angular.isObject(array[0])) { array.forEach(function(item, index) { array[index] = {}; array[index][key] = item; }); } return array; }; self.findInObjectArray = function(array, obj, key) { var item = null; for (var i = 0; i < array.length; i++) { // I'm aware of the internationalization issues regarding toLowerCase() // but I couldn't come up with a better solution right now if (self.safeToString(array[i][key]).toLowerCase() === self.safeToString(obj[key]).toLowerCase()) { item = array[i]; break; } } return item; }; self.safeHighlight = function(str, value) { if (!value) { return str; } function escapeRegexChars(str) { return str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); } var expression = new RegExp('&[^;]+;|' + escapeRegexChars(value), 'gi'); return str.replace(expression, function(match) { return match === value ? '' + value + '' : match; }); }; self.safeToString = function(value) { return angular.isUndefined(value) || value == null ? '' : value.toString().trim(); }; self.encodeHTML = function(value) { return value.replace(/&/g, '&') .replace(//g, '>'); }; self.simplePubSub = function() { var events = {}; return { on: function(names, handler) { names.split(' ').forEach(function(name) { if (!events[name]) { events[name] = []; } events[name].push(handler); }); return this; }, trigger: function(name, args) { var handlers = events[name] || []; handlers.every(function(handler) { var retVal = handler.call(null, args); return angular.isUndefined(retVal) || retVal; }); return this; } }; }; return self; }]); /* HTML templates */ tagsInput.run(["$templateCache", function($templateCache) { $templateCache.put('ngTagsInput/tags-input.html', "