/**
* Tagify (v 1.2.4)- tags input component
* By Yair Even-Or (2016)
* Don't sell this code. (c)
* https://github.com/yairEO/tagify
*/
;(function($){
// just a jQuery wrapper for the vanilla version of this component
$.fn.tagify = function(settings){
var $input = this,
tagify;
if( $input.data("tagify") ) // don't continue if already "tagified"
return this;
tagify = new Tagify(this[0], settings);
tagify.isJQueryPlugin = true;
$input.data("tagify", tagify);
return this;
}
function Tagify( input, settings ){
// protection
if( !input ){
console.warn('Tagify: ', 'invalid input element ', input)
return this;
}
this.settings = this.extend({}, settings, this.DEFAULTS);
this.settings.readonly = input.hasAttribute('readonly'); // if "readonly" do not include an "input" element inside the Tags component
this.legacyFixes();
if( input.pattern )
try {
this.settings.pattern = new RegExp(input.pattern);
} catch(e){}
if( settings && settings.delimiters ){
try {
this.settings.delimiters = new RegExp("[" + settings.delimiters + "]");
} catch(e){}
}
this.id = Math.random().toString(36).substr(2,9), // almost-random ID (because, fuck it)
this.value = []; // An array holding all the (currently used) tags
this.DOM = {}; // Store all relevant DOM elements in an Object
this.extend(this, new this.EventDispatcher());
this.build(input);
this.events();
}
Tagify.prototype = {
DEFAULTS : {
delimiters : ",", // [regex] split tags by any of these delimiters
pattern : "", // pattern to validate input by
callbacks : {}, // exposed callbacks object to be triggered on certain events
duplicates : false, // flag - allow tuplicate tags
enforceWhitelist : false, // flag - should ONLY use tags allowed in whitelist
autocomplete : true, // flag - show native suggeestions list as you type
whitelist : [], // is this list has any items, then only allow tags from this list
blacklist : [], // a list of non-allowed tags
maxTags : Infinity, // maximum number of tags
suggestionsMinChars : 2, // minimum characters to input to see sugegstions list
maxSuggestions : 10
},
/**
* Fixes which require backword support
*/
legacyFixes : function(){
var _s = this.settings;
// For backwards compatibility with older versions, which use 'enforeWhitelist' instead of 'enforceWhitelist'.
if( _s.hasOwnProperty("enforeWhitelist") && !_s.hasOwnProperty("enforceWhitelist") ){
_s["enforceWhitelist"] = _s["enforeWhitelist"];
delete _s["enforeWhitelist"];
console.warn("Please update your Tagify settings. The 'enforeWhitelist' property is deprecated and you should be using 'enforceWhitelist'.");
}
},
/**
* builds the HTML of this component
* @param {Object} input [DOM element which would be "transformed" into "Tags"]
*/
build : function( input ){
var that = this,
value = input.value,
inputHTML = '
'+ input.placeholder +'
';
this.DOM.originalInput = input;
this.DOM.scope = document.createElement('tags');
input.className && (this.DOM.scope.className = input.className); // copy any class names from the original input element to the Tags element
this.DOM.scope.innerHTML = inputHTML;
this.DOM.input = this.DOM.scope.querySelector('input');
if( this.settings.readonly )
this.DOM.scope.classList.add('readonly')
input.parentNode.insertBefore(this.DOM.scope, input);
this.DOM.scope.appendChild(input);
// if "autocomplete" flag on toggeled & "whitelist" has items, build suggestions list
if( this.settings.autocomplete && this.settings.whitelist.length ){
if( "suggestions" in this )
this.suggestions.init();
else
this.DOM.datalist = this.buildDataList();
}
// if the original input already had any value (tags)
if( value )
this.addTags(value).forEach(function(tag){
tag && tag.classList.add('tagify--noAnim');
});
},
/**
* Reverts back any changes made by this component
*/
destroy : function(){
this.DOM.scope.parentNode.appendChild(this.DOM.originalInput);
this.DOM.scope.parentNode.removeChild(this.DOM.scope);
},
/**
* Merge two objects into a new one
*/
extend : function(o, o1, o2){
if( !(o instanceof Object) ) o = {};
if( o2 ){
copy(o, o2)
copy(o, o1)
}
else
copy(o, o1)
function copy(a,b){
// copy o2 to o
for( var key in b )
if( b.hasOwnProperty(key) )
a[key] = b[key];
}
return o;
},
/**
* A constructor for exposing events to the outside
*/
EventDispatcher : function(){
// Create a DOM EventTarget object
var target = document.createTextNode('');
// Pass EventTarget interface calls to DOM EventTarget object
this.off = target.removeEventListener.bind(target);
this.on = target.addEventListener.bind(target);
this.trigger = function(eventName, data){
var e;
if( !eventName ) return;
if( this.isJQueryPlugin )
$(this.DOM.originalInput).triggerHandler(eventName, [data])
else{
try {
e = new CustomEvent(eventName, {"detail":data});
}
catch(err){
e = document.createEvent("Event");
e.initEvent("toggle", false, false);
}
target.dispatchEvent(e);
}
}
},
/**
* DOM events listeners binding
*/
events : function(){
var that = this,
events = {
// event name / event callback / element to be listening to
paste : ['onPaste' , 'input'],
focus : ['onFocusBlur' , 'input'],
blur : ['onFocusBlur' , 'input'],
input : ['onInput' , 'input'],
keydown : ['onKeydown' , 'input'],
click : ['onClickScope' , 'scope']
},
customList = ['add', 'remove', 'duplicate', 'maxTagsExceed', 'blacklisted', 'notWhitelisted'];
for( var e in events )
this.DOM[events[e][1]].addEventListener(e, this.callbacks[events[e][0]].bind(this));
customList.forEach(function(name){
that.on(name, that.settings.callbacks[name])
})
if( this.isJQueryPlugin )
$(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this))
},
/**
* DOM events callbacks
*/
callbacks : {
onFocusBlur : function(e){
var text = e.target.value.trim();
if( e.type == "focus" )
e.target.className = 'input';
else if( e.type == "blur" && text ){
if( this.addTags(text).length )
e.target.value = '';
}
else{
e.target.className = 'input placeholder';
this.DOM.input.removeAttribute('style');
}
},
onKeydown : function(e){
var s = e.target.value,
lastTag,
that = this;
if( e.key == "Backspace" && (s == "" || s.charCodeAt(0) == 8203) ){
lastTag = this.DOM.scope.querySelectorAll('tag:not(.tagify--hide)');
lastTag = lastTag[lastTag.length - 1];
this.removeTag( lastTag );
}
if( e.key == "Escape" ){
e.target.value = '';
e.target.blur();
}
if( e.key == "Enter" ){
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
if( this.addTags(s).length )
e.target.value = '';
return false;
}
else{
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput);
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50);
}
},
onInput : function(e){
var value = e.target.value,
lastChar = value[value.length - 1],
isDatalistInput = !this.noneDatalistInput && value.length > 1,
showSuggestions = value.length >= this.settings.suggestionsMinChars,
datalistInDOM;
e.target.style.width = ((e.target.value.length + 1) * 7) + 'px';
// if( value.indexOf(',') != -1 || isDatalistInput ){
if( value.slice().search(this.settings.delimiters) != -1 || isDatalistInput ){
if( this.addTags(value).length )
e.target.value = ''; // clear the input field's value
}
else if( this.settings.autocomplete && this.settings.whitelist.length ){
datalistInDOM = this.DOM.input.parentNode.contains( this.DOM.datalist );
// if sugegstions should be hidden
if( !showSuggestions && datalistInDOM )
this.DOM.input.parentNode.removeChild(this.DOM.datalist)
else if( showSuggestions && !datalistInDOM ){
this.DOM.input.parentNode.appendChild(this.DOM.datalist)
}
}
},
onPaste : function(e){
var that = this;
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput);
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50);
},
onClickScope : function(e){
if( e.target.tagName == "TAGS" )
this.DOM.input.focus();
if( e.target.tagName == "X" ){
this.removeTag( e.target.parentNode );
}
}
},
/**
* Build tags suggestions using HTML datalist
* @return {[type]} [description]
*/
buildDataList : function(){
var OPTIONS = "",
i,
datalist = document.createElement('datalist');
datalist.id = 'tagifySuggestions' + this.id;
datalist.innerHTML = "";
for( i=this.settings.whitelist.length; i--; )
OPTIONS += "";
datalist.innerHTML = datalist.innerHTML.replace('[OPTIONS]', OPTIONS); // inject the options string in the right place
// this.DOM.input.insertAdjacentHTML('afterend', datalist); // append the datalist HTML string in the Tags
return datalist;
},
getNodeIndex : function( node ){
var index = 0;
while( (node = node.previousSibling) )
if (node.nodeType != 3 || !/^\s*$/.test(node.data))
index++;
return index;
},
/**
* Searches if any tag with a certain value already exis
* @param {String} s [text value to search for]
* @return {boolean} [found / not found]
*/
isTagDuplicate : function(s){
return this.value.some(function(item){ return s.toLowerCase() === item.value.toLowerCase() });
},
/**
* Mark a tag element by its value
* @param {String / Number} value [text value to search for]
* @param {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings]
* @return {boolean} [found / not found]
*/
markTagByValue : function(value, tagElm){
var tagsElms, tagsElmsLen;
if( !tagElm ){
tagsElms = this.DOM.scope.querySelectorAll('tag');
for( tagsElmsLen = tagsElms.length; tagsElmsLen--; ){
if( tagsElms[tagsElmsLen].textContent.toLowerCase().includes(value.toLowerCase()) )
tagElm = tagsElms[tagsElmsLen];
}
}
// check AGAIN if "tagElm" is defined
if( tagElm ){
tagElm.classList.add('tagify--mark');
setTimeout(function(){ tagElm.classList.remove('tagify--mark') }, 2000);
return true;
}
else{
}
return false;
},
/**
* make sure the tag, or words in it, is not in the blacklist
*/
isTagBlacklisted : function(v){
v = v.split(' ');
return this.settings.blacklist.filter(function(x){ return v.indexOf(x) != -1 }).length;
},
/**
* make sure the tag, or words in it, is not in the blacklist
*/
isTagWhitelisted : function(v){
return this.settings.whitelist.indexOf(v) != -1;
},
/**
* add a "tag" element to the "tags" component
* @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects]
* @return {Array} Array of DOM elements (tags)
*/
addTags : function( tagsItems ){
var that = this,
tagElems = [];
this.DOM.input.removeAttribute('style');
/**
* pre-proccess the tagsItems, which can be a complex tagsItems like an Array of Objects or a string comprised of multiple words
* so each item should be iterated on and a tag created for.
* @return {Array} [Array of Objects]
*/
function normalizeTags(tagsItems){
var whitelistWithProps = this.settings.whitelist[0] instanceof Object,
isComplex = tagsItems instanceof Array && "value" in tagsItems[0], // checks if the value is a "complex" which means an Array of Objects, each object is a tag
result = tagsItems; // the returned result
// no need to continue if "tagsItems" is an Array of Objects
if( isComplex )
return result;
// search if the tag exists in the whitelist as an Object (has props), to be able to use its properties
if( !isComplex && typeof tagsItems == "string" && whitelistWithProps ){
var matchObj = this.settings.whitelist.filter(function(item){
return item.value.toLowerCase() == tagsItems.toLowerCase();
})
if( matchObj[0] ){
isComplex = true;
result = matchObj; // set the Array (with the found Object) as the new value
}
}
// if the value is a "simple" String, ex: "aaa, bbb, ccc"
if( !isComplex ){
tagsItems = tagsItems.trim();
if( !tagsItems ) return [];
// go over each tag and add it (if there were multiple ones)
result = tagsItems.split(this.settings.delimiters).map(function(v){
return { value:v.trim() }
});
}
return result.filter(function(n){ return n }); // cleanup the array from "undefined", "false" or empty items;
}
/**
* validate a tag object BEFORE the actual tag will be created & appeneded
* @param {Object} tagData [{"value":"text", "class":whatever", ...}]
* @return {Boolean/String} ["true" if validation has passed, String or "false" for any type of error]
*/
function validateTag( tagData ){
var value = tagData.value.trim(),
maxTagsExceed = this.value.length >= this.settings.maxTags,
isDuplicate,
eventName__error,
tagAllowed;
// check for empty value
if( !value )
return "empty";
// check if pattern should be used and if so, use it to test the value
if( this.settings.pattern && !(this.settings.pattern.test(value)) )
return "pattern";
// check if the tag already exists
if( this.isTagDuplicate(value) ){
this.trigger('duplicate', value);
if( !this.settings.duplicates ){
// this.markTagByValue(value, tagElm)
return "duplicate";
}
}
// check if the tag is allowed by the rules set
tagAllowed = !this.isTagBlacklisted(value) && (!this.settings.enforceWhitelist || this.isTagWhitelisted(value)) && !maxTagsExceed;
// Check against blacklist & whitelist (if enforced)
if( !tagAllowed ){
tagData.class = tagData.class ? tagData.class + " tagify--notAllowed" : "tagify--notAllowed";
// broadcast why the tag was not allowed
if( maxTagsExceed ) eventName__error = 'maxTagsExceed';
else if( this.isTagBlacklisted(value) ) eventName__error = 'blacklisted';
else if( this.settings.enforceWhitelist && !this.isTagWhitelisted(value) ) eventName__error = 'notWhitelisted';
this.trigger(eventName__error, {value:value, index:this.value.length});
return "notAllowed";
}
return true;
}
/**
* appened (validated) tag to the component's DOM scope
* @return {[type]} [description]
*/
function appendTag(tagElm){
this.DOM.scope.insertBefore(tagElm, this.DOM.input.parentNode);
}
//////////////////////
tagsItems = normalizeTags.call(this, tagsItems);
tagsItems.forEach(function(tagData){
var isTagValidated = validateTag.call(that, tagData);
if( isTagValidated === true || isTagValidated == "notAllowed" ){
// create the tag element
var tagElm = that.createTagElem(tagData);
// add the tag to the component's DOM
appendTag.call(that, tagElm);
// remove the tag "slowly"
if( isTagValidated == "notAllowed" ){
setTimeout(function(){ that.removeTag(tagElm, true) }, 1000);
}
else{
// update state
that.value.push(tagData);
that.update();
that.trigger('add', that.extend({}, tagData, {index:that.value.length, tag:tagElm}));
tagElems.push(tagElm);
}
}
})
return tagElems
},
/**
* creates a DOM tag element and injects it into the component (this.DOM.scope)
* @param Object} tagData [text value & properties for the created tag]
* @return {Object} [DOM element]
*/
createTagElem : function(tagData){
var tagElm = document.createElement('tag');
// for a certain Tag element, add attributes.
function addTagAttrs(tagElm, tagData){
var i, keys = Object.keys(tagData);
for( i=keys.length; i--; ){
var propName = keys[i];
if( !tagData.hasOwnProperty(propName) ) return;
tagElm.setAttribute(propName, tagData[propName] );
}
}
// The space below is important - http://stackoverflow.com/a/19668740/104380
tagElm.innerHTML = "
"+ tagData.value +"
";
// add any attribuets, if exists
addTagAttrs(tagElm, tagData);
return tagElm;
},
/**
* Removes a tag
* @param {Object} tagElm [DOM element]
* @param {Boolean} silent [A flag, which when turned on, does not removes any value and does not update the original input value but simply removes the tag from tagify]
*/
removeTag : function( tagElm, silent ){
var tagData,
tagIdx = this.getNodeIndex(tagElm);
if( !tagElm) return;
tagElm.style.width = parseFloat(window.getComputedStyle(tagElm).width) + 'px';
document.body.clientTop; // force repaint for the width to take affect before the "hide" class below
tagElm.classList.add('tagify--hide');
// manual timeout (hack, since transitionend cannot be used because of hover)
setTimeout(function(){
tagElm.parentNode.removeChild(tagElm);
}, 400);
if( !silent ){
tagData = this.value.splice(tagIdx, 1)[0]; // remove the tag from the data object
this.update(); // update the original input with the current value
this.trigger('remove', this.extend({}, tagData, {index:tagIdx, tag:tagElm}));
}
},
removeAllTags : function(){
this.value = [];
this.update();
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function(elm){
elm.parentNode.removeChild(elm);
});
},
/**
* update the origianl (hidden) input field's value
*/
update : function(){
var tagsAsString = this.value.map(function(v){ return v.value }).join(',');
this.DOM.originalInput.value = tagsAsString;
}
}
})(jQuery);