@yaireo/tagify
Advanced tools
Comparing version 1.2.2 to 2.0.0
@@ -0,3 +1,5 @@ | ||
"use strict"; | ||
/** | ||
* Tagify (v 1.2.1)- tags input component | ||
* Tagify (v 1.3.1)- tags input component | ||
* By Yair Even-Or (2016) | ||
@@ -7,437 +9,844 @@ * Don't sell this code. (c) | ||
*/ | ||
;(function($){ | ||
;(function ($) { | ||
// just a jQuery wrapper for the vanilla version of this component | ||
$.fn.tagify = function(settings){ | ||
var $input = this, | ||
tagify; | ||
$.fn.tagify = function () { | ||
var settings = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; | ||
if( $input.data("tagify") ) // don't continue if already "tagified" | ||
return this; | ||
return this.each(function () { | ||
var $input = $(this), | ||
tagify; | ||
tagify = new Tagify(this[0], settings); | ||
tagify.isJQueryPlugin = true; | ||
$input.data("tagify", tagify); | ||
if ($input.data("tagify")) // don't continue if already "tagified" | ||
return this; | ||
return this; | ||
} | ||
settings.isJQueryPlugin = true; | ||
tagify = new Tagify($input[0], settings); | ||
$input.data("tagify", tagify); | ||
}); | ||
}; | ||
function Tagify( input, settings ){ | ||
// protection | ||
if( !input ){ | ||
console.warn('Tagify: ', 'invalid input element ', input) | ||
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.settings = this.extend({}, this.DEFAULTS, settings); | ||
this.settings.readonly = input.hasAttribute('readonly'); // if "readonly" do not include an "input" element inside the Tags component | ||
if( input.pattern ) | ||
try { | ||
if (isIE) this.settings.autoComplete = false; // IE goes crazy if this isn't false | ||
if (input.pattern) try { | ||
this.settings.pattern = new RegExp(input.pattern); | ||
} catch(e){} | ||
} catch (e) {} | ||
if( settings && settings.delimiters ){ | ||
try { | ||
this.settings.delimiters = new RegExp("[" + settings.delimiters + "]"); | ||
} catch(e){} | ||
// Convert the "delimiters" setting into a REGEX object | ||
if (this.settings && this.settings.delimiters) { | ||
try { | ||
this.settings.delimiters = new RegExp("[" + this.settings.delimiters + "]", "g"); | ||
} 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 | ||
// events' callbacks references will be stores here, so events could be unbinded | ||
this.listeners = {}; | ||
this.DOM = {}; // Store all relevant DOM elements in an Object | ||
this.extend(this, new this.EventDispatcher(this)); | ||
this.build(input); | ||
this.events.customBinding.call(this); | ||
this.events.binding.call(this); | ||
} | ||
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 = { | ||
isIE: window.document.documentMode, | ||
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 | ||
enforeWhitelist : 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 | ||
}, | ||
DEFAULTS: { | ||
delimiters: ",", // [regex] split tags by any of these delimiters ("null" to cancel) Example: ",| |." | ||
pattern: null, // regex pattern to validate input by. Ex: /[1-9]/ | ||
maxTags: Infinity, // maximum number of tags | ||
callbacks: {}, // exposed callbacks object to be triggered on certain events | ||
addTagOnBlur: true, // flag - automatically adds the text which was inputed as a tag when blur event happens | ||
duplicates: false, // flag - allow tuplicate tags | ||
whitelist: [], // is this list has any items, then only allow tags from this list | ||
blacklist: [], // a list of non-allowed tags | ||
enforceWhitelist: false, // flag - should ONLY use tags allowed in whitelist | ||
autoComplete: true, // flag - tries to autocomplete the input's value while typing | ||
dropdown: { | ||
classname: '', | ||
enabled: 2, // minimum input characters needs to be typed for the dropdown to show | ||
maxItems: 10 | ||
} | ||
}, | ||
/** | ||
* 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 = '<div><input list="tagifySuggestions'+ this.id +'" class="placeholder"/><span>'+ input.placeholder +'</span></div>'; | ||
this.DOM.originalInput = input; | ||
this.DOM.scope = document.createElement('tags'); | ||
this.DOM.scope.innerHTML = inputHTML; | ||
this.DOM.input = this.DOM.scope.querySelector('input'); | ||
customEventsList: ['add', 'remove', 'duplicate', 'maxTagsExceed', 'blacklisted', 'notWhitelisted'], | ||
if( this.settings.readonly ) | ||
this.DOM.scope.classList.add('readonly') | ||
/** | ||
* utility method | ||
* https://stackoverflow.com/a/35385518/104380 | ||
* @param {String} s [HTML string] | ||
* @return {Object} [DOM node] | ||
*/ | ||
parseHTML: function parseHTML(s) { | ||
var parser = new DOMParser(), | ||
node = parser.parseFromString(s.trim(), "text/html"); | ||
input.parentNode.insertBefore(this.DOM.scope, input); | ||
this.DOM.scope.appendChild(input); | ||
return node.body.firstElementChild; | ||
}, | ||
// if "autocomplete" flag on toggeled & "whitelist" has items, build suggestions list | ||
if( this.settings.autocomplete && this.settings.whitelist.length ) | ||
this.DOM.datalist = this.buildDataList(); | ||
// if the original input already had any value (tags) | ||
if( value ) | ||
this.addTag(value).forEach(function(tag){ | ||
// https://stackoverflow.com/a/25396011/104380 | ||
escapeHtml: function escapeHtml(s) { | ||
var text = document.createTextNode(s); | ||
var p = document.createElement('p'); | ||
p.appendChild(text); | ||
return p.innerHTML; | ||
}, | ||
/** | ||
* builds the HTML of this component | ||
* @param {Object} input [DOM element which would be "transformed" into "Tags"] | ||
*/ | ||
build: function build(input) { | ||
var that = this, | ||
value = input.value, | ||
template = "\n <tags class=\"tagify " + input.className + " " + (this.settings.readonly ? 'readonly' : '') + "\">\n <div contenteditable data-placeholder=\"" + input.placeholder + "\" class=\"tagify--input\"></div>\n </tags>"; | ||
this.DOM.originalInput = input; | ||
this.DOM.scope = this.parseHTML(template); | ||
this.DOM.input = this.DOM.scope.querySelector('[contenteditable]'); | ||
input.parentNode.insertBefore(this.DOM.scope, input); | ||
// if "autocomplete" flag on toggeled & "whitelist" has items, build suggestions list | ||
if (this.settings.dropdown.enabled && this.settings.whitelist.length) { | ||
this.dropdown.init.call(this); | ||
} | ||
// 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 = {}; | ||
/** | ||
* Reverts back any changes made by this component | ||
*/ | ||
destroy: function destroy() { | ||
this.DOM.scope.parentNode.removeChild(this.DOM.scope); | ||
}, | ||
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]; | ||
} | ||
/** | ||
* Merge two objects into a new one | ||
* TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) | ||
*/ | ||
extend: function extend(o, o1, o2) { | ||
if (!(o instanceof Object)) o = {}; | ||
return o; | ||
}, | ||
copy(o, o1); | ||
if (o2) copy(o, o2); | ||
/** | ||
* A constructor for exposing events to the outside | ||
*/ | ||
EventDispatcher : function(){ | ||
// Create a DOM EventTarget object | ||
var target = document.createTextNode(''); | ||
function isObject(obj) { | ||
var type = Object.prototype.toString.call(obj); | ||
return obj === Object(obj) && type != '[object Array]' && type != '[object Function]'; | ||
}; | ||
// 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; | ||
function copy(a, b) { | ||
// copy o2 to o | ||
for (var key in b) { | ||
if (b.hasOwnProperty(key)) { | ||
if (isObject(b[key])) { | ||
if (!isObject(a[key])) a[key] = Object.assign({}, b[key]);else copy(a[key], b[key]); | ||
} else a[key] = b[key]; | ||
} | ||
} | ||
} | ||
if( this.isJQueryPlugin ) | ||
$(this.DOM.originalInput).triggerHandler(eventName, [data]) | ||
else{ | ||
try { | ||
e = new CustomEvent(eventName, {"detail":data}); | ||
return o; | ||
}, | ||
/** | ||
* A constructor for exposing events to the outside | ||
*/ | ||
EventDispatcher: function EventDispatcher(instance) { | ||
// Create a DOM EventTarget object | ||
var target = document.createTextNode(''); | ||
// Pass EventTarget interface calls to DOM EventTarget object | ||
this.off = function (name, cb) { | ||
if (cb) target.removeEventListener.call(target, name, cb); | ||
return this; | ||
}; | ||
this.on = function (name, cb) { | ||
if (cb) target.addEventListener.call(target, name, cb); | ||
return this; | ||
}; | ||
this.trigger = function (eventName, data) { | ||
var e; | ||
if (!eventName) return; | ||
if (instance.settings.isJQueryPlugin) { | ||
$(instance.DOM.originalInput).triggerHandler(eventName, [data]); | ||
} else { | ||
try { | ||
e = new CustomEvent(eventName, { "detail": data }); | ||
} catch (err) { | ||
console.warn(err); | ||
} | ||
target.dispatchEvent(e); | ||
} | ||
catch(err){ | ||
e = document.createEvent("Event"); | ||
e.initEvent("toggle", false, false); | ||
}; | ||
}, | ||
/** | ||
* DOM events listeners binding | ||
*/ | ||
events: { | ||
// bind custom events which were passed in the settings | ||
customBinding: function customBinding() { | ||
var _this2 = this; | ||
this.customEventsList.forEach(function (name) { | ||
_this2.on(name, _this2.settings.callbacks[name]); | ||
}); | ||
}, | ||
binding: function binding() { | ||
var bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; | ||
var _CB = this.events.callbacks, | ||
// setup callback references so events could be removed later | ||
_CBR = this.listeners.main = this.listeners.main || { | ||
paste: ['input', _CB.onPaste.bind(this)], | ||
focus: ['input', _CB.onFocusBlur.bind(this)], | ||
blur: ['input', _CB.onFocusBlur.bind(this)], | ||
keydown: ['input', _CB.onKeydown.bind(this)], | ||
click: ['scope', _CB.onClickScope.bind(this)] | ||
}, | ||
action = bindUnbind ? 'addEventListener' : 'removeEventListener'; | ||
for (var eventName in _CBR) { | ||
this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]); | ||
} | ||
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'] | ||
if (bindUnbind) { | ||
// this event should never be unbinded | ||
// IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead.. | ||
this.DOM.input.addEventListener(this.isIE ? "keydown" : "input", _CB[this.isIE ? "onInputIE" : "onInput"].bind(this)); | ||
if (this.settings.isJQueryPlugin) $(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)); | ||
} | ||
}, | ||
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]) | ||
}) | ||
/** | ||
* DOM events callbacks | ||
*/ | ||
callbacks: { | ||
onFocusBlur: function onFocusBlur(e) { | ||
var s = e.target.textContent.trim(); | ||
if( this.isJQueryPlugin ) | ||
$(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)) | ||
}, | ||
if (e.type == "focus") { | ||
// e.target.classList.remove('placeholder'); | ||
} else if (e.type == "blur" && s) { | ||
this.settings.addTagOnBlur && this.addTags(s, true).length; | ||
} else { | ||
// e.target.classList.add('placeholder'); | ||
this.DOM.input.removeAttribute('style'); | ||
this.dropdown.hide.call(this); | ||
} | ||
}, | ||
onKeydown: function onKeydown(e) { | ||
var s = e.target.textContent, | ||
lastTag; | ||
/** | ||
* DOM events callbacks | ||
*/ | ||
callbacks : { | ||
onFocusBlur : function(e){ | ||
var text = e.target.value.trim(); | ||
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); | ||
} else if (e.key == 'Escape') { | ||
this.input.set.call(this); | ||
e.target.blur(); | ||
} else if (e.key == 'Enter') { | ||
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 | ||
this.addTags(this.input.value, true); | ||
} else if (e.key == 'ArrowRight') this.input.autocomplete.set.call(this); | ||
}, | ||
onInput: function onInput(e) { | ||
var value = e.target.textContent.trim(), | ||
showSuggestions = value.length >= this.settings.dropdown.enabled; | ||
if( e.type == "focus" ) | ||
e.target.className = 'input'; | ||
else if( e.type == "blur" && text ){ | ||
if( this.addTag(text).length ) | ||
e.target.value = ''; | ||
if (this.input.value == value) return; | ||
// save the value on the input state object | ||
this.input.value = value; | ||
this.input.normalize.call(this); | ||
this.input.autocomplete.suggest.call(this, ''); // cleanup any possible previous suggestion | ||
if (value.search(this.settings.delimiters) != -1) { | ||
if (this.addTags(value).length) this.input.set.call(this); // clear the input field's value | ||
} else if (this.settings.dropdown.enabled && this.settings.whitelist.length) this.dropdown[showSuggestions ? "show" : "hide"].call(this, value); | ||
}, | ||
onInputIE: function onInputIE(e) { | ||
var _this = this; | ||
// for the "e.target.textContent" to be changed, the browser requires a small delay | ||
setTimeout(function () { | ||
_this.events.callbacks.onInput.call(_this, e); | ||
}); | ||
}, | ||
onPaste: function onPaste(e) {}, | ||
onClickScope: function onClickScope(e) { | ||
if (e.target.tagName == "TAGS") this.DOM.input.focus();else if (e.target.tagName == "X") { | ||
this.removeTag(e.target.parentNode); | ||
} | ||
} | ||
} | ||
else{ | ||
e.target.className = 'input placeholder'; | ||
this.DOM.input.removeAttribute('style'); | ||
} | ||
}, | ||
onKeydown : function(e){ | ||
var s = e.target.value, | ||
that = this; | ||
/** | ||
* input bridge for accessing & setting | ||
* @type {Object} | ||
*/ | ||
input: { | ||
value: '', | ||
set: function set() { | ||
var s = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; | ||
if( e.key == "Backspace" && (s == "" || s.charCodeAt(0) == 8203) ){ | ||
this.removeTag( this.DOM.scope.querySelectorAll('tag:not(.tagify--hide)').length - 1 ); | ||
this.input.value = this.DOM.input.innerHTML = s; | ||
if (s.length < 2) this.input.autocomplete.suggest.call(this, ''); | ||
}, | ||
// remove any child DOM elements that aren't of type TEXT (like <br>) | ||
normalize: function normalize() { | ||
while (this.DOM.input.firstElementChild) { | ||
this.DOM.input.removeChild(this.DOM.input.firstElementChild); | ||
} | ||
}, | ||
/** | ||
* suggest the rest of the input's value | ||
* @param {String} s [description] | ||
*/ | ||
autocomplete: { | ||
suggest: function suggest(s) { | ||
if (s) this.DOM.input.setAttribute("data-suggest", s.substring(this.input.value.length));else this.DOM.input.removeAttribute("data-suggest"); | ||
}, | ||
set: function set() { | ||
var suggestion = this.DOM.input.getAttribute('data-suggest'); | ||
if (suggestion && this.addTags(this.input.value + suggestion).length) { | ||
this.input.set.call(this); | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
} | ||
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.addTag(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; | ||
getNodeIndex: function getNodeIndex(node) { | ||
var index = 0; | ||
while (node = node.previousSibling) { | ||
if (node.nodeType != 3 || !/^\s*$/.test(node.data)) index++; | ||
}return index; | ||
}, | ||
e.target.style.width = ((e.target.value.length + 1) * 7) + 'px'; | ||
/** | ||
* 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 isTagDuplicate(s) { | ||
return this.value.some(function (item) { | ||
return s.toLowerCase() === item.value.toLowerCase(); | ||
}); | ||
}, | ||
// if( value.indexOf(',') != -1 || isDatalistInput ){ | ||
if( value.slice().search(this.settings.delimiters) != -1 || isDatalistInput ){ | ||
if( this.addTag(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) | ||
/** | ||
* 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 markTagByValue(value, tagElm) { | ||
var tagsElms, tagsElmsLen; | ||
if (!tagElm) { | ||
tagsElms = this.DOM.scope.querySelectorAll('tag'); | ||
for (tagsElmsLen = tagsElms.length; tagsElmsLen--;) { | ||
if (tagsElms[tagsElmsLen].value.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; | ||
}, | ||
onPaste : function(e){ | ||
var that = this; | ||
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput); | ||
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50); | ||
/** | ||
* make sure the tag, or words in it, is not in the blacklist | ||
*/ | ||
isTagBlacklisted: function isTagBlacklisted(v) { | ||
v = v.split(' '); | ||
return this.settings.blacklist.filter(function (x) { | ||
return v.indexOf(x) != -1; | ||
}).length; | ||
}, | ||
onClickScope : function(e){ | ||
if( e.target.tagName == "TAGS" ) | ||
this.DOM.input.focus(); | ||
if( e.target.tagName == "X" ){ | ||
this.removeTag( this.getNodeIndex(e.target.parentNode) ); | ||
} | ||
} | ||
}, | ||
/** | ||
* Build tags suggestions using HTML datalist | ||
* @return {[type]} [description] | ||
*/ | ||
buildDataList : function(){ | ||
var OPTIONS = "", | ||
i, | ||
datalist = document.createElement('datalist'); | ||
/** | ||
* make sure the tag, or words in it, is not in the blacklist | ||
*/ | ||
isTagWhitelisted: function isTagWhitelisted(v) { | ||
return this.settings.whitelist.some(function (item) { | ||
var value = item.value ? item.value : item; | ||
if (value.toLowerCase() === v.toLowerCase()) return true; | ||
}); | ||
}, | ||
datalist.id = 'tagifySuggestions' + this.id; | ||
datalist.innerHTML = "<label> \ | ||
select from the list: \ | ||
<select> \ | ||
<option value=''></option> \ | ||
[OPTIONS] \ | ||
</select> \ | ||
</label>"; | ||
for( i=this.settings.whitelist.length; i--; ) | ||
OPTIONS += "<option>"+ this.settings.whitelist[i] +"</option>"; | ||
/** | ||
* 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] | ||
* @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags] | ||
* @return {Array} Array of DOM elements (tags) | ||
*/ | ||
addTags: function addTags(tagsItems, clearInput) { | ||
var that = this, | ||
tagElems = []; | ||
datalist.innerHTML = datalist.innerHTML.replace('[OPTIONS]', OPTIONS); // inject the options string in the right place | ||
this.DOM.input.removeAttribute('style'); | ||
// this.DOM.input.insertAdjacentHTML('afterend', datalist); // append the datalist HTML string in the Tags | ||
/** | ||
* 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 | ||
return datalist; | ||
}, | ||
// no need to continue if "tagsItems" is an Array of Objects | ||
if (isComplex) return result; | ||
getNodeIndex : function( node ){ | ||
var index = 0; | ||
while( (node = node.previousSibling) ) | ||
if (node.nodeType != 3 || !/^\s*$/.test(node.data)) | ||
index++; | ||
return index; | ||
}, | ||
// 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(); | ||
}); | ||
/** | ||
* Searches if any tags with a certain value exist and mark them | ||
* @param {String / Number} value [description] | ||
* @return {boolean} [found / not found] | ||
*/ | ||
markTagByValue : function(value){ | ||
var idx = this.value.filter(function(item){ return value.toLowerCase() === item.toLowerCase() })[0], | ||
tag = this.DOM.scope.querySelectorAll('tag')[idx]; | ||
if (matchObj[0]) { | ||
isComplex = true; | ||
result = matchObj; // set the Array (with the found Object) as the new value | ||
} | ||
} | ||
if( tag ){ | ||
tag.classList.add('tagify--mark'); | ||
setTimeout(function(){ tag.classList.remove('tagify--mark') }, 2000); | ||
return true; | ||
} | ||
// if the value is a "simple" String, ex: "aaa, bbb, ccc" | ||
if (!isComplex) { | ||
tagsItems = tagsItems.trim(); | ||
if (!tagsItems) return []; | ||
return false; | ||
}, | ||
// 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() }; | ||
}); | ||
} | ||
/** | ||
* 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; | ||
}, | ||
return result.filter(function (n) { | ||
return n; | ||
}); // cleanup the array from "undefined", "false" or empty items; | ||
} | ||
/** | ||
* make sure the tag, or words in it, is not in the blacklist | ||
*/ | ||
isTagWhitelisted : function(v){ | ||
return this.settings.whitelist.indexOf(v) != -1; | ||
}, | ||
/** | ||
* 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; | ||
/** | ||
* add a "tag" element to the "tags" component | ||
* @param {String} value [A string of a value or multiple values] | ||
* @return {Array} Array of DOM elements | ||
*/ | ||
addTag : function( value ){ | ||
var that = this, | ||
result; | ||
// check for empty value | ||
if (!value) return "empty"; | ||
this.DOM.input.removeAttribute('style'); | ||
// 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"; | ||
value = value.trim(); | ||
// check if the tag already exists | ||
if (this.isTagDuplicate(value)) { | ||
this.trigger('duplicate', value); | ||
if( !value ) return []; | ||
if (!this.settings.duplicates) { | ||
// this.markTagByValue(value, tagElm) | ||
return "duplicate"; | ||
} | ||
} | ||
// go over each tag and add it (if there were multiple ones) | ||
result = value.split(this.settings.delimiters).filter(function(v){ return !!v }).map(function(v){ | ||
v = v.trim(); | ||
// check if the tag is allowed by the rules set | ||
tagAllowed = !this.isTagBlacklisted(value) && (!this.settings.enforceWhitelist || this.isTagWhitelisted(value)) && !maxTagsExceed; | ||
if( that.settings.pattern && !(that.settings.pattern.test(v)) ) | ||
return false; | ||
// Check against blacklist & whitelist (if enforced) | ||
if (!tagAllowed) { | ||
tagData.class = tagData.class ? tagData.class + " tagify--notAllowed" : "tagify--notAllowed"; | ||
var tagElm = document.createElement('tag'), | ||
isDuplicate = that.markTagByValue(v), | ||
tagAllowed, | ||
tagNotAllowedEventName, | ||
maxTagsExceed = that.value.length >= that.settings.maxTags; | ||
// 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'; | ||
if( isDuplicate ){ | ||
that.trigger('duplicate', v); | ||
if( !that.settings.duplicates ){ | ||
return false; | ||
this.trigger(eventName__error, { value: value, index: this.value.length }); | ||
return "notAllowed"; | ||
} | ||
return true; | ||
} | ||
tagAllowed = !that.isTagBlacklisted(v) && (!that.settings.enforeWhitelist || that.isTagWhitelisted(v)) && !maxTagsExceed; | ||
/** | ||
* appened (validated) tag to the component's DOM scope | ||
* @return {[type]} [description] | ||
*/ | ||
function appendTag(tagElm) { | ||
this.DOM.scope.insertBefore(tagElm, this.DOM.input); | ||
} | ||
// check against blacklist & whitelist (if enforced) | ||
if( !tagAllowed ){ | ||
tagElm.classList.add('tagify--notAllowed'); | ||
setTimeout(function(){ that.removeTag(that.getNodeIndex(tagElm), true) }, 1000); | ||
////////////////////// | ||
tagsItems = normalizeTags.call(this, tagsItems); | ||
// broadcast why the tag was not allowed | ||
if( maxTagsExceed ) tagNotAllowedEventName = 'maxTagsExceed'; | ||
else if( that.isTagBlacklisted(v) ) tagNotAllowedEventName = 'blacklisted'; | ||
else if( that.settings.enforeWhitelist && !that.isTagWhitelisted(v) ) tagNotAllowedEventName = 'notWhitelisted'; | ||
tagsItems.forEach(function (tagData) { | ||
var isTagValidated = validateTag.call(that, tagData); | ||
that.trigger(tagNotAllowedEventName, {value:v, index:that.value.length}); | ||
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({}, { index: that.value.length, tag: tagElm }, tagData)); | ||
tagElems.push(tagElm); | ||
} | ||
} | ||
}); | ||
if (tagsItems.length && clearInput) { | ||
this.input.set.call(this); | ||
} | ||
// the space below is important - http://stackoverflow.com/a/19668740/104380 | ||
tagElm.innerHTML = "<x></x><div><span title='"+ v +"'>"+ v +" </span></div>"; | ||
that.DOM.scope.insertBefore(tagElm, that.DOM.input.parentNode); | ||
return tagElems; | ||
}, | ||
if( tagAllowed ){ | ||
that.value.push(v); | ||
that.update(); | ||
that.trigger('add', {value:v, index:that.value.length}); | ||
/** | ||
* 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 createTagElem(tagData) { | ||
var tagElm, | ||
escapedValue = this.escapeHtml(tagData.value), | ||
template = "<tag>\n <x></x><div><span title='" + escapedValue + "'>" + escapedValue + "</span></div>\n </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]); | ||
} | ||
} | ||
tagElm = this.parseHTML(template); | ||
// add any attribuets, if exists | ||
addTagAttrs(tagElm, tagData); | ||
return tagElm; | ||
}); | ||
}, | ||
return result.filter(function(n){ return n }); | ||
}, | ||
/** | ||
* Removes a tag | ||
* @param {Number} idx [tag index to be removed] | ||
* @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( idx, silent ){ | ||
var tagElm = this.DOM.scope.children[idx]; | ||
if( !tagElm) return; | ||
/** | ||
* 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 removeTag(tagElm, silent) { | ||
var tagData, | ||
tagIdx = this.getNodeIndex(tagElm); | ||
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'); | ||
if (!tagElm) return; | ||
// manual timeout (hack, since transitionend cannot be used because of hover) | ||
setTimeout(function(){ | ||
tagElm.parentNode.removeChild(tagElm); | ||
}, 400); | ||
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'); | ||
if( !silent ){ | ||
this.value.splice(idx, 1); // remove the tag from the data object | ||
this.update(); // update the original input with the current value | ||
this.trigger('remove', {value:tagElm.textContent.trim(), index:idx}); | ||
} | ||
}, | ||
// manual timeout (hack, since transitionend cannot be used because of hover) | ||
setTimeout(function () { | ||
tagElm.parentNode.removeChild(tagElm); | ||
}, 400); | ||
removeAllTags : function(){ | ||
this.value = []; | ||
this.update(); | ||
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function(elm){ | ||
elm.parentNode.removeChild(elm); | ||
}); | ||
}, | ||
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({}, { index: tagIdx, tag: tagElm }, tagData)); | ||
} | ||
}, | ||
removeAllTags: function removeAllTags() { | ||
this.value = []; | ||
this.update(); | ||
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function (elm) { | ||
return elm.parentNode.removeChild(elm); | ||
}); | ||
}, | ||
/** | ||
* update the origianl (hidden) input field's value | ||
*/ | ||
update : function(){ | ||
this.DOM.originalInput.value = this.value.join(','); | ||
} | ||
} | ||
})(jQuery); | ||
/** | ||
* update the origianl (hidden) input field's value | ||
*/ | ||
update: function update() { | ||
var tagsAsString = this.value.map(function (v) { | ||
return v.value; | ||
}).join(','); | ||
this.DOM.originalInput.value = tagsAsString; | ||
}, | ||
/** | ||
* Dropdown controller | ||
* @type {Object} | ||
*/ | ||
dropdown: { | ||
init: function init() { | ||
this.DOM.dropdown = this.dropdown.build.call(this); | ||
}, | ||
build: function build() { | ||
var className = ("tagify__dropdown " + this.settings.dropdown.classname).trim(), | ||
template = "<div class=\"" + className + "\"></div>"; | ||
return this.parseHTML(template); | ||
}, | ||
show: function show(value) { | ||
var listItems = this.dropdown.filterListItems.call(this, value), | ||
listHTML = this.dropdown.createListHTML(listItems); | ||
if (listItems.length && this.settings.autoComplete) this.input.autocomplete.suggest.call(this, listItems[0].value); | ||
if (!listHTML || listItems.length < 2) { | ||
this.dropdown.hide.call(this); | ||
return; | ||
} | ||
this.DOM.dropdown.innerHTML = listHTML; | ||
this.dropdown.position.call(this); | ||
// if the dropdown has yet to be appended to the document, | ||
// append the dropdown to the body element & handle events | ||
if (!this.DOM.dropdown.parentNode != document.body) { | ||
document.body.appendChild(this.DOM.dropdown); | ||
this.events.binding.call(this, false); // unbind the main events | ||
this.dropdown.events.binding.call(this); | ||
} | ||
}, | ||
hide: function hide() { | ||
if (!this.DOM.dropdown || this.DOM.dropdown.parentNode != document.body) return; | ||
document.body.removeChild(this.DOM.dropdown); | ||
window.removeEventListener('resize', this.dropdown.position); | ||
this.dropdown.events.binding.call(this, false); // unbind all events | ||
this.events.binding.call(this); // re-bind main events | ||
}, | ||
position: function position() { | ||
var rect = this.DOM.scope.getBoundingClientRect(); | ||
this.DOM.dropdown.style.cssText = "left: " + rect.left + window.pageXOffset + "px; \ | ||
top: " + (rect.top + rect.height - 1 + window.pageYOffset) + "px; \ | ||
width: " + rect.width + "px"; | ||
}, | ||
/** | ||
* @type {Object} | ||
*/ | ||
events: { | ||
/** | ||
* Events should only be binded when the dropdown is rendered and removed when isn't | ||
* @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events] | ||
* @return {[type]} [description] | ||
*/ | ||
binding: function binding() { | ||
var bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; | ||
// references to the ".bind()" methods must be saved so they could be unbinded later | ||
var _CBR = this.listeners.dropdown = this.listeners.dropdown || { | ||
position: this.dropdown.position.bind(this), | ||
onKeyDown: this.dropdown.events.callbacks.onKeyDown.bind(this), | ||
onMouseOver: this.dropdown.events.callbacks.onMouseOver.bind(this), | ||
onClick: this.dropdown.events.callbacks.onClick.bind(this) | ||
}, | ||
action = bindUnbind ? 'addEventListener' : 'removeEventListener'; | ||
window[action]('resize', _CBR.position); | ||
window[action]('keydown', _CBR.onKeyDown); | ||
window[action]('click', _CBR.onClick); | ||
this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); | ||
// this.DOM.dropdown[action]('click', _CBR.onClick); | ||
}, | ||
callbacks: { | ||
onKeyDown: function onKeyDown(e) { | ||
var selectedElm = this.DOM.dropdown.querySelectorAll("[class$='--active']")[0], | ||
newValue = ""; | ||
switch (e.key) { | ||
case 'ArrowDown': | ||
case 'ArrowUp': | ||
case 'Down': // >IE11 | ||
case 'Up': | ||
// >IE11 | ||
e.preventDefault(); | ||
if (selectedElm) selectedElm = selectedElm[e.key == 'ArrowUp' || e.key == 'Up' ? "previousElementSibling" : "nextElementSibling"]; | ||
// if no element was found, loop | ||
else selectedElm = this.DOM.dropdown.children[e.key == 'ArrowUp' || e.key == 'Up' ? this.DOM.dropdown.children.length - 1 : 0]; | ||
this.dropdown.highlightOption.call(this, selectedElm); | ||
break; | ||
case 'Escape': | ||
this.dropdown.hide.call(this); | ||
break; | ||
case 'Enter': | ||
e.preventDefault(); | ||
newValue = selectedElm ? selectedElm.textContent : this.input.value; | ||
this.addTags(newValue, true); | ||
this.dropdown.hide.call(this); | ||
break; | ||
case 'ArrowRight': | ||
this.input.autocomplete.set.call(this); | ||
break; | ||
} | ||
}, | ||
onMouseOver: function onMouseOver(e) { | ||
// event delegation check | ||
if (e.target.className.includes('__item')) this.dropdown.highlightOption.call(this, e.target); | ||
}, | ||
onClick: function onClick(e) { | ||
if (e.target.className.includes('tagify__dropdown__item')) { | ||
this.input.set.call(this); | ||
this.addTags(e.target.textContent); | ||
} | ||
// clicked outside the dropdown, so just close it | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
}, | ||
highlightOption: function highlightOption(elm) { | ||
if (!elm) return; | ||
var className = "tagify__dropdown__item--active"; | ||
// for IE support, which doesn't allow "forEach" on "NodeList" Objects | ||
[].forEach.call(this.DOM.dropdown.querySelectorAll("[class$='--active']"), function (activeElm) { | ||
activeElm.classList.remove(className); | ||
}); | ||
// this.DOM.dropdown.querySelectorAll("[class$='--active']").forEach(activeElm => activeElm.classList.remove(className)); | ||
elm.classList.add(className); | ||
}, | ||
/** | ||
* returns an HTML string of the suggestions' list items | ||
* @return {[type]} [description] | ||
*/ | ||
filterListItems: function filterListItems(value) { | ||
if (!value) return ""; | ||
var list = [], | ||
whitelist = this.settings.whitelist, | ||
suggestionsCount = this.settings.dropdown.maxItems || Infinity, | ||
whitelistItem, | ||
valueIsInWhitelist, | ||
i = 0; | ||
for (; i < whitelist.length; i++) { | ||
whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value: whitelist[i] }, //normalize value as an Object | ||
valueIsInWhitelist = whitelistItem.value.toLowerCase().replace(/\s/g, '').indexOf(value.toLowerCase().replace(/\s/g, '')) == 0; // for fuzzy-search use ">=" | ||
// match for the value within each "whitelist" item | ||
if (valueIsInWhitelist && !this.isTagDuplicate(whitelistItem.value) && suggestionsCount--) list.push(whitelistItem); | ||
if (suggestionsCount == 0) break; | ||
} | ||
return list; | ||
}, | ||
/** | ||
* Creates the dropdown items' HTML | ||
* @param {Array} list [Array of Objects] | ||
* @return {String} | ||
*/ | ||
createListHTML: function createListHTML(list) { | ||
function getItem(item) { | ||
return "<div class='tagify__dropdown__item " + (item.class ? item.class : "") + "' " + getAttributesString(item) + ">" + item.value + "</div>"; | ||
}; | ||
// for a certain Tag element, add attributes. | ||
function getAttributesString(item) { | ||
var i, | ||
keys = Object.keys(item), | ||
s = ""; | ||
for (i = keys.length; i--;) { | ||
var propName = keys[i]; | ||
if (propName != 'class' && !item.hasOwnProperty(propName)) return; | ||
s += " " + propName + (item[propName] ? "=" + item[propName] : ""); | ||
} | ||
return s; | ||
} | ||
return list.map(getItem).join(""); | ||
} | ||
} | ||
}; | ||
})(jQuery); |
@@ -1,1 +0,1 @@ | ||
!function(t){function e(t,e){if(!t)return console.warn("Tagify: ","invalid input element ",t),this;if(this.settings=this.extend({},e,this.DEFAULTS),this.settings.readonly=t.hasAttribute("readonly"),t.pattern)try{this.settings.pattern=new RegExp(t.pattern)}catch(t){}if(e&&e.delimiters)try{this.settings.delimiters=new RegExp("["+e.delimiters+"]")}catch(t){}this.id=Math.random().toString(36).substr(2,9),this.value=[],this.DOM={},this.extend(this,new this.EventDispatcher),this.build(t),this.events()}t.fn.tagify=function(t){var i,n=this;return n.data("tagify")?this:(i=new e(this[0],t),i.isJQueryPlugin=!0,n.data("tagify",i),this)},e.prototype={DEFAULTS:{delimiters:",",pattern:"",callbacks:{},duplicates:!1,enforeWhitelist:!1,autocomplete:!0,whitelist:[],blacklist:[],maxTags:1/0,suggestionsMinChars:2},build:function(t){var e=t.value,i='<div><input list="tagifySuggestions'+this.id+'" class="placeholder"/><span>'+t.placeholder+"</span></div>";this.DOM.originalInput=t,this.DOM.scope=document.createElement("tags"),this.DOM.scope.innerHTML=i,this.DOM.input=this.DOM.scope.querySelector("input"),this.settings.readonly&&this.DOM.scope.classList.add("readonly"),t.parentNode.insertBefore(this.DOM.scope,t),this.DOM.scope.appendChild(t),this.settings.autocomplete&&this.settings.whitelist.length&&(this.DOM.datalist=this.buildDataList()),e&&this.addTag(e).forEach(function(t){t&&t.classList.add("tagify--noAnim")})},destroy:function(){this.DOM.scope.parentNode.appendChild(this.DOM.originalInput),this.DOM.scope.parentNode.removeChild(this.DOM.scope)},extend:function(t,e,i){function n(t,e){for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i])}return t instanceof Object||(t={}),i?(n(t,i),n(t,e)):n(t,e),t},EventDispatcher:function(){var e=document.createTextNode("");this.off=e.removeEventListener.bind(e),this.on=e.addEventListener.bind(e),this.trigger=function(i,n){var s;if(i)if(this.isJQueryPlugin)t(this.DOM.originalInput).triggerHandler(i,[n]);else{try{s=new CustomEvent(i,{detail:n})}catch(t){(s=document.createEvent("Event")).initEvent("toggle",!1,!1)}e.dispatchEvent(s)}}},events:function(){var e=this,i={paste:["onPaste","input"],focus:["onFocusBlur","input"],blur:["onFocusBlur","input"],input:["onInput","input"],keydown:["onKeydown","input"],click:["onClickScope","scope"]},n=["add","remove","duplicate","maxTagsExceed","blacklisted","notWhitelisted"];for(var s in i)this.DOM[i[s][1]].addEventListener(s,this.callbacks[i[s][0]].bind(this));n.forEach(function(t){e.on(t,e.settings.callbacks[t])}),this.isJQueryPlugin&&t(this.DOM.originalInput).on("tagify.removeAllTags",this.removeAllTags.bind(this))},callbacks:{onFocusBlur:function(t){var e=t.target.value.trim();"focus"==t.type?t.target.className="input":"blur"==t.type&&e?this.addTag(e).length&&(t.target.value=""):(t.target.className="input placeholder",this.DOM.input.removeAttribute("style"))},onKeydown:function(t){var e=t.target.value,i=this;if("Backspace"!=t.key||""!=e&&8203!=e.charCodeAt(0)||this.removeTag(this.DOM.scope.querySelectorAll("tag:not(.tagify--hide)").length-1),"Escape"==t.key&&(t.target.value="",t.target.blur()),"Enter"==t.key)return t.preventDefault(),this.addTag(e).length&&(t.target.value=""),!1;this.noneDatalistInput&&clearTimeout(this.noneDatalistInput),this.noneDatalistInput=setTimeout(function(){i.noneDatalistInput=null},50)},onInput:function(t){var e,i=t.target.value,n=(i[i.length-1],!this.noneDatalistInput&&i.length>1),s=i.length>=this.settings.suggestionsMinChars;t.target.style.width=7*(t.target.value.length+1)+"px",-1!=i.slice().search(this.settings.delimiters)||n?this.addTag(i).length&&(t.target.value=""):this.settings.autocomplete&&this.settings.whitelist.length&&(e=this.DOM.input.parentNode.contains(this.DOM.datalist),!s&&e?this.DOM.input.parentNode.removeChild(this.DOM.datalist):s&&!e&&this.DOM.input.parentNode.appendChild(this.DOM.datalist))},onPaste:function(t){var e=this;this.noneDatalistInput&&clearTimeout(this.noneDatalistInput),this.noneDatalistInput=setTimeout(function(){e.noneDatalistInput=null},50)},onClickScope:function(t){"TAGS"==t.target.tagName&&this.DOM.input.focus(),"X"==t.target.tagName&&this.removeTag(this.getNodeIndex(t.target.parentNode))}},buildDataList:function(){var t,e="",i=document.createElement("datalist");for(i.id="tagifySuggestions"+this.id,i.innerHTML="<label> select from the list: <select> <option value=''></option> [OPTIONS] </select> </label>",t=this.settings.whitelist.length;t--;)e+="<option>"+this.settings.whitelist[t]+"</option>";return i.innerHTML=i.innerHTML.replace("[OPTIONS]",e),i},getNodeIndex:function(t){for(var e=0;t=t.previousSibling;)3==t.nodeType&&/^\s*$/.test(t.data)||e++;return e},markTagByValue:function(t){var e=this.value.filter(function(e){return t.toLowerCase()===e.toLowerCase()})[0],i=this.DOM.scope.querySelectorAll("tag")[e];return!!i&&(i.classList.add("tagify--mark"),setTimeout(function(){i.classList.remove("tagify--mark")},2e3),!0)},isTagBlacklisted:function(t){return t=t.split(" "),this.settings.blacklist.filter(function(e){return-1!=t.indexOf(e)}).length},isTagWhitelisted:function(t){return-1!=this.settings.whitelist.indexOf(t)},addTag:function(t){var e=this;return this.DOM.input.removeAttribute("style"),(t=t.trim())?t.split(this.settings.delimiters).filter(function(t){return!!t}).map(function(t){if(t=t.trim(),e.settings.pattern&&!e.settings.pattern.test(t))return!1;var i,n,s=document.createElement("tag"),a=e.markTagByValue(t),r=e.value.length>=e.settings.maxTags;return!(a&&(e.trigger("duplicate",t),!e.settings.duplicates))&&((i=!e.isTagBlacklisted(t)&&(!e.settings.enforeWhitelist||e.isTagWhitelisted(t))&&!r)||(s.classList.add("tagify--notAllowed"),setTimeout(function(){e.removeTag(e.getNodeIndex(s),!0)},1e3),r?n="maxTagsExceed":e.isTagBlacklisted(t)?n="blacklisted":e.settings.enforeWhitelist&&!e.isTagWhitelisted(t)&&(n="notWhitelisted"),e.trigger(n,{value:t,index:e.value.length})),s.innerHTML="<x></x><div><span title='"+t+"'>"+t+" </span></div>",e.DOM.scope.insertBefore(s,e.DOM.input.parentNode),i&&(e.value.push(t),e.update(),e.trigger("add",{value:t,index:e.value.length})),s)}).filter(function(t){return t}):[]},removeTag:function(t,e){var i=this.DOM.scope.children[t];i&&(i.style.width=parseFloat(window.getComputedStyle(i).width)+"px",document.body.clientTop,i.classList.add("tagify--hide"),setTimeout(function(){i.parentNode.removeChild(i)},400),e||(this.value.splice(t,1),this.update(),this.trigger("remove",{value:i.textContent.trim(),index:t})))},removeAllTags:function(){this.value=[],this.update(),Array.prototype.slice.call(this.DOM.scope.querySelectorAll("tag")).forEach(function(t){t.parentNode.removeChild(t)})},update:function(){this.DOM.originalInput.value=this.value.join(",")}}}(jQuery); | ||
"use strict";!function(t){function e(t,e){if(!t)return console.warn("Tagify: ","invalid input element ",t),this;if(this.settings=this.extend({},this.DEFAULTS,e),this.settings.readonly=t.hasAttribute("readonly"),isIE&&(this.settings.autoComplete=!1),t.pattern)try{this.settings.pattern=new RegExp(t.pattern)}catch(t){}if(this.settings&&this.settings.delimiters)try{this.settings.delimiters=new RegExp("["+this.settings.delimiters+"]","g")}catch(t){}this.id=Math.random().toString(36).substr(2,9),this.value=[],this.listeners={},this.DOM={},this.extend(this,new this.EventDispatcher(this)),this.build(t),this.events.customBinding.call(this),this.events.binding.call(this)}t.fn.tagify=function(){var i=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.each(function(){var n,s=t(this);if(s.data("tagify"))return this;i.isJQueryPlugin=!0,n=new e(s[0],i),s.data("tagify",n)})},e.prototype={isIE:window.document.documentMode,DEFAULTS:{delimiters:",",pattern:null,maxTags:1/0,callbacks:{},addTagOnBlur:!0,duplicates:!1,whitelist:[],blacklist:[],enforceWhitelist:!1,autoComplete:!0,dropdown:{classname:"",enabled:2,maxItems:10}},customEventsList:["add","remove","duplicate","maxTagsExceed","blacklisted","notWhitelisted"],parseHTML:function(t){return(new DOMParser).parseFromString(t.trim(),"text/html").body.firstElementChild},escapeHtml:function(t){var e=document.createTextNode(t),i=document.createElement("p");return i.appendChild(e),i.innerHTML},build:function(t){var e=t.value,i='\n <tags class="tagify '+t.className+" "+(this.settings.readonly?"readonly":"")+'">\n <div contenteditable data-placeholder="'+t.placeholder+'" class="tagify--input"></div>\n </tags>';this.DOM.originalInput=t,this.DOM.scope=this.parseHTML(i),this.DOM.input=this.DOM.scope.querySelector("[contenteditable]"),t.parentNode.insertBefore(this.DOM.scope,t),this.settings.dropdown.enabled&&this.settings.whitelist.length&&this.dropdown.init.call(this),e&&this.addTags(e).forEach(function(t){t&&t.classList.add("tagify--noAnim")})},destroy:function(){this.DOM.scope.parentNode.removeChild(this.DOM.scope)},extend:function(t,e,i){function n(t){var e=Object.prototype.toString.call(t);return t===Object(t)&&"[object Array]"!=e&&"[object Function]"!=e}function s(t,e){for(var i in e)e.hasOwnProperty(i)&&(n(e[i])?n(t[i])?s(t[i],e[i]):t[i]=Object.assign({},e[i]):t[i]=e[i])}return t instanceof Object||(t={}),s(t,e),i&&s(t,i),t},EventDispatcher:function(e){var i=document.createTextNode("");this.off=function(t,e){return e&&i.removeEventListener.call(i,t,e),this},this.on=function(t,e){return e&&i.addEventListener.call(i,t,e),this},this.trigger=function(n,s){var o;if(n)if(e.settings.isJQueryPlugin)t(e.DOM.originalInput).triggerHandler(n,[s]);else{try{o=new CustomEvent(n,{detail:s})}catch(t){console.warn(t)}i.dispatchEvent(o)}}},events:{customBinding:function(){var t=this;this.customEventsList.forEach(function(e){t.on(e,t.settings.callbacks[e])})},binding:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],i=this.events.callbacks,n=this.listeners.main=this.listeners.main||{paste:["input",i.onPaste.bind(this)],focus:["input",i.onFocusBlur.bind(this)],blur:["input",i.onFocusBlur.bind(this)],keydown:["input",i.onKeydown.bind(this)],click:["scope",i.onClickScope.bind(this)]},s=e?"addEventListener":"removeEventListener";for(var o in n)this.DOM[n[o][0]][s](o,n[o][1]);e&&(this.DOM.input.addEventListener(this.isIE?"keydown":"input",i[this.isIE?"onInputIE":"onInput"].bind(this)),this.settings.isJQueryPlugin&&t(this.DOM.originalInput).on("tagify.removeAllTags",this.removeAllTags.bind(this)))},callbacks:{onFocusBlur:function(t){var e=t.target.textContent.trim();"focus"==t.type||("blur"==t.type&&e?this.settings.addTagOnBlur&&this.addTags(e,!0).length:(this.DOM.input.removeAttribute("style"),this.dropdown.hide.call(this)))},onKeydown:function(t){var e,i=t.target.textContent;"Backspace"!=t.key||""!=i&&8203!=i.charCodeAt(0)?"Escape"==t.key?(this.input.set.call(this),t.target.blur()):"Enter"==t.key?(t.preventDefault(),this.addTags(this.input.value,!0)):"ArrowRight"==t.key&&this.input.autocomplete.set.call(this):(e=(e=this.DOM.scope.querySelectorAll("tag:not(.tagify--hide)"))[e.length-1],this.removeTag(e))},onInput:function(t){var e=t.target.textContent.trim(),i=e.length>=this.settings.dropdown.enabled;this.input.value!=e&&(this.input.value=e,this.input.normalize.call(this),this.input.autocomplete.suggest.call(this,""),-1!=e.search(this.settings.delimiters)?this.addTags(e).length&&this.input.set.call(this):this.settings.dropdown.enabled&&this.settings.whitelist.length&&this.dropdown[i?"show":"hide"].call(this,e))},onInputIE:function(t){var e=this;setTimeout(function(){e.events.callbacks.onInput.call(e,t)})},onPaste:function(t){},onClickScope:function(t){"TAGS"==t.target.tagName?this.DOM.input.focus():"X"==t.target.tagName&&this.removeTag(t.target.parentNode)}}},input:{value:"",set:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"";this.input.value=this.DOM.input.innerHTML=t,t.length<2&&this.input.autocomplete.suggest.call(this,"")},normalize:function(){for(;this.DOM.input.firstElementChild;)this.DOM.input.removeChild(this.DOM.input.firstElementChild)},autocomplete:{suggest:function(t){t?this.DOM.input.setAttribute("data-suggest",t.substring(this.input.value.length)):this.DOM.input.removeAttribute("data-suggest")},set:function(){var t=this.DOM.input.getAttribute("data-suggest");t&&this.addTags(this.input.value+t).length&&(this.input.set.call(this),this.dropdown.hide.call(this))}}},getNodeIndex:function(t){for(var e=0;t=t.previousSibling;)3==t.nodeType&&/^\s*$/.test(t.data)||e++;return e},isTagDuplicate:function(t){return this.value.some(function(e){return t.toLowerCase()===e.value.toLowerCase()})},markTagByValue:function(t,e){var i,n;if(!e)for(n=(i=this.DOM.scope.querySelectorAll("tag")).length;n--;)i[n].value.toLowerCase().includes(t.toLowerCase())&&(e=i[n]);return!!e&&(e.classList.add("tagify--mark"),setTimeout(function(){e.classList.remove("tagify--mark")},2e3),!0)},isTagBlacklisted:function(t){return t=t.split(" "),this.settings.blacklist.filter(function(e){return-1!=t.indexOf(e)}).length},isTagWhitelisted:function(t){return this.settings.whitelist.some(function(e){if((e.value?e.value:e).toLowerCase()===t.toLowerCase())return!0})},addTags:function(t,e){function i(t){var e,i=t.value.trim(),n=this.value.length>=this.settings.maxTags;return i?this.settings.pattern&&!this.settings.pattern.test(i)?"pattern":this.isTagDuplicate(i)&&(this.trigger("duplicate",i),!this.settings.duplicates)?"duplicate":!!(!this.isTagBlacklisted(i)&&(!this.settings.enforceWhitelist||this.isTagWhitelisted(i))&&!n)||(t.class=t.class?t.class+" tagify--notAllowed":"tagify--notAllowed",n?e="maxTagsExceed":this.isTagBlacklisted(i)?e="blacklisted":this.settings.enforceWhitelist&&!this.isTagWhitelisted(i)&&(e="notWhitelisted"),this.trigger(e,{value:i,index:this.value.length}),"notAllowed"):"empty"}function n(t){this.DOM.scope.insertBefore(t,this.DOM.input)}var s=this,o=[];return this.DOM.input.removeAttribute("style"),(t=function(t){var e=this.settings.whitelist[0]instanceof Object,i=t instanceof Array&&"value"in t[0],n=t;if(i)return n;if(!i&&"string"==typeof t&&e){var s=this.settings.whitelist.filter(function(e){return e.value.toLowerCase()==t.toLowerCase()});s[0]&&(i=!0,n=s)}if(!i){if(!(t=t.trim()))return[];n=t.split(this.settings.delimiters).map(function(t){return{value:t.trim()}})}return n.filter(function(t){return t})}.call(this,t)).forEach(function(t){var e=i.call(s,t);if(!0===e||"notAllowed"==e){var a=s.createTagElem(t);n.call(s,a),"notAllowed"==e?setTimeout(function(){s.removeTag(a,!0)},1e3):(s.value.push(t),s.update(),s.trigger("add",s.extend({},{index:s.value.length,tag:a},t)),o.push(a))}}),t.length&&e&&this.input.set.call(this),o},createTagElem:function(t){var e,i=this.escapeHtml(t.value),n="<tag>\n <x></x><div><span title='"+i+"'>"+i+"</span></div>\n </tag>";return e=this.parseHTML(n),function(t,e){var i,n=Object.keys(e);for(i=n.length;i--;){var s=n[i];if(!e.hasOwnProperty(s))return;t.setAttribute(s,e[s])}}(e,t),e},removeTag:function(t,e){var i,n=this.getNodeIndex(t);t&&(t.style.width=parseFloat(window.getComputedStyle(t).width)+"px",document.body.clientTop,t.classList.add("tagify--hide"),setTimeout(function(){t.parentNode.removeChild(t)},400),e||(i=this.value.splice(n,1)[0],this.update(),this.trigger("remove",this.extend({},{index:n,tag:t},i))))},removeAllTags:function(){this.value=[],this.update(),Array.prototype.slice.call(this.DOM.scope.querySelectorAll("tag")).forEach(function(t){return t.parentNode.removeChild(t)})},update:function(){var t=this.value.map(function(t){return t.value}).join(",");this.DOM.originalInput.value=t},dropdown:{init:function(){this.DOM.dropdown=this.dropdown.build.call(this)},build:function(){var t='<div class="'+("tagify__dropdown "+this.settings.dropdown.classname).trim()+'"></div>';return this.parseHTML(t)},show:function(t){var e=this.dropdown.filterListItems.call(this,t),i=this.dropdown.createListHTML(e);e.length&&this.settings.autoComplete&&this.input.autocomplete.suggest.call(this,e[0].value),!i||e.length<2?this.dropdown.hide.call(this):(this.DOM.dropdown.innerHTML=i,this.dropdown.position.call(this),!this.DOM.dropdown.parentNode!=document.body&&(document.body.appendChild(this.DOM.dropdown),this.events.binding.call(this,!1),this.dropdown.events.binding.call(this)))},hide:function(){this.DOM.dropdown&&this.DOM.dropdown.parentNode==document.body&&(document.body.removeChild(this.DOM.dropdown),window.removeEventListener("resize",this.dropdown.position),this.dropdown.events.binding.call(this,!1),this.events.binding.call(this))},position:function(){var t=this.DOM.scope.getBoundingClientRect();this.DOM.dropdown.style.cssText="left: "+t.left+window.pageXOffset+"px; top: "+(t.top+t.height-1+window.pageYOffset)+"px; width: "+t.width+"px"},events:{binding:function(){var t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],e=this.listeners.dropdown=this.listeners.dropdown||{position:this.dropdown.position.bind(this),onKeyDown:this.dropdown.events.callbacks.onKeyDown.bind(this),onMouseOver:this.dropdown.events.callbacks.onMouseOver.bind(this),onClick:this.dropdown.events.callbacks.onClick.bind(this)},i=t?"addEventListener":"removeEventListener";window[i]("resize",e.position),window[i]("keydown",e.onKeyDown),window[i]("click",e.onClick),this.DOM.dropdown[i]("mouseover",e.onMouseOver)},callbacks:{onKeyDown:function(t){var e=this.DOM.dropdown.querySelectorAll("[class$='--active']")[0],i="";switch(t.key){case"ArrowDown":case"ArrowUp":case"Down":case"Up":t.preventDefault(),e=e?e["ArrowUp"==t.key||"Up"==t.key?"previousElementSibling":"nextElementSibling"]:this.DOM.dropdown.children["ArrowUp"==t.key||"Up"==t.key?this.DOM.dropdown.children.length-1:0],this.dropdown.highlightOption.call(this,e);break;case"Escape":this.dropdown.hide.call(this);break;case"Enter":t.preventDefault(),i=e?e.textContent:this.input.value,this.addTags(i,!0),this.dropdown.hide.call(this);break;case"ArrowRight":this.input.autocomplete.set.call(this)}},onMouseOver:function(t){t.target.className.includes("__item")&&this.dropdown.highlightOption.call(this,t.target)},onClick:function(t){t.target.className.includes("tagify__dropdown__item")&&(this.input.set.call(this),this.addTags(t.target.textContent)),this.dropdown.hide.call(this)}}},highlightOption:function(t){if(t){var e="tagify__dropdown__item--active";[].forEach.call(this.DOM.dropdown.querySelectorAll("[class$='--active']"),function(t){t.classList.remove(e)}),t.classList.add(e)}},filterListItems:function(t){if(!t)return"";for(var e,i=[],n=this.settings.whitelist,s=this.settings.dropdown.maxItems||1/0,o=0;o<n.length&&(e=n[o]instanceof Object?n[o]:{value:n[o]},0==e.value.toLowerCase().replace(/\s/g,"").indexOf(t.toLowerCase().replace(/\s/g,""))&&!this.isTagDuplicate(e.value)&&s--&&i.push(e),0!=s);o++);return i},createListHTML:function(t){function e(t){var e,i=Object.keys(t),n="";for(e=i.length;e--;){var s=i[e];if("class"!=s&&!t.hasOwnProperty(s))return;n+=" "+s+(t[s]?"="+t[s]:"")}return n}return t.map(function(t){return"<div class='tagify__dropdown__item "+(t.class?t.class:"")+"' "+e(t)+">"+t.value+"</div>"}).join("")}}}}(jQuery); |
/** | ||
* Tagify (v 1.2.1)- tags input component | ||
* Tagify (v 1.3.1)- tags input component | ||
* By Yair Even-Or (2016) | ||
@@ -16,101 +16,145 @@ * Don't sell this code. (c) | ||
}(this, function() { | ||
function Tagify( input, settings ){ | ||
'use strict'; | ||
function Tagify(input, settings) { | ||
// protection | ||
if( !input ){ | ||
console.warn('Tagify: ', 'invalid input element ', input) | ||
if (!input) { | ||
console.warn('Tagify: ', 'invalid input element ', input); | ||
return this; | ||
} | ||
this.settings = this.extend({}, settings, this.DEFAULTS); | ||
this.settings = this.extend({}, this.DEFAULTS, settings); | ||
this.settings.readonly = input.hasAttribute('readonly'); // if "readonly" do not include an "input" element inside the Tags component | ||
if( input.pattern ) | ||
try { | ||
this.settings.pattern = new RegExp(input.pattern); | ||
} catch(e){} | ||
if (isIE) this.settings.autoComplete = false; // IE goes crazy if this isn't false | ||
if( settings && settings.delimiters ){ | ||
if (input.pattern) try { | ||
this.settings.pattern = new RegExp(input.pattern); | ||
} catch (e) {} | ||
// Convert the "delimiters" setting into a REGEX object | ||
if (this.settings && this.settings.delimiters) { | ||
try { | ||
this.settings.delimiters = new RegExp("[" + settings.delimiters + "]"); | ||
} catch(e){} | ||
this.settings.delimiters = new RegExp("[" + this.settings.delimiters + "]", "g"); | ||
} catch (e) {} | ||
} | ||
this.id = Math.random().toString(36).substr(2,9), // almost-random ID (because, fuck it) | ||
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 | ||
// events' callbacks references will be stores here, so events could be unbinded | ||
this.listeners = {}; | ||
this.DOM = {}; // Store all relevant DOM elements in an Object | ||
this.extend(this, new this.EventDispatcher()); | ||
this.extend(this, new this.EventDispatcher(this)); | ||
this.build(input); | ||
this.events(); | ||
this.events.customBinding.call(this); | ||
this.events.binding.call(this); | ||
} | ||
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 | ||
enforeWhitelist : 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 | ||
isIE: window.document.documentMode, | ||
DEFAULTS: { | ||
delimiters: ",", // [regex] split tags by any of these delimiters ("null" to cancel) Example: ",| |." | ||
pattern: null, // regex pattern to validate input by. Ex: /[1-9]/ | ||
maxTags: Infinity, // maximum number of tags | ||
callbacks: {}, // exposed callbacks object to be triggered on certain events | ||
addTagOnBlur: true, // flag - automatically adds the text which was inputed as a tag when blur event happens | ||
duplicates: false, // flag - allow tuplicate tags | ||
whitelist: [], // is this list has any items, then only allow tags from this list | ||
blacklist: [], // a list of non-allowed tags | ||
enforceWhitelist: false, // flag - should ONLY use tags allowed in whitelist | ||
autoComplete: true, // flag - tries to autocomplete the input's value while typing | ||
dropdown: { | ||
classname: '', | ||
enabled: 2, // minimum input characters needs to be typed for the dropdown to show | ||
maxItems: 10 | ||
} | ||
}, | ||
customEventsList: ['add', 'remove', 'duplicate', 'maxTagsExceed', 'blacklisted', 'notWhitelisted'], | ||
/** | ||
* utility method | ||
* https://stackoverflow.com/a/35385518/104380 | ||
* @param {String} s [HTML string] | ||
* @return {Object} [DOM node] | ||
*/ | ||
parseHTML: function parseHTML(s) { | ||
var parser = new DOMParser(), | ||
node = parser.parseFromString(s.trim(), "text/html"); | ||
return node.body.firstElementChild; | ||
}, | ||
// https://stackoverflow.com/a/25396011/104380 | ||
escapeHtml: function escapeHtml(s) { | ||
var text = document.createTextNode(s); | ||
var p = document.createElement('p'); | ||
p.appendChild(text); | ||
return p.innerHTML; | ||
}, | ||
/** | ||
* builds the HTML of this component | ||
* @param {Object} input [DOM element which would be "transformed" into "Tags"] | ||
*/ | ||
build : function( input ){ | ||
build: function build(input) { | ||
var that = this, | ||
value = input.value, | ||
inputHTML = '<div><input list="tagifySuggestions'+ this.id +'" class="placeholder"/><span>'+ input.placeholder +'</span></div>'; | ||
template = '\n <tags class="tagify ' + input.className + ' ' + (this.settings.readonly ? 'readonly' : '') + '">\n <div contenteditable data-placeholder="' + input.placeholder + '" class="tagify--input"></div>\n </tags>'; | ||
this.DOM.originalInput = input; | ||
this.DOM.scope = document.createElement('tags'); | ||
this.DOM.scope.innerHTML = inputHTML; | ||
this.DOM.input = this.DOM.scope.querySelector('input'); | ||
if( this.settings.readonly ) | ||
this.DOM.scope.classList.add('readonly') | ||
this.DOM.scope = this.parseHTML(template); | ||
this.DOM.input = this.DOM.scope.querySelector('[contenteditable]'); | ||
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 ) | ||
this.DOM.datalist = this.buildDataList(); | ||
if (this.settings.dropdown.enabled && this.settings.whitelist.length) { | ||
this.dropdown.init.call(this); | ||
} | ||
// if the original input already had any value (tags) | ||
if( value ) | ||
this.addTag(value).forEach(function(tag){ | ||
tag && tag.classList.add('tagify--noAnim'); | ||
}); | ||
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); | ||
destroy: function destroy() { | ||
this.DOM.scope.parentNode.removeChild(this.DOM.scope); | ||
}, | ||
/** | ||
* Merge two objects into a new one | ||
* TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) | ||
*/ | ||
extend : function(o, o1, o2){ | ||
if( !(o instanceof Object) ) o = {}; | ||
extend: function extend(o, o1, o2) { | ||
if (!(o instanceof Object)) o = {}; | ||
if( o2 ){ | ||
copy(o, o2) | ||
copy(o, o1) | ||
} | ||
else | ||
copy(o, o1) | ||
copy(o, o1); | ||
if (o2) copy(o, o2); | ||
function copy(a,b){ | ||
function isObject(obj) { | ||
var type = Object.prototype.toString.call(obj); | ||
return obj === Object(obj) && type != '[object Array]' && type != '[object Function]'; | ||
}; | ||
function copy(a, b) { | ||
// copy o2 to o | ||
for( var key in b ) | ||
if( b.hasOwnProperty(key) ) | ||
a[key] = b[key]; | ||
for (var key in b) { | ||
if (b.hasOwnProperty(key)) { | ||
if (isObject(b[key])) { | ||
if (!isObject(a[key])) a[key] = Object.assign({}, b[key]);else copy(a[key], b[key]); | ||
} else a[key] = b[key]; | ||
} | ||
} | ||
} | ||
@@ -121,6 +165,7 @@ | ||
/** | ||
* A constructor for exposing events to the outside | ||
*/ | ||
EventDispatcher : function(){ | ||
EventDispatcher: function EventDispatcher(instance) { | ||
// Create a DOM EventTarget object | ||
@@ -130,129 +175,130 @@ 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){ | ||
this.off = function (name, cb) { | ||
if (cb) target.removeEventListener.call(target, name, cb); | ||
return this; | ||
}; | ||
this.on = function (name, cb) { | ||
if (cb) target.addEventListener.call(target, name, cb); | ||
return this; | ||
}; | ||
this.trigger = function (eventName, data) { | ||
var e; | ||
if( !eventName ) return; | ||
if (!eventName) return; | ||
if( this.isJQueryPlugin ) | ||
$(this.DOM.originalInput).triggerHandler(eventName, [data]) | ||
else{ | ||
if (instance.settings.isJQueryPlugin) { | ||
$(instance.DOM.originalInput).triggerHandler(eventName, [data]); | ||
} else { | ||
try { | ||
e = new CustomEvent(eventName, {"detail":data}); | ||
e = new CustomEvent(eventName, { "detail": data }); | ||
} catch (err) { | ||
console.warn(err); | ||
} | ||
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']; | ||
events: { | ||
// bind custom events which were passed in the settings | ||
customBinding: function customBinding() { | ||
var _this2 = this; | ||
for( var e in events ) | ||
this.DOM[events[e][1]].addEventListener(e, this.callbacks[events[e][0]].bind(this)); | ||
this.customEventsList.forEach(function (name) { | ||
_this2.on(name, _this2.settings.callbacks[name]); | ||
}); | ||
}, | ||
binding: function binding() { | ||
var bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; | ||
customList.forEach(function(name){ | ||
that.on(name, that.settings.callbacks[name]) | ||
}) | ||
var _CB = this.events.callbacks, | ||
if( this.isJQueryPlugin ) | ||
$(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)) | ||
}, | ||
// setup callback references so events could be removed later | ||
_CBR = this.listeners.main = this.listeners.main || { | ||
paste: ['input', _CB.onPaste.bind(this)], | ||
focus: ['input', _CB.onFocusBlur.bind(this)], | ||
blur: ['input', _CB.onFocusBlur.bind(this)], | ||
keydown: ['input', _CB.onKeydown.bind(this)], | ||
click: ['scope', _CB.onClickScope.bind(this)] | ||
}, | ||
action = bindUnbind ? 'addEventListener' : 'removeEventListener'; | ||
/** | ||
* 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.addTag(text).length ) | ||
e.target.value = ''; | ||
for (var eventName in _CBR) { | ||
this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]); | ||
} | ||
else{ | ||
e.target.className = 'input placeholder'; | ||
this.DOM.input.removeAttribute('style'); | ||
} | ||
}, | ||
onKeydown : function(e){ | ||
var s = e.target.value, | ||
that = this; | ||
if (bindUnbind) { | ||
// this event should never be unbinded | ||
// IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead.. | ||
this.DOM.input.addEventListener(this.isIE ? "keydown" : "input", _CB[this.isIE ? "onInputIE" : "onInput"].bind(this)); | ||
if( e.key == "Backspace" && (s == "" || s.charCodeAt(0) == 8203) ){ | ||
this.removeTag( this.DOM.scope.querySelectorAll('tag:not(.tagify--hide)').length - 1 ); | ||
if (this.settings.isJQueryPlugin) $(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)); | ||
} | ||
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.addTag(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'; | ||
/** | ||
* DOM events callbacks | ||
*/ | ||
callbacks: { | ||
onFocusBlur: function onFocusBlur(e) { | ||
var s = e.target.textContent.trim(); | ||
// if( value.indexOf(',') != -1 || isDatalistInput ){ | ||
if( value.slice().search(this.settings.delimiters) != -1 || isDatalistInput ){ | ||
if( this.addTag(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) | ||
if (e.type == "focus") { | ||
// e.target.classList.remove('placeholder'); | ||
} else if (e.type == "blur" && s) { | ||
this.settings.addTagOnBlur && this.addTags(s, true).length; | ||
} else { | ||
// e.target.classList.add('placeholder'); | ||
this.DOM.input.removeAttribute('style'); | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
}, | ||
}, | ||
onKeydown: function onKeydown(e) { | ||
var s = e.target.textContent, | ||
lastTag; | ||
onPaste : function(e){ | ||
var that = this; | ||
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput); | ||
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50); | ||
}, | ||
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); | ||
} else if (e.key == 'Escape') { | ||
this.input.set.call(this); | ||
e.target.blur(); | ||
} else if (e.key == 'Enter') { | ||
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 | ||
this.addTags(this.input.value, true); | ||
} else if (e.key == 'ArrowRight') this.input.autocomplete.set.call(this); | ||
}, | ||
onInput: function onInput(e) { | ||
var value = e.target.textContent.trim(), | ||
showSuggestions = value.length >= this.settings.dropdown.enabled; | ||
onClickScope : function(e){ | ||
if( e.target.tagName == "TAGS" ) | ||
this.DOM.input.focus(); | ||
if( e.target.tagName == "X" ){ | ||
this.removeTag( this.getNodeIndex(e.target.parentNode) ); | ||
if (this.input.value == value) return; | ||
// save the value on the input state object | ||
this.input.value = value; | ||
this.input.normalize.call(this); | ||
this.input.autocomplete.suggest.call(this, ''); // cleanup any possible previous suggestion | ||
if (value.search(this.settings.delimiters) != -1) { | ||
if (this.addTags(value).length) this.input.set.call(this); // clear the input field's value | ||
} else if (this.settings.dropdown.enabled && this.settings.whitelist.length) this.dropdown[showSuggestions ? "show" : "hide"].call(this, value); | ||
}, | ||
onInputIE: function onInputIE(e) { | ||
var _this = this; | ||
// for the "e.target.textContent" to be changed, the browser requires a small delay | ||
setTimeout(function () { | ||
_this.events.callbacks.onInput.call(_this, e); | ||
}); | ||
}, | ||
onPaste: function onPaste(e) {}, | ||
onClickScope: function onClickScope(e) { | ||
if (e.target.tagName == "TAGS") this.DOM.input.focus();else if (e.target.tagName == "X") { | ||
this.removeTag(e.target.parentNode); | ||
} | ||
} | ||
@@ -263,145 +309,300 @@ } | ||
/** | ||
* Build tags suggestions using HTML datalist | ||
* @return {[type]} [description] | ||
* input bridge for accessing & setting | ||
* @type {Object} | ||
*/ | ||
buildDataList : function(){ | ||
var OPTIONS = "", | ||
i, | ||
datalist = document.createElement('datalist'); | ||
input: { | ||
value: '', | ||
set: function set() { | ||
var s = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; | ||
datalist.id = 'tagifySuggestions' + this.id; | ||
datalist.innerHTML = "<label> \ | ||
select from the list: \ | ||
<select> \ | ||
<option value=''></option> \ | ||
[OPTIONS] \ | ||
</select> \ | ||
</label>"; | ||
this.input.value = this.DOM.input.innerHTML = s; | ||
for( i=this.settings.whitelist.length; i--; ) | ||
OPTIONS += "<option>"+ this.settings.whitelist[i] +"</option>"; | ||
if (s.length < 2) this.input.autocomplete.suggest.call(this, ''); | ||
}, | ||
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 | ||
// remove any child DOM elements that aren't of type TEXT (like <br>) | ||
normalize: function normalize() { | ||
while (this.DOM.input.firstElementChild) { | ||
this.DOM.input.removeChild(this.DOM.input.firstElementChild); | ||
} | ||
}, | ||
return datalist; | ||
/** | ||
* suggest the rest of the input's value | ||
* @param {String} s [description] | ||
*/ | ||
autocomplete: { | ||
suggest: function suggest(s) { | ||
if (s) this.DOM.input.setAttribute("data-suggest", s.substring(this.input.value.length));else this.DOM.input.removeAttribute("data-suggest"); | ||
}, | ||
set: function set() { | ||
var suggestion = this.DOM.input.getAttribute('data-suggest'); | ||
if (suggestion && this.addTags(this.input.value + suggestion).length) { | ||
this.input.set.call(this); | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
} | ||
}, | ||
getNodeIndex : function( node ){ | ||
getNodeIndex: function getNodeIndex(node) { | ||
var index = 0; | ||
while( (node = node.previousSibling) ) | ||
if (node.nodeType != 3 || !/^\s*$/.test(node.data)) | ||
index++; | ||
return index; | ||
while (node = node.previousSibling) { | ||
if (node.nodeType != 3 || !/^\s*$/.test(node.data)) index++; | ||
}return index; | ||
}, | ||
/** | ||
* Searches if any tags with a certain value exist and mark them | ||
* @param {String / Number} value [description] | ||
* @return {boolean} [found / not found] | ||
* Searches if any tag with a certain value already exis | ||
* @param {String} s [text value to search for] | ||
* @return {boolean} [found / not found] | ||
*/ | ||
markTagByValue : function(value){ | ||
var idx = this.value.filter(function(item){ return value.toLowerCase() === item.toLowerCase() })[0], | ||
tag = this.DOM.scope.querySelectorAll('tag')[idx]; | ||
isTagDuplicate: function isTagDuplicate(s) { | ||
return this.value.some(function (item) { | ||
return s.toLowerCase() === item.value.toLowerCase(); | ||
}); | ||
}, | ||
if( tag ){ | ||
tag.classList.add('tagify--mark'); | ||
setTimeout(function(){ tag.classList.remove('tagify--mark') }, 2000); | ||
return true; | ||
/** | ||
* 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 markTagByValue(value, tagElm) { | ||
var tagsElms, tagsElmsLen; | ||
if (!tagElm) { | ||
tagsElms = this.DOM.scope.querySelectorAll('tag'); | ||
for (tagsElmsLen = tagsElms.length; tagsElmsLen--;) { | ||
if (tagsElms[tagsElmsLen].value.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){ | ||
isTagBlacklisted: function isTagBlacklisted(v) { | ||
v = v.split(' '); | ||
return this.settings.blacklist.filter(function(x){ return v.indexOf(x) != -1 }).length; | ||
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; | ||
isTagWhitelisted: function isTagWhitelisted(v) { | ||
return this.settings.whitelist.some(function (item) { | ||
var value = item.value ? item.value : item; | ||
if (value.toLowerCase() === v.toLowerCase()) return true; | ||
}); | ||
}, | ||
/** | ||
* add a "tag" element to the "tags" component | ||
* @param {String} value [A string of a value or multiple values] | ||
* @return {Array} Array of DOM elements | ||
* @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects] | ||
* @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags] | ||
* @return {Array} Array of DOM elements (tags) | ||
*/ | ||
addTag : function( value ){ | ||
addTags: function addTags(tagsItems, clearInput) { | ||
var that = this, | ||
result; | ||
tagElems = []; | ||
this.DOM.input.removeAttribute('style'); | ||
value = value.trim(); | ||
/** | ||
* 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 | ||
if( !value ) return []; | ||
// no need to continue if "tagsItems" is an Array of Objects | ||
if (isComplex) return result; | ||
// go over each tag and add it (if there were multiple ones) | ||
result = value.split(this.settings.delimiters).filter(function(v){ return !!v }).map(function(v){ | ||
v = v.trim(); | ||
// 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( that.settings.pattern && !(that.settings.pattern.test(v)) ) | ||
return false; | ||
if (matchObj[0]) { | ||
isComplex = true; | ||
result = matchObj; // set the Array (with the found Object) as the new value | ||
} | ||
} | ||
var tagElm = document.createElement('tag'), | ||
isDuplicate = that.markTagByValue(v), | ||
tagAllowed, | ||
tagNotAllowedEventName, | ||
maxTagsExceed = that.value.length >= that.settings.maxTags; | ||
// if the value is a "simple" String, ex: "aaa, bbb, ccc" | ||
if (!isComplex) { | ||
tagsItems = tagsItems.trim(); | ||
if (!tagsItems) return []; | ||
if( isDuplicate ){ | ||
that.trigger('duplicate', v); | ||
if( !that.settings.duplicates ){ | ||
return false; | ||
// 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"; | ||
} | ||
} | ||
tagAllowed = !that.isTagBlacklisted(v) && (!that.settings.enforeWhitelist || that.isTagWhitelisted(v)) && !maxTagsExceed; | ||
// 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 ){ | ||
tagElm.classList.add('tagify--notAllowed'); | ||
setTimeout(function(){ that.removeTag(that.getNodeIndex(tagElm), true) }, 1000); | ||
// 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 ) tagNotAllowedEventName = 'maxTagsExceed'; | ||
else if( that.isTagBlacklisted(v) ) tagNotAllowedEventName = 'blacklisted'; | ||
else if( that.settings.enforeWhitelist && !that.isTagWhitelisted(v) ) tagNotAllowedEventName = 'notWhitelisted'; | ||
if (maxTagsExceed) eventName__error = 'maxTagsExceed';else if (this.isTagBlacklisted(value)) eventName__error = 'blacklisted';else if (this.settings.enforceWhitelist && !this.isTagWhitelisted(value)) eventName__error = 'notWhitelisted'; | ||
that.trigger(tagNotAllowedEventName, {value:v, index:that.value.length}); | ||
this.trigger(eventName__error, { value: value, index: this.value.length }); | ||
return "notAllowed"; | ||
} | ||
// the space below is important - http://stackoverflow.com/a/19668740/104380 | ||
tagElm.innerHTML = "<x></x><div><span title='"+ v +"'>"+ v +" </span></div>"; | ||
that.DOM.scope.insertBefore(tagElm, that.DOM.input.parentNode); | ||
return true; | ||
} | ||
if( tagAllowed ){ | ||
that.value.push(v); | ||
that.update(); | ||
that.trigger('add', {value:v, index:that.value.length}); | ||
/** | ||
* appened (validated) tag to the component's DOM scope | ||
* @return {[type]} [description] | ||
*/ | ||
function appendTag(tagElm) { | ||
this.DOM.scope.insertBefore(tagElm, this.DOM.input); | ||
} | ||
////////////////////// | ||
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({}, { index: that.value.length, tag: tagElm }, tagData)); | ||
tagElems.push(tagElm); | ||
} | ||
} | ||
return tagElm; | ||
}); | ||
return result.filter(function(n){ return n }); | ||
if (tagsItems.length && clearInput) { | ||
this.input.set.call(this); | ||
} | ||
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 createTagElem(tagData) { | ||
var tagElm, | ||
escapedValue = this.escapeHtml(tagData.value), | ||
template = '<tag>\n <x></x><div><span title=\'' + escapedValue + '\'>' + escapedValue + '</span></div>\n </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]); | ||
} | ||
} | ||
tagElm = this.parseHTML(template); | ||
// add any attribuets, if exists | ||
addTagAttrs(tagElm, tagData); | ||
return tagElm; | ||
}, | ||
/** | ||
* Removes a tag | ||
* @param {Number} idx [tag index to be removed] | ||
* @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] | ||
* @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( idx, silent ){ | ||
var tagElm = this.DOM.scope.children[idx]; | ||
if( !tagElm) return; | ||
removeTag: function removeTag(tagElm, silent) { | ||
var tagData, | ||
tagIdx = this.getNodeIndex(tagElm); | ||
if (!tagElm) return; | ||
tagElm.style.width = parseFloat(window.getComputedStyle(tagElm).width) + 'px'; | ||
@@ -412,30 +613,236 @@ document.body.clientTop; // force repaint for the width to take affect before the "hide" class below | ||
// manual timeout (hack, since transitionend cannot be used because of hover) | ||
setTimeout(function(){ | ||
setTimeout(function () { | ||
tagElm.parentNode.removeChild(tagElm); | ||
}, 400); | ||
if( !silent ){ | ||
this.value.splice(idx, 1); // remove the tag from the data object | ||
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', {value:tagElm.textContent.trim(), index:idx}); | ||
this.trigger('remove', this.extend({}, { index: tagIdx, tag: tagElm }, tagData)); | ||
} | ||
}, | ||
removeAllTags : function(){ | ||
removeAllTags: function removeAllTags() { | ||
this.value = []; | ||
this.update(); | ||
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function(elm){ | ||
elm.parentNode.removeChild(elm); | ||
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function (elm) { | ||
return elm.parentNode.removeChild(elm); | ||
}); | ||
}, | ||
/** | ||
* update the origianl (hidden) input field's value | ||
*/ | ||
update : function(){ | ||
this.DOM.originalInput.value = this.value.join(','); | ||
update: function update() { | ||
var tagsAsString = this.value.map(function (v) { | ||
return v.value; | ||
}).join(','); | ||
this.DOM.originalInput.value = tagsAsString; | ||
}, | ||
/** | ||
* Dropdown controller | ||
* @type {Object} | ||
*/ | ||
dropdown: { | ||
init: function init() { | ||
this.DOM.dropdown = this.dropdown.build.call(this); | ||
}, | ||
build: function build() { | ||
var className = ('tagify__dropdown ' + this.settings.dropdown.classname).trim(), | ||
template = '<div class="' + className + '"></div>'; | ||
return this.parseHTML(template); | ||
}, | ||
show: function show(value) { | ||
var listItems = this.dropdown.filterListItems.call(this, value), | ||
listHTML = this.dropdown.createListHTML(listItems); | ||
if (listItems.length && this.settings.autoComplete) this.input.autocomplete.suggest.call(this, listItems[0].value); | ||
if (!listHTML || listItems.length < 2) { | ||
this.dropdown.hide.call(this); | ||
return; | ||
} | ||
this.DOM.dropdown.innerHTML = listHTML; | ||
this.dropdown.position.call(this); | ||
// if the dropdown has yet to be appended to the document, | ||
// append the dropdown to the body element & handle events | ||
if (!this.DOM.dropdown.parentNode != document.body) { | ||
document.body.appendChild(this.DOM.dropdown); | ||
this.events.binding.call(this, false); // unbind the main events | ||
this.dropdown.events.binding.call(this); | ||
} | ||
}, | ||
hide: function hide() { | ||
if (!this.DOM.dropdown || this.DOM.dropdown.parentNode != document.body) return; | ||
document.body.removeChild(this.DOM.dropdown); | ||
window.removeEventListener('resize', this.dropdown.position); | ||
this.dropdown.events.binding.call(this, false); // unbind all events | ||
this.events.binding.call(this); // re-bind main events | ||
}, | ||
position: function position() { | ||
var rect = this.DOM.scope.getBoundingClientRect(); | ||
this.DOM.dropdown.style.cssText = "left: " + rect.left + window.pageXOffset + "px; \ | ||
top: " + (rect.top + rect.height - 1 + window.pageYOffset) + "px; \ | ||
width: " + rect.width + "px"; | ||
}, | ||
/** | ||
* @type {Object} | ||
*/ | ||
events: { | ||
/** | ||
* Events should only be binded when the dropdown is rendered and removed when isn't | ||
* @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events] | ||
* @return {[type]} [description] | ||
*/ | ||
binding: function binding() { | ||
var bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; | ||
// references to the ".bind()" methods must be saved so they could be unbinded later | ||
var _CBR = this.listeners.dropdown = this.listeners.dropdown || { | ||
position: this.dropdown.position.bind(this), | ||
onKeyDown: this.dropdown.events.callbacks.onKeyDown.bind(this), | ||
onMouseOver: this.dropdown.events.callbacks.onMouseOver.bind(this), | ||
onClick: this.dropdown.events.callbacks.onClick.bind(this) | ||
}, | ||
action = bindUnbind ? 'addEventListener' : 'removeEventListener'; | ||
window[action]('resize', _CBR.position); | ||
window[action]('keydown', _CBR.onKeyDown); | ||
window[action]('click', _CBR.onClick); | ||
this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); | ||
// this.DOM.dropdown[action]('click', _CBR.onClick); | ||
}, | ||
callbacks: { | ||
onKeyDown: function onKeyDown(e) { | ||
var selectedElm = this.DOM.dropdown.querySelectorAll("[class$='--active']")[0], | ||
newValue = ""; | ||
switch (e.key) { | ||
case 'ArrowDown': | ||
case 'ArrowUp': | ||
case 'Down': // >IE11 | ||
case 'Up': | ||
// >IE11 | ||
e.preventDefault(); | ||
if (selectedElm) selectedElm = selectedElm[e.key == 'ArrowUp' || e.key == 'Up' ? "previousElementSibling" : "nextElementSibling"]; | ||
// if no element was found, loop | ||
else selectedElm = this.DOM.dropdown.children[e.key == 'ArrowUp' || e.key == 'Up' ? this.DOM.dropdown.children.length - 1 : 0]; | ||
this.dropdown.highlightOption.call(this, selectedElm); | ||
break; | ||
case 'Escape': | ||
this.dropdown.hide.call(this); | ||
break; | ||
case 'Enter': | ||
e.preventDefault(); | ||
newValue = selectedElm ? selectedElm.textContent : this.input.value; | ||
this.addTags(newValue, true); | ||
this.dropdown.hide.call(this); | ||
break; | ||
case 'ArrowRight': | ||
this.input.autocomplete.set.call(this); | ||
break; | ||
} | ||
}, | ||
onMouseOver: function onMouseOver(e) { | ||
// event delegation check | ||
if (e.target.className.includes('__item')) this.dropdown.highlightOption.call(this, e.target); | ||
}, | ||
onClick: function onClick(e) { | ||
if (e.target.className.includes('tagify__dropdown__item')) { | ||
this.input.set.call(this); | ||
this.addTags(e.target.textContent); | ||
} | ||
// clicked outside the dropdown, so just close it | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
}, | ||
highlightOption: function highlightOption(elm) { | ||
if (!elm) return; | ||
var className = "tagify__dropdown__item--active"; | ||
// for IE support, which doesn't allow "forEach" on "NodeList" Objects | ||
[].forEach.call(this.DOM.dropdown.querySelectorAll("[class$='--active']"), function (activeElm) { | ||
activeElm.classList.remove(className); | ||
}); | ||
// this.DOM.dropdown.querySelectorAll("[class$='--active']").forEach(activeElm => activeElm.classList.remove(className)); | ||
elm.classList.add(className); | ||
}, | ||
/** | ||
* returns an HTML string of the suggestions' list items | ||
* @return {[type]} [description] | ||
*/ | ||
filterListItems: function filterListItems(value) { | ||
if (!value) return ""; | ||
var list = [], | ||
whitelist = this.settings.whitelist, | ||
suggestionsCount = this.settings.dropdown.maxItems || Infinity, | ||
whitelistItem, | ||
valueIsInWhitelist, | ||
i = 0; | ||
for (; i < whitelist.length; i++) { | ||
whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value: whitelist[i] }, //normalize value as an Object | ||
valueIsInWhitelist = whitelistItem.value.toLowerCase().replace(/\s/g, '').indexOf(value.toLowerCase().replace(/\s/g, '')) == 0; // for fuzzy-search use ">=" | ||
// match for the value within each "whitelist" item | ||
if (valueIsInWhitelist && !this.isTagDuplicate(whitelistItem.value) && suggestionsCount--) list.push(whitelistItem); | ||
if (suggestionsCount == 0) break; | ||
} | ||
return list; | ||
}, | ||
/** | ||
* Creates the dropdown items' HTML | ||
* @param {Array} list [Array of Objects] | ||
* @return {String} | ||
*/ | ||
createListHTML: function createListHTML(list) { | ||
function getItem(item) { | ||
return '<div class=\'tagify__dropdown__item ' + (item.class ? item.class : "") + '\' ' + getAttributesString(item) + '>' + item.value + '</div>'; | ||
}; | ||
// for a certain Tag element, add attributes. | ||
function getAttributesString(item) { | ||
var i, | ||
keys = Object.keys(item), | ||
s = ""; | ||
for (i = keys.length; i--;) { | ||
var propName = keys[i]; | ||
if (propName != 'class' && !item.hasOwnProperty(propName)) return; | ||
s += " " + propName + (item[propName] ? "=" + item[propName] : ""); | ||
} | ||
return s; | ||
} | ||
return list.map(getItem).join(""); | ||
} | ||
} | ||
} | ||
}; | ||
return Tagify; | ||
})); |
@@ -1,1 +0,1 @@ | ||
!function(t,e){"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?module.exports=e():t.Tagify=e()}(this,function(){function t(t,e){if(!t)return console.warn("Tagify: ","invalid input element ",t),this;if(this.settings=this.extend({},e,this.DEFAULTS),this.settings.readonly=t.hasAttribute("readonly"),t.pattern)try{this.settings.pattern=new RegExp(t.pattern)}catch(t){}if(e&&e.delimiters)try{this.settings.delimiters=new RegExp("["+e.delimiters+"]")}catch(t){}this.id=Math.random().toString(36).substr(2,9),this.value=[],this.DOM={},this.extend(this,new this.EventDispatcher),this.build(t),this.events()}return t.prototype={DEFAULTS:{delimiters:",",pattern:"",callbacks:{},duplicates:!1,enforeWhitelist:!1,autocomplete:!0,whitelist:[],blacklist:[],maxTags:1/0,suggestionsMinChars:2},build:function(t){var e=t.value,i='<div><input list="tagifySuggestions'+this.id+'" class="placeholder"/><span>'+t.placeholder+"</span></div>";this.DOM.originalInput=t,this.DOM.scope=document.createElement("tags"),this.DOM.scope.innerHTML=i,this.DOM.input=this.DOM.scope.querySelector("input"),this.settings.readonly&&this.DOM.scope.classList.add("readonly"),t.parentNode.insertBefore(this.DOM.scope,t),this.DOM.scope.appendChild(t),this.settings.autocomplete&&this.settings.whitelist.length&&(this.DOM.datalist=this.buildDataList()),e&&this.addTag(e).forEach(function(t){t&&t.classList.add("tagify--noAnim")})},destroy:function(){this.DOM.scope.parentNode.appendChild(this.DOM.originalInput),this.DOM.scope.parentNode.removeChild(this.DOM.scope)},extend:function(t,e,i){function n(t,e){for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i])}return t instanceof Object||(t={}),i?(n(t,i),n(t,e)):n(t,e),t},EventDispatcher:function(){var t=document.createTextNode("");this.off=t.removeEventListener.bind(t),this.on=t.addEventListener.bind(t),this.trigger=function(e,i){var n;if(e)if(this.isJQueryPlugin)$(this.DOM.originalInput).triggerHandler(e,[i]);else{try{n=new CustomEvent(e,{detail:i})}catch(t){(n=document.createEvent("Event")).initEvent("toggle",!1,!1)}t.dispatchEvent(n)}}},events:function(){var t=this,e={paste:["onPaste","input"],focus:["onFocusBlur","input"],blur:["onFocusBlur","input"],input:["onInput","input"],keydown:["onKeydown","input"],click:["onClickScope","scope"]},i=["add","remove","duplicate","maxTagsExceed","blacklisted","notWhitelisted"];for(var n in e)this.DOM[e[n][1]].addEventListener(n,this.callbacks[e[n][0]].bind(this));i.forEach(function(e){t.on(e,t.settings.callbacks[e])}),this.isJQueryPlugin&&$(this.DOM.originalInput).on("tagify.removeAllTags",this.removeAllTags.bind(this))},callbacks:{onFocusBlur:function(t){var e=t.target.value.trim();"focus"==t.type?t.target.className="input":"blur"==t.type&&e?this.addTag(e).length&&(t.target.value=""):(t.target.className="input placeholder",this.DOM.input.removeAttribute("style"))},onKeydown:function(t){var e=t.target.value,i=this;if("Backspace"!=t.key||""!=e&&8203!=e.charCodeAt(0)||this.removeTag(this.DOM.scope.querySelectorAll("tag:not(.tagify--hide)").length-1),"Escape"==t.key&&(t.target.value="",t.target.blur()),"Enter"==t.key)return t.preventDefault(),this.addTag(e).length&&(t.target.value=""),!1;this.noneDatalistInput&&clearTimeout(this.noneDatalistInput),this.noneDatalistInput=setTimeout(function(){i.noneDatalistInput=null},50)},onInput:function(t){var e,i=t.target.value,n=(i[i.length-1],!this.noneDatalistInput&&i.length>1),s=i.length>=this.settings.suggestionsMinChars;t.target.style.width=7*(t.target.value.length+1)+"px",-1!=i.slice().search(this.settings.delimiters)||n?this.addTag(i).length&&(t.target.value=""):this.settings.autocomplete&&this.settings.whitelist.length&&(e=this.DOM.input.parentNode.contains(this.DOM.datalist),!s&&e?this.DOM.input.parentNode.removeChild(this.DOM.datalist):s&&!e&&this.DOM.input.parentNode.appendChild(this.DOM.datalist))},onPaste:function(t){var e=this;this.noneDatalistInput&&clearTimeout(this.noneDatalistInput),this.noneDatalistInput=setTimeout(function(){e.noneDatalistInput=null},50)},onClickScope:function(t){"TAGS"==t.target.tagName&&this.DOM.input.focus(),"X"==t.target.tagName&&this.removeTag(this.getNodeIndex(t.target.parentNode))}},buildDataList:function(){var t,e="",i=document.createElement("datalist");for(i.id="tagifySuggestions"+this.id,i.innerHTML="<label> select from the list: <select> <option value=''></option> [OPTIONS] </select> </label>",t=this.settings.whitelist.length;t--;)e+="<option>"+this.settings.whitelist[t]+"</option>";return i.innerHTML=i.innerHTML.replace("[OPTIONS]",e),i},getNodeIndex:function(t){for(var e=0;t=t.previousSibling;)3==t.nodeType&&/^\s*$/.test(t.data)||e++;return e},markTagByValue:function(t){var e=this.value.filter(function(e){return t.toLowerCase()===e.toLowerCase()})[0],i=this.DOM.scope.querySelectorAll("tag")[e];return!!i&&(i.classList.add("tagify--mark"),setTimeout(function(){i.classList.remove("tagify--mark")},2e3),!0)},isTagBlacklisted:function(t){return t=t.split(" "),this.settings.blacklist.filter(function(e){return-1!=t.indexOf(e)}).length},isTagWhitelisted:function(t){return-1!=this.settings.whitelist.indexOf(t)},addTag:function(t){var e=this;return this.DOM.input.removeAttribute("style"),(t=t.trim())?t.split(this.settings.delimiters).filter(function(t){return!!t}).map(function(t){if(t=t.trim(),e.settings.pattern&&!e.settings.pattern.test(t))return!1;var i,n,s=document.createElement("tag"),a=e.markTagByValue(t),o=e.value.length>=e.settings.maxTags;return!(a&&(e.trigger("duplicate",t),!e.settings.duplicates))&&((i=!e.isTagBlacklisted(t)&&(!e.settings.enforeWhitelist||e.isTagWhitelisted(t))&&!o)||(s.classList.add("tagify--notAllowed"),setTimeout(function(){e.removeTag(e.getNodeIndex(s),!0)},1e3),o?n="maxTagsExceed":e.isTagBlacklisted(t)?n="blacklisted":e.settings.enforeWhitelist&&!e.isTagWhitelisted(t)&&(n="notWhitelisted"),e.trigger(n,{value:t,index:e.value.length})),s.innerHTML="<x></x><div><span title='"+t+"'>"+t+" </span></div>",e.DOM.scope.insertBefore(s,e.DOM.input.parentNode),i&&(e.value.push(t),e.update(),e.trigger("add",{value:t,index:e.value.length})),s)}).filter(function(t){return t}):[]},removeTag:function(t,e){var i=this.DOM.scope.children[t];i&&(i.style.width=parseFloat(window.getComputedStyle(i).width)+"px",document.body.clientTop,i.classList.add("tagify--hide"),setTimeout(function(){i.parentNode.removeChild(i)},400),e||(this.value.splice(t,1),this.update(),this.trigger("remove",{value:i.textContent.trim(),index:t})))},removeAllTags:function(){this.value=[],this.update(),Array.prototype.slice.call(this.DOM.scope.querySelectorAll("tag")).forEach(function(t){t.parentNode.removeChild(t)})},update:function(){this.DOM.originalInput.value=this.value.join(",")}},t}); | ||
!function(t,e){"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?module.exports=e():t.Tagify=e()}(this,function(){"use strict";function t(t,e){if(!t)return console.warn("Tagify: ","invalid input element ",t),this;if(this.settings=this.extend({},this.DEFAULTS,e),this.settings.readonly=t.hasAttribute("readonly"),isIE&&(this.settings.autoComplete=!1),t.pattern)try{this.settings.pattern=new RegExp(t.pattern)}catch(t){}if(this.settings&&this.settings.delimiters)try{this.settings.delimiters=new RegExp("["+this.settings.delimiters+"]","g")}catch(t){}this.id=Math.random().toString(36).substr(2,9),this.value=[],this.listeners={},this.DOM={},this.extend(this,new this.EventDispatcher(this)),this.build(t),this.events.customBinding.call(this),this.events.binding.call(this)}return t.prototype={isIE:window.document.documentMode,DEFAULTS:{delimiters:",",pattern:null,maxTags:1/0,callbacks:{},addTagOnBlur:!0,duplicates:!1,whitelist:[],blacklist:[],enforceWhitelist:!1,autoComplete:!0,dropdown:{classname:"",enabled:2,maxItems:10}},customEventsList:["add","remove","duplicate","maxTagsExceed","blacklisted","notWhitelisted"],parseHTML:function(t){return(new DOMParser).parseFromString(t.trim(),"text/html").body.firstElementChild},escapeHtml:function(t){var e=document.createTextNode(t),i=document.createElement("p");return i.appendChild(e),i.innerHTML},build:function(t){var e=t.value,i='\n <tags class="tagify '+t.className+" "+(this.settings.readonly?"readonly":"")+'">\n <div contenteditable data-placeholder="'+t.placeholder+'" class="tagify--input"></div>\n </tags>';this.DOM.originalInput=t,this.DOM.scope=this.parseHTML(i),this.DOM.input=this.DOM.scope.querySelector("[contenteditable]"),t.parentNode.insertBefore(this.DOM.scope,t),this.settings.dropdown.enabled&&this.settings.whitelist.length&&this.dropdown.init.call(this),e&&this.addTags(e).forEach(function(t){t&&t.classList.add("tagify--noAnim")})},destroy:function(){this.DOM.scope.parentNode.removeChild(this.DOM.scope)},extend:function(t,e,i){function n(t){var e=Object.prototype.toString.call(t);return t===Object(t)&&"[object Array]"!=e&&"[object Function]"!=e}function s(t,e){for(var i in e)e.hasOwnProperty(i)&&(n(e[i])?n(t[i])?s(t[i],e[i]):t[i]=Object.assign({},e[i]):t[i]=e[i])}return t instanceof Object||(t={}),s(t,e),i&&s(t,i),t},EventDispatcher:function(t){var e=document.createTextNode("");this.off=function(t,i){return i&&e.removeEventListener.call(e,t,i),this},this.on=function(t,i){return i&&e.addEventListener.call(e,t,i),this},this.trigger=function(i,n){var s;if(i)if(t.settings.isJQueryPlugin)$(t.DOM.originalInput).triggerHandler(i,[n]);else{try{s=new CustomEvent(i,{detail:n})}catch(t){console.warn(t)}e.dispatchEvent(s)}}},events:{customBinding:function(){var t=this;this.customEventsList.forEach(function(e){t.on(e,t.settings.callbacks[e])})},binding:function(){var t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],e=this.events.callbacks,i=this.listeners.main=this.listeners.main||{paste:["input",e.onPaste.bind(this)],focus:["input",e.onFocusBlur.bind(this)],blur:["input",e.onFocusBlur.bind(this)],keydown:["input",e.onKeydown.bind(this)],click:["scope",e.onClickScope.bind(this)]},n=t?"addEventListener":"removeEventListener";for(var s in i)this.DOM[i[s][0]][n](s,i[s][1]);t&&(this.DOM.input.addEventListener(this.isIE?"keydown":"input",e[this.isIE?"onInputIE":"onInput"].bind(this)),this.settings.isJQueryPlugin&&$(this.DOM.originalInput).on("tagify.removeAllTags",this.removeAllTags.bind(this)))},callbacks:{onFocusBlur:function(t){var e=t.target.textContent.trim();"focus"==t.type||("blur"==t.type&&e?this.settings.addTagOnBlur&&this.addTags(e,!0).length:(this.DOM.input.removeAttribute("style"),this.dropdown.hide.call(this)))},onKeydown:function(t){var e,i=t.target.textContent;"Backspace"!=t.key||""!=i&&8203!=i.charCodeAt(0)?"Escape"==t.key?(this.input.set.call(this),t.target.blur()):"Enter"==t.key?(t.preventDefault(),this.addTags(this.input.value,!0)):"ArrowRight"==t.key&&this.input.autocomplete.set.call(this):(e=(e=this.DOM.scope.querySelectorAll("tag:not(.tagify--hide)"))[e.length-1],this.removeTag(e))},onInput:function(t){var e=t.target.textContent.trim(),i=e.length>=this.settings.dropdown.enabled;this.input.value!=e&&(this.input.value=e,this.input.normalize.call(this),this.input.autocomplete.suggest.call(this,""),-1!=e.search(this.settings.delimiters)?this.addTags(e).length&&this.input.set.call(this):this.settings.dropdown.enabled&&this.settings.whitelist.length&&this.dropdown[i?"show":"hide"].call(this,e))},onInputIE:function(t){var e=this;setTimeout(function(){e.events.callbacks.onInput.call(e,t)})},onPaste:function(t){},onClickScope:function(t){"TAGS"==t.target.tagName?this.DOM.input.focus():"X"==t.target.tagName&&this.removeTag(t.target.parentNode)}}},input:{value:"",set:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"";this.input.value=this.DOM.input.innerHTML=t,t.length<2&&this.input.autocomplete.suggest.call(this,"")},normalize:function(){for(;this.DOM.input.firstElementChild;)this.DOM.input.removeChild(this.DOM.input.firstElementChild)},autocomplete:{suggest:function(t){t?this.DOM.input.setAttribute("data-suggest",t.substring(this.input.value.length)):this.DOM.input.removeAttribute("data-suggest")},set:function(){var t=this.DOM.input.getAttribute("data-suggest");t&&this.addTags(this.input.value+t).length&&(this.input.set.call(this),this.dropdown.hide.call(this))}}},getNodeIndex:function(t){for(var e=0;t=t.previousSibling;)3==t.nodeType&&/^\s*$/.test(t.data)||e++;return e},isTagDuplicate:function(t){return this.value.some(function(e){return t.toLowerCase()===e.value.toLowerCase()})},markTagByValue:function(t,e){var i,n;if(!e)for(n=(i=this.DOM.scope.querySelectorAll("tag")).length;n--;)i[n].value.toLowerCase().includes(t.toLowerCase())&&(e=i[n]);return!!e&&(e.classList.add("tagify--mark"),setTimeout(function(){e.classList.remove("tagify--mark")},2e3),!0)},isTagBlacklisted:function(t){return t=t.split(" "),this.settings.blacklist.filter(function(e){return-1!=t.indexOf(e)}).length},isTagWhitelisted:function(t){return this.settings.whitelist.some(function(e){if((e.value?e.value:e).toLowerCase()===t.toLowerCase())return!0})},addTags:function(t,e){function i(t){var e,i=t.value.trim(),n=this.value.length>=this.settings.maxTags;return i?this.settings.pattern&&!this.settings.pattern.test(i)?"pattern":this.isTagDuplicate(i)&&(this.trigger("duplicate",i),!this.settings.duplicates)?"duplicate":!!(!this.isTagBlacklisted(i)&&(!this.settings.enforceWhitelist||this.isTagWhitelisted(i))&&!n)||(t.class=t.class?t.class+" tagify--notAllowed":"tagify--notAllowed",n?e="maxTagsExceed":this.isTagBlacklisted(i)?e="blacklisted":this.settings.enforceWhitelist&&!this.isTagWhitelisted(i)&&(e="notWhitelisted"),this.trigger(e,{value:i,index:this.value.length}),"notAllowed"):"empty"}function n(t){this.DOM.scope.insertBefore(t,this.DOM.input)}var s=this,o=[];return this.DOM.input.removeAttribute("style"),(t=function(t){var e=this.settings.whitelist[0]instanceof Object,i=t instanceof Array&&"value"in t[0],n=t;if(i)return n;if(!i&&"string"==typeof t&&e){var s=this.settings.whitelist.filter(function(e){return e.value.toLowerCase()==t.toLowerCase()});s[0]&&(i=!0,n=s)}if(!i){if(!(t=t.trim()))return[];n=t.split(this.settings.delimiters).map(function(t){return{value:t.trim()}})}return n.filter(function(t){return t})}.call(this,t)).forEach(function(t){var e=i.call(s,t);if(!0===e||"notAllowed"==e){var a=s.createTagElem(t);n.call(s,a),"notAllowed"==e?setTimeout(function(){s.removeTag(a,!0)},1e3):(s.value.push(t),s.update(),s.trigger("add",s.extend({},{index:s.value.length,tag:a},t)),o.push(a))}}),t.length&&e&&this.input.set.call(this),o},createTagElem:function(t){var e,i=this.escapeHtml(t.value),n="<tag>\n <x></x><div><span title='"+i+"'>"+i+"</span></div>\n </tag>";return e=this.parseHTML(n),function(t,e){var i,n=Object.keys(e);for(i=n.length;i--;){var s=n[i];if(!e.hasOwnProperty(s))return;t.setAttribute(s,e[s])}}(e,t),e},removeTag:function(t,e){var i,n=this.getNodeIndex(t);t&&(t.style.width=parseFloat(window.getComputedStyle(t).width)+"px",document.body.clientTop,t.classList.add("tagify--hide"),setTimeout(function(){t.parentNode.removeChild(t)},400),e||(i=this.value.splice(n,1)[0],this.update(),this.trigger("remove",this.extend({},{index:n,tag:t},i))))},removeAllTags:function(){this.value=[],this.update(),Array.prototype.slice.call(this.DOM.scope.querySelectorAll("tag")).forEach(function(t){return t.parentNode.removeChild(t)})},update:function(){var t=this.value.map(function(t){return t.value}).join(",");this.DOM.originalInput.value=t},dropdown:{init:function(){this.DOM.dropdown=this.dropdown.build.call(this)},build:function(){var t='<div class="'+("tagify__dropdown "+this.settings.dropdown.classname).trim()+'"></div>';return this.parseHTML(t)},show:function(t){var e=this.dropdown.filterListItems.call(this,t),i=this.dropdown.createListHTML(e);e.length&&this.settings.autoComplete&&this.input.autocomplete.suggest.call(this,e[0].value),!i||e.length<2?this.dropdown.hide.call(this):(this.DOM.dropdown.innerHTML=i,this.dropdown.position.call(this),!this.DOM.dropdown.parentNode!=document.body&&(document.body.appendChild(this.DOM.dropdown),this.events.binding.call(this,!1),this.dropdown.events.binding.call(this)))},hide:function(){this.DOM.dropdown&&this.DOM.dropdown.parentNode==document.body&&(document.body.removeChild(this.DOM.dropdown),window.removeEventListener("resize",this.dropdown.position),this.dropdown.events.binding.call(this,!1),this.events.binding.call(this))},position:function(){var t=this.DOM.scope.getBoundingClientRect();this.DOM.dropdown.style.cssText="left: "+t.left+window.pageXOffset+"px; top: "+(t.top+t.height-1+window.pageYOffset)+"px; width: "+t.width+"px"},events:{binding:function(){var t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],e=this.listeners.dropdown=this.listeners.dropdown||{position:this.dropdown.position.bind(this),onKeyDown:this.dropdown.events.callbacks.onKeyDown.bind(this),onMouseOver:this.dropdown.events.callbacks.onMouseOver.bind(this),onClick:this.dropdown.events.callbacks.onClick.bind(this)},i=t?"addEventListener":"removeEventListener";window[i]("resize",e.position),window[i]("keydown",e.onKeyDown),window[i]("click",e.onClick),this.DOM.dropdown[i]("mouseover",e.onMouseOver)},callbacks:{onKeyDown:function(t){var e=this.DOM.dropdown.querySelectorAll("[class$='--active']")[0],i="";switch(t.key){case"ArrowDown":case"ArrowUp":case"Down":case"Up":t.preventDefault(),e=e?e["ArrowUp"==t.key||"Up"==t.key?"previousElementSibling":"nextElementSibling"]:this.DOM.dropdown.children["ArrowUp"==t.key||"Up"==t.key?this.DOM.dropdown.children.length-1:0],this.dropdown.highlightOption.call(this,e);break;case"Escape":this.dropdown.hide.call(this);break;case"Enter":t.preventDefault(),i=e?e.textContent:this.input.value,this.addTags(i,!0),this.dropdown.hide.call(this);break;case"ArrowRight":this.input.autocomplete.set.call(this)}},onMouseOver:function(t){t.target.className.includes("__item")&&this.dropdown.highlightOption.call(this,t.target)},onClick:function(t){t.target.className.includes("tagify__dropdown__item")&&(this.input.set.call(this),this.addTags(t.target.textContent)),this.dropdown.hide.call(this)}}},highlightOption:function(t){if(t){var e="tagify__dropdown__item--active";[].forEach.call(this.DOM.dropdown.querySelectorAll("[class$='--active']"),function(t){t.classList.remove(e)}),t.classList.add(e)}},filterListItems:function(t){if(!t)return"";for(var e,i=[],n=this.settings.whitelist,s=this.settings.dropdown.maxItems||1/0,o=0;o<n.length&&(e=n[o]instanceof Object?n[o]:{value:n[o]},0==e.value.toLowerCase().replace(/\s/g,"").indexOf(t.toLowerCase().replace(/\s/g,""))&&!this.isTagDuplicate(e.value)&&s--&&i.push(e),0!=s);o++);return i},createListHTML:function(t){function e(t){var e,i=Object.keys(t),n="";for(e=i.length;e--;){var s=i[e];if("class"!=s&&!t.hasOwnProperty(s))return;n+=" "+s+(t[s]?"="+t[s]:"")}return n}return t.map(function(t){return"<div class='tagify__dropdown__item "+(t.class?t.class:"")+"' "+e(t)+">"+t.value+"</div>"}).join("")}}},t}); |
112
gulpfile.js
@@ -1,28 +0,6 @@ | ||
var gulp = require('gulp'), | ||
gutil = require('gulp-util'), | ||
watch = require('gulp-watch'), | ||
cache = require('gulp-cached'), | ||
gulp_if = require('gulp-if'), | ||
runSequence = require('run-sequence'), | ||
var gulp = require('gulp'), | ||
$ = require( "gulp-load-plugins" )({ pattern:['*', 'gulp-'] }), | ||
pkg = require('./package.json'); | ||
sourcemaps = require('gulp-sourcemaps'), | ||
//debug = require('gulp-debug'), | ||
sass = require('gulp-sass'), | ||
csso = require('gulp-csso'), | ||
combineMq = require('gulp-combine-mq'), | ||
cssGlobbing = require('gulp-css-globbing'), | ||
autoprefixer = require('gulp-autoprefixer'), | ||
umd = require('gulp-umd'), | ||
uglify = require('gulp-uglify'), | ||
rename = require('gulp-rename'), | ||
concat = require('gulp-concat'), | ||
eslint = require('gulp-eslint'), | ||
replace = require('gulp-replace'), | ||
insert = require('gulp-insert'), | ||
beep = require('beepbeep'), | ||
pkg = require('./package.json'); | ||
var uglifyOptions = { | ||
@@ -105,14 +83,14 @@ compress: { | ||
// just a jQuery wrapper for the vanilla version of this component | ||
$.fn.tagify = function(settings){ | ||
var $input = this, | ||
tagify; | ||
$.fn.tagify = function(settings = {}){ | ||
return this.each(function() { | ||
var $input = $(this), | ||
tagify; | ||
if( $input.data("tagify") ) // don't continue if already "tagified" | ||
return this; | ||
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; | ||
settings.isJQueryPlugin = true; | ||
tagify = new Tagify($input[0], settings); | ||
$input.data("tagify", tagify); | ||
}); | ||
} | ||
@@ -132,10 +110,11 @@ | ||
return gulp.src('src/*.scss') | ||
.pipe(cssGlobbing({ | ||
.pipe($.cssGlobbing({ | ||
extensions: '.scss' | ||
})) | ||
.pipe( | ||
sass().on('error', sass.logError) | ||
$.sass().on('error', $.sass.logError) | ||
) | ||
.pipe(combineMq()) // combine media queries | ||
.pipe( autoprefixer({ browsers:['last 7 versions'] }) ) | ||
// .pipe($.combineMq()) // combine media queries | ||
.pipe($.autoprefixer({ browsers:['last 7 versions'] }) ) | ||
.pipe($.cleanCss()) | ||
.pipe(gulp.dest('./dist')) | ||
@@ -146,12 +125,10 @@ }); | ||
gulp.task('build_js', () => { | ||
gulp.task('build_js', ['lint_js'], () => { | ||
var jsStream = gulp.src('src/tagify.js'); | ||
lint(jsStream); | ||
return gulp.src('src/tagify.js') | ||
.pipe(umd()) | ||
.pipe(insert.prepend(banner)) | ||
.pipe(gulp.dest('./dist/')) | ||
return jsStream | ||
.pipe( $.babel({presets: ['env']}) ) | ||
.pipe( $.umd() ) | ||
.pipe( $.insert.prepend(banner) ) | ||
.pipe( gulp.dest('./dist/') ) | ||
}); | ||
@@ -163,4 +140,5 @@ | ||
return gulp.src('src/tagify.js') | ||
.pipe(insert.wrap(banner + jQueryPluginWrap[0], jQueryPluginWrap[1])) | ||
.pipe(rename('jQuery.tagify.js')) | ||
.pipe($.insert.wrap(banner + jQueryPluginWrap[0], jQueryPluginWrap[1])) | ||
.pipe( $.babel({presets: ['env']}) ) | ||
.pipe($.rename('jQuery.tagify.js')) | ||
.pipe(gulp.dest('./dist/')) | ||
@@ -170,14 +148,13 @@ }); | ||
gulp.task('minify', () => { | ||
gulp.src('dist/tagify.js') | ||
.pipe(uglify()) | ||
.pipe($.uglify()) | ||
.on('error', handleError) | ||
.pipe(rename('tagify.min.js')) | ||
.pipe($.rename('tagify.min.js')) | ||
.pipe(gulp.dest('./dist/')) | ||
return gulp.src('dist/jQuery.tagify.js') | ||
.pipe(uglify()) | ||
.pipe($.uglify()) | ||
.on('error', handleError) | ||
.pipe(rename('jQuery.tagify.min.js')) | ||
.pipe($.rename('jQuery.tagify.min.js')) | ||
.pipe(gulp.dest('./dist/')) | ||
@@ -187,20 +164,23 @@ }); | ||
function handleError(err) { | ||
gutil.log( err.toString() ); | ||
$.util.log( err.toString() ); | ||
this.emit('end'); | ||
} | ||
function lint( stream ){ | ||
return stream | ||
/** | ||
* lints the javscript source code using "eslint" | ||
*/ | ||
gulp.task('lint_js', () => { | ||
return gulp.src('src/tagify.js') | ||
// eslint() attaches the lint output to the eslint property | ||
// of the file object so it can be used by other modules. | ||
.pipe(cache('linting')) | ||
.pipe(eslint(eslint_settings)) | ||
// eslint.format() outputs the lint results to the console. | ||
.pipe($.cached('linting')) | ||
.pipe($.eslint(eslint_settings)) | ||
// $.eslint.format() outputs the lint results to the console. | ||
// Alternatively use eslint.formatEach() (see Docs). | ||
.pipe(eslint.format()) | ||
.pipe($.eslint.format()) | ||
// To have the process exit with an error code (1) on | ||
// lint error, return the stream and pipe to failAfterError last. | ||
.pipe(eslint.failAfterError()) | ||
.on('error', beep); | ||
} | ||
.pipe($.eslint.failAfterError()) | ||
.on('error', $.beepbeep) | ||
}); | ||
@@ -211,3 +191,3 @@ | ||
gulp.watch('./src/*.scss', ['scss']); | ||
gulp.watch('./src/tagify.js').on('change', ()=>{ runSequence('build_js', 'build_jquery_version', 'minify') }); | ||
gulp.watch('./src/tagify.js').on('change', ()=>{ $.runSequence('build_js', 'build_jquery_version', 'minify') }); | ||
}); | ||
@@ -217,3 +197,3 @@ | ||
gulp.task('default', ( done ) => { | ||
runSequence(['build_js', 'scss'], 'build_jquery_version', 'minify', 'watch', done); | ||
$.runSequence(['build_js', 'scss'], 'build_jquery_version', 'minify', 'watch', done); | ||
}); |
{ | ||
"name" : "@yaireo/tagify", | ||
"version" : "1.2.2", | ||
"homepage" : "https://github.com/yairEO/tagify", | ||
"description" : "Want to simply convert an input field into a tags element, in a easy customizable way, with good performance and smallest code footprint? You are in the right place my friend.", | ||
"_npmUser" : { | ||
"name" : "vsync", | ||
"email" : "vsync.design@gmail.com" | ||
"name": "@yaireo/tagify", | ||
"version": "2.0.0", | ||
"homepage": "https://github.com/yairEO/tagify", | ||
"description": "Want to simply convert an input field into a tags element, in a easy customizable way, with good performance and smallest code footprint? You are in the right place my friend.", | ||
"_npmUser": { | ||
"name": "vsync", | ||
"email": "vsync.design@gmail.com" | ||
}, | ||
"author": { | ||
"name" : "Yair Even-Or", | ||
"email" : "vsync.design@gmail.com" | ||
"name": "Yair Even-Or", | ||
"email": "vsync.design@gmail.com" | ||
}, | ||
"main" : "tagify.js", | ||
"repository" : { | ||
"type" : "git", | ||
"url" : "git+https://github.com/yairEO/tagify.git" | ||
"main": "tagify.js", | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/yairEO/tagify.git" | ||
}, | ||
@@ -23,25 +23,27 @@ "bugs": { | ||
"devDependencies": { | ||
"beepbeep" : "^1.2.1", | ||
"gulp" : "^3.9.1", | ||
"gulp-autoprefixer" : "4.0.0", | ||
"gulp-cached" : "^1.1.1", | ||
"gulp-combine-mq" : "^0.4.0", | ||
"gulp-concat" : "^2.6.1", | ||
"gulp-css-globbing" : "^0.1.9", | ||
"gulp-csso" : "^3.0.0", | ||
"gulp-eslint" : "^3.0.1", | ||
"gulp-if" : "^2.0.2", | ||
"gulp-insert" : "^0.5.0", | ||
"gulp-minify-css" : "^1.2.4", | ||
"gulp-rename" : "~1.2.2", | ||
"gulp-replace" : "^0.5.4", | ||
"gulp-sass" : "^3.1.0", | ||
"gulp-sourcemaps" : "^2.6.0", | ||
"babel-core": "^6.26.3", | ||
"babel-preset-env": "^1.7.0", | ||
"beepbeep": "^1.2.1", | ||
"gulp": "^3.9.1", | ||
"gulp-autoprefixer": "4.0.0", | ||
"gulp-babel": "^7.0.1", | ||
"gulp-cached": "^1.1.1", | ||
"gulp-clean-css": "^3.9.4", | ||
"gulp-combine-mq": "^0.4.0", | ||
"gulp-concat": "^2.6.1", | ||
"gulp-css-globbing": "^0.2.2", | ||
"gulp-eslint": "^3.0.1", | ||
"gulp-insert": "^0.5.0", | ||
"gulp-load-plugins": "^1.5.0", | ||
"gulp-rename": "~1.2.2", | ||
"gulp-replace": "^0.5.4", | ||
"gulp-sass": "^3.1.0", | ||
"gulp-sourcemaps": "^2.6.4", | ||
"gulp-uglify": "^3.0.0", | ||
"gulp-umd" : "^0.2.1", | ||
"gulp-util" : "^3.0.8", | ||
"gulp-watch" : "latest", | ||
"path" : "^0.12.7", | ||
"run-sequence" : "^1.2.2" | ||
"gulp-umd": "^0.2.1", | ||
"gulp-util": "^3.0.8", | ||
"gulp-watch": "latest", | ||
"path": "^0.12.7", | ||
"run-sequence": "^1.2.2" | ||
} | ||
} |
224
README.md
@@ -18,25 +18,37 @@ Tagify - lightweight input "tags" script | ||
Want to simply convert an input field into a tags element, in a easy customizable way, | ||
with good performance and smallest code footprint? You are in the right place my friend. | ||
Transforms an input field or a textarea into a *Tags* component, in an easy, customizable way, | ||
with great performance and tiny code footprint. | ||
## [Demo page](https://yaireo.github.io/tagify/) | ||
## [Documentation & Demos](https://yaireo.github.io/tagify) | ||
## Selling points | ||
* supports whitelist (with native suggestions dropdown as-you-type) | ||
* supports blacklists | ||
* JS file is under 150 very readiable lines of code | ||
* JS weights less than ~5kb | ||
* SCSS file is ~2kb of highly readable and flexible code | ||
* JS weights less than ~12kb (less than 900 easily understandale lines of code) | ||
* SCSS file is ~6kb of well-crafted flexible code | ||
* Easily change direction to RTL via the SCSS file only | ||
* No other inputs are used beside the original, and its value is kept in sync | ||
* Easily customized | ||
* Exposed custom events (Add, Remove, Invalid, Duplicate) | ||
* For Internet Explorer 11 use "tagify.polyfills.js" under "/dist" | ||
## What can Tagify do | ||
* Can be applied on input & textarea elements | ||
* Supports whitelist | ||
* Supports blacklists | ||
* Shows suggestions selectbox (flexiable settings & styling) | ||
* Auto-complete input as-you-type (whitelist first match) | ||
* Can paste in multiple values ("tag 1, tag 2, tag 3") | ||
* Tags can be created by Regex delimiter or by pressing the "Enter" key / focusing of the input | ||
* Validate tags by Regex pattern | ||
* Supports read-only mode to the whole componenet or per-tag | ||
* Each tag can have any properties desired (class, data-whatever, readonly...) | ||
* Automatically disallow duplicate tags (vis "settings" object) | ||
* Tags can be created by commas *or* by pressing the "Enter" key | ||
* Tags can be trimmed via `hellip` by giving `max-width` to the `tag` element in your `CSS` | ||
* Easily customized | ||
* Exposed custom events | ||
## building the project | ||
Simply run `gulp` in your terminal, from the project's path (Gulp should be installed first) | ||
## Building the project | ||
Simply run `gulp` in your terminal, from the project's path ([Gulp](https://gulpjs.com) should be installed first). | ||
Source files are this path `/src/` | ||
Output files, which are automatically generated using Gulp, are in `/dist/`; | ||
The rest of the files are most likely irrelevant. | ||
@@ -48,6 +60,6 @@ ## Basic usage | ||
<input name='tags' placeholder='write some tags' value='foo, bar,buzz'> | ||
<textarea name='tags' placeholder='write some tags'>foo, bar,buzz</textarea> | ||
<textarea name='tags' pattern=".{3,}" placeholder='write some tags'>foo, bar,buzz</textarea> | ||
``` | ||
what you need to do to convert that nice input into "tags" is simply select your input/textarea and run `tagify()`: | ||
What you need to do to convert that nice input into "tags" is simply select your input/textarea and run `tagify()`: | ||
@@ -68,8 +80,133 @@ ```javascript | ||
// listen to custom tags' events such as 'add' or 'remove' | ||
tagify1.on('remove', ()=>{ | ||
// listen to custom tags' events such as 'add' or 'remove' (see full list below). | ||
// listeners are chainable | ||
tagify.on('remove', onTagRemoved) | ||
.on('add', onTagAdded); | ||
function onTagRemoved(e){ | ||
console.log(e, e.detail); | ||
}); | ||
// remove listener after first tag removal | ||
tagify.off('remove', onTagRemoved); | ||
} | ||
function onTagAdded(e){ | ||
// do whatever | ||
} | ||
``` | ||
The value of the Tagify component can be accessed like so: | ||
```javascript | ||
var tagify = new Tagify(...); | ||
console.log( tagify.value ) | ||
// [{"value":"tag1"}, {"value":"tag2"}, ...] | ||
``` | ||
If the Tags were added with custom properties, the *value* output might look something like this: | ||
```javascript | ||
tagify.value | ||
// [{ "value":"tag1", "class":"red", "id":1}, ...] | ||
``` | ||
### Tags with properties ([example](https://yaireo.github.io/tagify#section-extra-properties)) | ||
The below example will populate the Tags component with 2 tags, each with specific attributes & values. | ||
the `addTags` method accepts an Array of Objects with **any** key/value, as long as the `value` key is defined. | ||
```html | ||
<input placeholder="add tags"> | ||
```javascript | ||
var allowedTags = [ | ||
{ | ||
"value" : "apple", | ||
"data-id" : 3, | ||
"class" : 'color-green' | ||
}, | ||
{ | ||
"value" : "orange", | ||
"data-id" : 56, | ||
"class" : 'color-orange' | ||
}, | ||
{ | ||
"value" : "passion fruit", | ||
"data-id" : 17, | ||
"class" : 'color-purple' | ||
}, | ||
{ | ||
"value" : "banana", | ||
"data-id" : 12, | ||
"class" : 'color-yellow' | ||
}, | ||
{ | ||
"value" : "paprika", | ||
"data-id" : 25, | ||
"class" : 'color-red' | ||
} | ||
]; | ||
var input = document.querySelector('input'), | ||
tagify = new Tagify(input, | ||
whitelist : allowedTags | ||
); | ||
// Add the first 2 tags from the "allowedTags" Array | ||
tagify.addTags( allowedTags.slice(0,2) ) | ||
``` | ||
The above will prepend a "tags" element before the original input element: | ||
```html | ||
<tags class="tagify"> | ||
<tag readonly="true" class="color-red" data-id="8" value="strawberry"> | ||
<x></x><div><span title="strawberry">strawberry</span></div> | ||
</tag> | ||
<tag readonly="true" class="color-darkblue" data-id="6" value="blueberry"> | ||
<x></x><div><span title="blueberry">blueberry</span></div> | ||
</tag> | ||
<div contenteditable data-placeholder="add tags" class="tagify--input"></div> | ||
</tags> | ||
<input placeholder="add tags"> | ||
``` | ||
### Suggestions selectbox | ||
The suggestions selectbox is shown is a whitelist Array of Strings or Objects was passed in the settings when the Tagify instance was created. | ||
Suggestions list will only be rendered if there were at least two sugegstions found. | ||
Matching suggested values is case-insensetive. | ||
The selectbox dropdown will be appended to the document's "body" element and will be positioned under the element. | ||
Using the keyboard arrows up/down will highlight an option from the list, and hitting the Enter key to select. | ||
It is possible to tweak the selectbox dropdown via 2 settings: | ||
- enabled - this is a numeral value which tells Tagify when to show the suggestions dropdown, when a minimum of N characters were typed. | ||
- maxItems - Limits the number of items the suggestions selectbox will render | ||
```javascript | ||
var input = document.querySelector('input'), | ||
tagify = new Tagify(input, | ||
whitelist : ['aaa', 'aaab', 'aaabb', 'aaabc', 'aaabd', 'aaabe', 'aaac', 'aaacc'], | ||
dropdown : { | ||
classname : "color-blue", | ||
enabled : 3, | ||
maxItems : 5 | ||
} | ||
); | ||
``` | ||
Will render: | ||
```html | ||
<div class="tagify__dropdown" style="left: 993.5px; top: 106.375px; width: 616px;"> | ||
<div class="tagify__dropdown__item" value="aaab">aaab</div> | ||
<div class="tagify__dropdown__item" value="aaabb">aaabb</div> | ||
<div class="tagify__dropdown__item" value="aaabc">aaabc</div> | ||
<div class="tagify__dropdown__item" value="aaabd">aaabd</div> | ||
<div class="tagify__dropdown__item" value="aaabe">aaabe</div> | ||
</div> | ||
``` | ||
### jQuery plugin version (jQuery.tagify.js) | ||
@@ -85,34 +222,3 @@ | ||
Now markup be like: | ||
```html | ||
<tags> | ||
<tag> | ||
<x></x> | ||
<div><span title="css">css</span></div> | ||
</tag> | ||
<tag> | ||
<x></x> | ||
<div><span title="html">html</span></div> | ||
</tag> | ||
<tag> | ||
<x></x> | ||
<div><span title="javascript">javascript</span></div> | ||
</tag> | ||
<div> | ||
<input list="tagsSuggestions3l9nbieyr" class="input placeholder"> | ||
<datalist id="tagsSuggestions3l9nbieyr"> | ||
<label> select from the list: | ||
<select> | ||
<option value=""></option> | ||
<option>foo</option> | ||
<option>bar</option> | ||
</select> | ||
</label> | ||
</datalist><span>write some tags</span> | ||
</div> | ||
<input name="tags" placeholder="write some tags" value="foo, bar,buzz"> | ||
</tags> | ||
``` | ||
## Methods | ||
@@ -122,6 +228,7 @@ | ||
--------------- | -------------------------------------------------------------------------- | ||
destroy | if called, will revert the input back as it was before Tagify was applied | ||
removeAllTags | removes all tags and rests the original input tag's value property | ||
destroy | Reverts the input element back as it was before Tagify was applied | ||
removeAllTags | Removes all tags and resets the original input tag's value property | ||
addTags | Accepts a String (word, single or multiple with a delimiter) or an Array of Objects (see above) | ||
removeTag | Removes a specific tag (argument is the tag DOM element to be removed. see source code.) | ||
## Exposed events | ||
@@ -144,13 +251,16 @@ | ||
------------------- | ---------- | ----------- | -------------------------------------------------------------------------- | ||
delimiters | String | "," | [regex] split tags by any of these delimiters. Example: Space or Coma - ", " | ||
pattern | String | "" | Validate the input by REGEX pattern (can also be applied on the input itself as an attribute) | ||
delimiters | String | "," | [regex] split tags by any of these delimiters. Example: ",| |." | ||
pattern | String | null | Validate input by REGEX pattern (can also be applied on the input itself as an attribute) Ex: /[1-9]/ | ||
duplicates | Boolean | false | (flag) should duplicate tags be allowed or not | ||
enforeWhitelist | Boolean | false | should ONLY use tags allowed in whitelist | ||
autocomplete | Boolean | true | show native suggeestions list, as you type | ||
enforceWhitelist | Boolean | false | should ONLY use tags allowed in whitelist | ||
autocomplete | Boolean | true | tries to autocomplete the input's value while typing (match from whitelist) | ||
whitelist | Array | [] | an array of tags which only they are allowed | ||
blacklist | Array | [] | an array of tags which aren't allowed | ||
addTagOnBlur | Boolean | true | automatically adds the text which was inputed as a tag when blur event happens | ||
callbacks | Object | {} | exposed callbacks object to be triggered on events: 'add' / 'remove' tags | ||
maxTags | Number | Infinity | max number of tags | ||
suggestionsMinChars | Number | 2 | minimum characters to input which shows the sugegstions list | ||
dropdown.enabled | Number | 2 | minimum characters to input which shows the suggestions list dropdown | ||
dropdown.maxItems | Number | 10 | maximum items to show in the suggestions list dropdown | ||
dropdown.classname | String | "" | custom class name for the dropdown suggestions selectbox | ||
@@ -8,14 +8,16 @@ function Tagify( input, settings ){ | ||
this.settings = this.extend({}, settings, this.DEFAULTS); | ||
this.settings = this.extend({}, this.DEFAULTS, settings); | ||
this.settings.readonly = input.hasAttribute('readonly'); // if "readonly" do not include an "input" element inside the Tags component | ||
if( isIE ) | ||
this.settings.autoComplete = false; // IE goes crazy if this isn't false | ||
if( input.pattern ) | ||
try { | ||
this.settings.pattern = new RegExp(input.pattern); | ||
} catch(e){} | ||
try { this.settings.pattern = new RegExp(input.pattern) } | ||
catch(e){} | ||
if( settings && settings.delimiters ){ | ||
try { | ||
this.settings.delimiters = new RegExp("[" + settings.delimiters + "]"); | ||
} catch(e){} | ||
// Convert the "delimiters" setting into a REGEX object | ||
if( this.settings && this.settings.delimiters ){ | ||
try { this.settings.delimiters = new RegExp("[" + this.settings.delimiters + "]", "g") } | ||
catch(e){} | ||
} | ||
@@ -25,48 +27,83 @@ | ||
this.value = []; // An array holding all the (currently used) tags | ||
// events' callbacks references will be stores here, so events could be unbinded | ||
this.listeners = {}; | ||
this.DOM = {}; // Store all relevant DOM elements in an Object | ||
this.extend(this, new this.EventDispatcher()); | ||
this.extend(this, new this.EventDispatcher(this)); | ||
this.build(input); | ||
this.events(); | ||
this.events.customBinding.call(this); | ||
this.events.binding.call(this); | ||
} | ||
Tagify.prototype = { | ||
isIE : window.document.documentMode, | ||
DEFAULTS : { | ||
delimiters : ",", // [regex] split tags by any of these delimiters | ||
pattern : "", // pattern to validate input by | ||
delimiters : ",", // [regex] split tags by any of these delimiters ("null" to cancel) Example: ",| |." | ||
pattern : null, // regex pattern to validate input by. Ex: /[1-9]/ | ||
maxTags : Infinity, // maximum number of tags | ||
callbacks : {}, // exposed callbacks object to be triggered on certain events | ||
addTagOnBlur : true, // flag - automatically adds the text which was inputed as a tag when blur event happens | ||
duplicates : false, // flag - allow tuplicate tags | ||
enforeWhitelist : 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 | ||
enforceWhitelist : false, // flag - should ONLY use tags allowed in whitelist | ||
autoComplete : true, // flag - tries to autocomplete the input's value while typing | ||
dropdown : { | ||
classname : '', | ||
enabled : 2, // minimum input characters needs to be typed for the dropdown to show | ||
maxItems : 10 | ||
} | ||
}, | ||
customEventsList : ['add', 'remove', 'duplicate', 'maxTagsExceed', 'blacklisted', 'notWhitelisted'], | ||
/** | ||
* utility method | ||
* https://stackoverflow.com/a/35385518/104380 | ||
* @param {String} s [HTML string] | ||
* @return {Object} [DOM node] | ||
*/ | ||
parseHTML(s){ | ||
var parser = new DOMParser(), | ||
node = parser.parseFromString(s.trim(), "text/html"); | ||
return node.body.firstElementChild; | ||
}, | ||
// https://stackoverflow.com/a/25396011/104380 | ||
escapeHtml(s){ | ||
var text = document.createTextNode(s); | ||
var p = document.createElement('p'); | ||
p.appendChild(text); | ||
return p.innerHTML; | ||
}, | ||
/** | ||
* builds the HTML of this component | ||
* @param {Object} input [DOM element which would be "transformed" into "Tags"] | ||
*/ | ||
build : function( input ){ | ||
build( input ){ | ||
var that = this, | ||
value = input.value, | ||
inputHTML = '<div><input list="tagifySuggestions'+ this.id +'" class="placeholder"/><span>'+ input.placeholder +'</span></div>'; | ||
template = ` | ||
<tags class="tagify ${input.className} ${this.settings.readonly ? 'readonly' : ''}"> | ||
<div contenteditable data-placeholder="${input.placeholder}" class="tagify--input"></div> | ||
</tags>`; | ||
this.DOM.originalInput = input; | ||
this.DOM.scope = document.createElement('tags'); | ||
this.DOM.scope.innerHTML = inputHTML; | ||
this.DOM.input = this.DOM.scope.querySelector('input'); | ||
if( this.settings.readonly ) | ||
this.DOM.scope.classList.add('readonly') | ||
this.DOM.scope = this.parseHTML(template); | ||
this.DOM.input = this.DOM.scope.querySelector('[contenteditable]'); | ||
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 ) | ||
this.DOM.datalist = this.buildDataList(); | ||
if( this.settings.dropdown.enabled && this.settings.whitelist.length ){ | ||
this.dropdown.init.call(this); | ||
} | ||
// if the original input already had any value (tags) | ||
if( value ) | ||
this.addTag(value).forEach(function(tag){ | ||
this.addTags(value).forEach(tag => { | ||
tag && tag.classList.add('tagify--noAnim'); | ||
@@ -79,4 +116,3 @@ }); | ||
*/ | ||
destroy : function(){ | ||
this.DOM.scope.parentNode.appendChild(this.DOM.originalInput); | ||
destroy(){ | ||
this.DOM.scope.parentNode.removeChild(this.DOM.scope); | ||
@@ -87,18 +123,29 @@ }, | ||
* Merge two objects into a new one | ||
* TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) | ||
*/ | ||
extend : function(o, o1, o2){ | ||
extend(o, o1, o2){ | ||
if( !(o instanceof Object) ) o = {}; | ||
if( o2 ){ | ||
copy(o, o1); | ||
if( o2 ) | ||
copy(o, o2) | ||
copy(o, o1) | ||
} | ||
else | ||
copy(o, o1) | ||
function isObject(obj) { | ||
var type = Object.prototype.toString.call(obj); | ||
return obj === Object(obj) && type != '[object Array]' && type != '[object Function]'; | ||
}; | ||
function copy(a,b){ | ||
// copy o2 to o | ||
for( var key in b ) | ||
if( b.hasOwnProperty(key) ) | ||
a[key] = b[key]; | ||
if( b.hasOwnProperty(key) ){ | ||
if( isObject(b[key]) ){ | ||
if( !isObject(a[key]) ) | ||
a[key] = Object.assign({}, b[key]); | ||
else | ||
copy(a[key], b[key]) | ||
} | ||
else | ||
a[key] = b[key]; | ||
} | ||
} | ||
@@ -112,3 +159,3 @@ | ||
*/ | ||
EventDispatcher : function(){ | ||
EventDispatcher( instance ){ | ||
// Create a DOM EventTarget object | ||
@@ -118,4 +165,14 @@ 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.off = function(name, cb){ | ||
if( cb ) | ||
target.removeEventListener.call(target, name, cb); | ||
return this; | ||
}; | ||
this.on = function(name, cb){ | ||
if( cb ) | ||
target.addEventListener.call(target, name, cb); | ||
return this; | ||
}; | ||
this.trigger = function(eventName, data){ | ||
@@ -125,4 +182,5 @@ var e; | ||
if( this.isJQueryPlugin ) | ||
$(this.DOM.originalInput).triggerHandler(eventName, [data]) | ||
if( instance.settings.isJQueryPlugin ){ | ||
$(instance.DOM.originalInput).triggerHandler(eventName, [data]) | ||
} | ||
else{ | ||
@@ -132,6 +190,3 @@ try { | ||
} | ||
catch(err){ | ||
e = document.createEvent("Event"); | ||
e.initEvent("toggle", false, false); | ||
} | ||
catch(err){ console.warn(err) } | ||
target.dispatchEvent(e); | ||
@@ -145,105 +200,117 @@ } | ||
*/ | ||
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']; | ||
events : { | ||
// bind custom events which were passed in the settings | ||
customBinding(){ | ||
this.customEventsList.forEach(name => { | ||
this.on(name, this.settings.callbacks[name]) | ||
}) | ||
}, | ||
for( var e in events ) | ||
this.DOM[events[e][1]].addEventListener(e, this.callbacks[events[e][0]].bind(this)); | ||
binding( bindUnbind = true ){ | ||
var _CB = this.events.callbacks, | ||
// setup callback references so events could be removed later | ||
_CBR = (this.listeners.main = this.listeners.main || { | ||
paste : ['input', _CB.onPaste.bind(this)], | ||
focus : ['input', _CB.onFocusBlur.bind(this)], | ||
blur : ['input', _CB.onFocusBlur.bind(this)], | ||
keydown : ['input', _CB.onKeydown.bind(this)], | ||
click : ['scope', _CB.onClickScope.bind(this)] | ||
}), | ||
action = bindUnbind ? 'addEventListener' : 'removeEventListener'; | ||
customList.forEach(function(name){ | ||
that.on(name, that.settings.callbacks[name]) | ||
}) | ||
for( var eventName in _CBR ){ | ||
this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]); | ||
} | ||
if( this.isJQueryPlugin ) | ||
$(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)) | ||
}, | ||
if( bindUnbind ){ | ||
// this event should never be unbinded | ||
// IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead.. | ||
this.DOM.input.addEventListener(this.isIE ? "keydown" : "input", _CB[this.isIE ? "onInputIE" : "onInput"].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.addTag(text).length ) | ||
e.target.value = ''; | ||
if( this.settings.isJQueryPlugin ) | ||
$(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this)) | ||
} | ||
else{ | ||
e.target.className = 'input placeholder'; | ||
this.DOM.input.removeAttribute('style'); | ||
} | ||
}, | ||
onKeydown : function(e){ | ||
var s = e.target.value, | ||
that = this; | ||
/** | ||
* DOM events callbacks | ||
*/ | ||
callbacks : { | ||
onFocusBlur(e){ | ||
var s = e.target.textContent.trim(); | ||
if( e.key == "Backspace" && (s == "" || s.charCodeAt(0) == 8203) ){ | ||
this.removeTag( this.DOM.scope.querySelectorAll('tag:not(.tagify--hide)').length - 1 ); | ||
} | ||
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.addTag(s).length ) | ||
e.target.value = ''; | ||
return false; | ||
} | ||
else{ | ||
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput); | ||
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50); | ||
} | ||
}, | ||
if( e.type == "focus" ){ | ||
// e.target.classList.remove('placeholder'); | ||
} | ||
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; | ||
else if( e.type == "blur" && s ){ | ||
this.settings.addTagOnBlur && this.addTags(s, true).length; | ||
} | ||
e.target.style.width = ((e.target.value.length + 1) * 7) + 'px'; | ||
else{ | ||
// e.target.classList.add('placeholder'); | ||
this.DOM.input.removeAttribute('style'); | ||
this.dropdown.hide.call(this); | ||
} | ||
}, | ||
onKeydown(e){ | ||
var s = e.target.textContent, | ||
lastTag; | ||
// if( value.indexOf(',') != -1 || isDatalistInput ){ | ||
if( value.slice().search(this.settings.delimiters) != -1 || isDatalistInput ){ | ||
if( this.addTag(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) | ||
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 ); | ||
} | ||
} | ||
}, | ||
onPaste : function(e){ | ||
var that = this; | ||
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput); | ||
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50); | ||
}, | ||
else if( e.key == 'Escape' ){ | ||
this.input.set.call(this) | ||
e.target.blur(); | ||
} | ||
onClickScope : function(e){ | ||
if( e.target.tagName == "TAGS" ) | ||
this.DOM.input.focus(); | ||
if( e.target.tagName == "X" ){ | ||
this.removeTag( this.getNodeIndex(e.target.parentNode) ); | ||
else if( e.key == 'Enter' ){ | ||
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 | ||
this.addTags(this.input.value, true) | ||
} | ||
else if( e.key == 'ArrowRight' ) | ||
this.input.autocomplete.set.call(this); | ||
}, | ||
onInput(e){ | ||
var value = e.target.textContent.trim(), | ||
showSuggestions = value.length >= this.settings.dropdown.enabled; | ||
if( this.input.value == value ) return; | ||
// save the value on the input state object | ||
this.input.value = value; | ||
this.input.normalize.call(this); | ||
this.input.autocomplete.suggest.call(this, ''); // cleanup any possible previous suggestion | ||
if( value.search(this.settings.delimiters) != -1 ){ | ||
if( this.addTags( value ).length ) | ||
this.input.set.call(this); // clear the input field's value | ||
} | ||
else if( this.settings.dropdown.enabled && this.settings.whitelist.length ) | ||
this.dropdown[showSuggestions ? "show" : "hide"].call(this, value); | ||
}, | ||
onInputIE(e){ | ||
var _this = this; | ||
// for the "e.target.textContent" to be changed, the browser requires a small delay | ||
setTimeout(function(){ | ||
_this.events.callbacks.onInput.call(_this, e) | ||
}) | ||
}, | ||
onPaste(e){ | ||
}, | ||
onClickScope(e){ | ||
if( e.target.tagName == "TAGS" ) | ||
this.DOM.input.focus(); | ||
else if( e.target.tagName == "X" ){ | ||
this.removeTag( e.target.parentNode ); | ||
} | ||
} | ||
@@ -254,30 +321,42 @@ } | ||
/** | ||
* Build tags suggestions using HTML datalist | ||
* @return {[type]} [description] | ||
* input bridge for accessing & setting | ||
* @type {Object} | ||
*/ | ||
buildDataList : function(){ | ||
var OPTIONS = "", | ||
i, | ||
datalist = document.createElement('datalist'); | ||
input : { | ||
value : '', | ||
set(s = ''){ | ||
this.input.value = this.DOM.input.innerHTML = s; | ||
datalist.id = 'tagifySuggestions' + this.id; | ||
datalist.innerHTML = "<label> \ | ||
select from the list: \ | ||
<select> \ | ||
<option value=''></option> \ | ||
[OPTIONS] \ | ||
</select> \ | ||
</label>"; | ||
if( s.length < 2 ) | ||
this.input.autocomplete.suggest.call(this, ''); | ||
}, | ||
for( i=this.settings.whitelist.length; i--; ) | ||
OPTIONS += "<option>"+ this.settings.whitelist[i] +"</option>"; | ||
// remove any child DOM elements that aren't of type TEXT (like <br>) | ||
normalize(){ | ||
while (this.DOM.input.firstElementChild ){ | ||
this.DOM.input.removeChild(this.DOM.input.firstElementChild ); | ||
} | ||
}, | ||
datalist.innerHTML = datalist.innerHTML.replace('[OPTIONS]', OPTIONS); // inject the options string in the right place | ||
/** | ||
* suggest the rest of the input's value | ||
* @param {String} s [description] | ||
*/ | ||
autocomplete : { | ||
suggest(s){ | ||
if( s ) this.DOM.input.setAttribute("data-suggest", s.substring(this.input.value.length)); | ||
else this.DOM.input.removeAttribute("data-suggest"); | ||
}, | ||
set(){ | ||
var suggestion = this.DOM.input.getAttribute('data-suggest'); | ||
// this.DOM.input.insertAdjacentHTML('afterend', datalist); // append the datalist HTML string in the Tags | ||
return datalist; | ||
if( suggestion && this.addTags(this.input.value + suggestion).length ){ | ||
this.input.set.call(this); | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
} | ||
}, | ||
getNodeIndex : function( node ){ | ||
getNodeIndex( node ){ | ||
var index = 0; | ||
@@ -291,16 +370,38 @@ while( (node = node.previousSibling) ) | ||
/** | ||
* Searches if any tags with a certain value exist and mark them | ||
* @param {String / Number} value [description] | ||
* @return {boolean} [found / not found] | ||
* Searches if any tag with a certain value already exis | ||
* @param {String} s [text value to search for] | ||
* @return {boolean} [found / not found] | ||
*/ | ||
markTagByValue : function(value){ | ||
var idx = this.value.filter(function(item){ return value.toLowerCase() === item.toLowerCase() })[0], | ||
tag = this.DOM.scope.querySelectorAll('tag')[idx]; | ||
isTagDuplicate(s){ | ||
return this.value.some(item => s.toLowerCase() === item.value.toLowerCase()); | ||
}, | ||
if( tag ){ | ||
tag.classList.add('tagify--mark'); | ||
setTimeout(function(){ tag.classList.remove('tagify--mark') }, 2000); | ||
/** | ||
* 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(value, tagElm){ | ||
var tagsElms, tagsElmsLen; | ||
if( !tagElm ){ | ||
tagsElms = this.DOM.scope.querySelectorAll('tag'); | ||
for( tagsElmsLen = tagsElms.length; tagsElmsLen--; ){ | ||
if( tagsElms[tagsElmsLen].value.toLowerCase().includes(value.toLowerCase()) ) | ||
tagElm = tagsElms[tagsElmsLen]; | ||
} | ||
} | ||
// check AGAIN if "tagElm" is defined | ||
if( tagElm ){ | ||
tagElm.classList.add('tagify--mark'); | ||
setTimeout(() => { tagElm.classList.remove('tagify--mark') }, 2000); | ||
return true; | ||
} | ||
else{ | ||
} | ||
return false; | ||
@@ -312,5 +413,5 @@ }, | ||
*/ | ||
isTagBlacklisted : function(v){ | ||
isTagBlacklisted(v){ | ||
v = v.split(' '); | ||
return this.settings.blacklist.filter(function(x){ return v.indexOf(x) != -1 }).length; | ||
return this.settings.blacklist.filter(x =>v.indexOf(x) != -1).length; | ||
}, | ||
@@ -321,4 +422,8 @@ | ||
*/ | ||
isTagWhitelisted : function(v){ | ||
return this.settings.whitelist.indexOf(v) != -1; | ||
isTagWhitelisted(v){ | ||
return this.settings.whitelist.some(item => { | ||
var value = item.value ? item.value : item; | ||
if( value.toLowerCase() === v.toLowerCase() ) | ||
return true; | ||
}); | ||
}, | ||
@@ -328,73 +433,181 @@ | ||
* add a "tag" element to the "tags" component | ||
* @param {String} value [A string of a value or multiple values] | ||
* @return {Array} Array of DOM elements | ||
* @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects] | ||
* @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags] | ||
* @return {Array} Array of DOM elements (tags) | ||
*/ | ||
addTag : function( value ){ | ||
addTags( tagsItems, clearInput ){ | ||
var that = this, | ||
result; | ||
tagElems = []; | ||
this.DOM.input.removeAttribute('style'); | ||
value = value.trim(); | ||
/** | ||
* 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 | ||
if( !value ) return []; | ||
// no need to continue if "tagsItems" is an Array of Objects | ||
if( isComplex ) | ||
return result; | ||
// go over each tag and add it (if there were multiple ones) | ||
result = value.split(this.settings.delimiters).filter(function(v){ return !!v }).map(function(v){ | ||
v = v.trim(); | ||
// 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( item => item.value.toLowerCase() == tagsItems.toLowerCase() ) | ||
if( that.settings.pattern && !(that.settings.pattern.test(v)) ) | ||
return false; | ||
if( matchObj[0] ){ | ||
isComplex = true; | ||
result = matchObj; // set the Array (with the found Object) as the new value | ||
} | ||
} | ||
var tagElm = document.createElement('tag'), | ||
isDuplicate = that.markTagByValue(v), | ||
tagAllowed, | ||
tagNotAllowedEventName, | ||
maxTagsExceed = that.value.length >= that.settings.maxTags; | ||
// if the value is a "simple" String, ex: "aaa, bbb, ccc" | ||
if( !isComplex ){ | ||
tagsItems = tagsItems.trim(); | ||
if( !tagsItems ) return []; | ||
if( isDuplicate ){ | ||
that.trigger('duplicate', v); | ||
if( !that.settings.duplicates ){ | ||
return false; | ||
// go over each tag and add it (if there were multiple ones) | ||
result = tagsItems.split(this.settings.delimiters).map(v => ({ value:v.trim() })); | ||
} | ||
return result.filter(n => 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"; | ||
} | ||
} | ||
tagAllowed = !that.isTagBlacklisted(v) && (!that.settings.enforeWhitelist || that.isTagWhitelisted(v)) && !maxTagsExceed; | ||
// 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) | ||
// Check against blacklist & whitelist (if enforced) | ||
if( !tagAllowed ){ | ||
tagElm.classList.add('tagify--notAllowed'); | ||
setTimeout(function(){ that.removeTag(that.getNodeIndex(tagElm), true) }, 1000); | ||
tagData.class = tagData.class ? tagData.class + " tagify--notAllowed" : "tagify--notAllowed"; | ||
// broadcast why the tag was not allowed | ||
if( maxTagsExceed ) tagNotAllowedEventName = 'maxTagsExceed'; | ||
else if( that.isTagBlacklisted(v) ) tagNotAllowedEventName = 'blacklisted'; | ||
else if( that.settings.enforeWhitelist && !that.isTagWhitelisted(v) ) tagNotAllowedEventName = 'notWhitelisted'; | ||
if( maxTagsExceed ) eventName__error = 'maxTagsExceed'; | ||
else if( this.isTagBlacklisted(value) ) eventName__error = 'blacklisted'; | ||
else if( this.settings.enforceWhitelist && !this.isTagWhitelisted(value) ) eventName__error = 'notWhitelisted'; | ||
that.trigger(tagNotAllowedEventName, {value:v, index:that.value.length}); | ||
this.trigger(eventName__error, {value:value, index:this.value.length}); | ||
return "notAllowed"; | ||
} | ||
// the space below is important - http://stackoverflow.com/a/19668740/104380 | ||
tagElm.innerHTML = "<x></x><div><span title='"+ v +"'>"+ v +" </span></div>"; | ||
that.DOM.scope.insertBefore(tagElm, that.DOM.input.parentNode); | ||
return true; | ||
} | ||
if( tagAllowed ){ | ||
that.value.push(v); | ||
that.update(); | ||
that.trigger('add', {value:v, index:that.value.length}); | ||
/** | ||
* appened (validated) tag to the component's DOM scope | ||
* @return {[type]} [description] | ||
*/ | ||
function appendTag(tagElm){ | ||
this.DOM.scope.insertBefore(tagElm, this.DOM.input); | ||
} | ||
////////////////////// | ||
tagsItems = normalizeTags.call(this, tagsItems); | ||
tagsItems.forEach(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(() => { that.removeTag(tagElm, true) }, 1000); | ||
} | ||
else{ | ||
// update state | ||
that.value.push(tagData); | ||
that.update(); | ||
that.trigger('add', that.extend({}, {index:that.value.length, tag:tagElm}, tagData)); | ||
tagElems.push(tagElm); | ||
} | ||
} | ||
}) | ||
return tagElm; | ||
}); | ||
if( tagsItems.length && clearInput ){ | ||
this.input.set.call(this); | ||
} | ||
return result.filter(function(n){ return n }); | ||
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(tagData){ | ||
var tagElm, | ||
escapedValue = this.escapeHtml(tagData.value), | ||
template = `<tag> | ||
<x></x><div><span title='${escapedValue}'>${escapedValue}</span></div> | ||
</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] ); | ||
} | ||
} | ||
tagElm = this.parseHTML(template); | ||
// add any attribuets, if exists | ||
addTagAttrs(tagElm, tagData); | ||
return tagElm; | ||
}, | ||
/** | ||
* Removes a tag | ||
* @param {Number} idx [tag index to be removed] | ||
* @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] | ||
* @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( idx, silent ){ | ||
var tagElm = this.DOM.scope.children[idx]; | ||
removeTag( tagElm, silent ){ | ||
var tagData, | ||
tagIdx = this.getNodeIndex(tagElm); | ||
if( !tagElm) return; | ||
@@ -407,3 +620,3 @@ | ||
// manual timeout (hack, since transitionend cannot be used because of hover) | ||
setTimeout(function(){ | ||
setTimeout(() => { | ||
tagElm.parentNode.removeChild(tagElm); | ||
@@ -413,14 +626,12 @@ }, 400); | ||
if( !silent ){ | ||
this.value.splice(idx, 1); // remove the tag from the data object | ||
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', {value:tagElm.textContent.trim(), index:idx}); | ||
this.trigger('remove', this.extend({}, {index:tagIdx, tag:tagElm}, tagData)); | ||
} | ||
}, | ||
removeAllTags : function(){ | ||
removeAllTags(){ | ||
this.value = []; | ||
this.update(); | ||
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function(elm){ | ||
elm.parentNode.removeChild(elm); | ||
}); | ||
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(elm => elm.parentNode.removeChild(elm)); | ||
}, | ||
@@ -431,5 +642,214 @@ | ||
*/ | ||
update : function(){ | ||
this.DOM.originalInput.value = this.value.join(','); | ||
update(){ | ||
var tagsAsString = this.value.map(v => v.value).join(','); | ||
this.DOM.originalInput.value = tagsAsString; | ||
}, | ||
/** | ||
* Dropdown controller | ||
* @type {Object} | ||
*/ | ||
dropdown : { | ||
init(){ | ||
this.DOM.dropdown = this.dropdown.build.call(this); | ||
}, | ||
build(){ | ||
var className = `tagify__dropdown ${this.settings.dropdown.classname}`.trim(), | ||
template = `<div class="${className}"></div>`; | ||
return this.parseHTML(template); | ||
}, | ||
show( value ){ | ||
var listItems = this.dropdown.filterListItems.call(this, value), | ||
listHTML = this.dropdown.createListHTML(listItems); | ||
if( listItems.length && this.settings.autoComplete ) | ||
this.input.autocomplete.suggest.call(this, listItems[0].value); | ||
if( !listHTML || listItems.length < 2 ){ | ||
this.dropdown.hide.call(this); | ||
return; | ||
} | ||
this.DOM.dropdown.innerHTML = listHTML | ||
this.dropdown.position.call(this); | ||
// if the dropdown has yet to be appended to the document, | ||
// append the dropdown to the body element & handle events | ||
if( !this.DOM.dropdown.parentNode != document.body ){ | ||
document.body.appendChild(this.DOM.dropdown); | ||
this.events.binding.call(this, false); // unbind the main events | ||
this.dropdown.events.binding.call(this); | ||
} | ||
}, | ||
hide(){ | ||
if( !this.DOM.dropdown || this.DOM.dropdown.parentNode != document.body ) return; | ||
document.body.removeChild(this.DOM.dropdown); | ||
window.removeEventListener('resize', this.dropdown.position) | ||
this.dropdown.events.binding.call(this, false); // unbind all events | ||
this.events.binding.call(this); // re-bind main events | ||
}, | ||
position(){ | ||
var rect = this.DOM.scope.getBoundingClientRect(); | ||
this.DOM.dropdown.style.cssText = "left: " + rect.left + window.pageXOffset + "px; \ | ||
top: " + (rect.top + rect.height - 1 + window.pageYOffset) + "px; \ | ||
width: " + rect.width + "px"; | ||
}, | ||
/** | ||
* @type {Object} | ||
*/ | ||
events : { | ||
/** | ||
* Events should only be binded when the dropdown is rendered and removed when isn't | ||
* @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events] | ||
* @return {[type]} [description] | ||
*/ | ||
binding( bindUnbind = true ){ | ||
// references to the ".bind()" methods must be saved so they could be unbinded later | ||
var _CBR = (this.listeners.dropdown = this.listeners.dropdown || { | ||
position : this.dropdown.position.bind(this), | ||
onKeyDown : this.dropdown.events.callbacks.onKeyDown.bind(this), | ||
onMouseOver : this.dropdown.events.callbacks.onMouseOver.bind(this), | ||
onClick : this.dropdown.events.callbacks.onClick.bind(this) | ||
}), | ||
action = bindUnbind ? 'addEventListener' : 'removeEventListener'; | ||
window[action]('resize', _CBR.position); | ||
window[action]('keydown', _CBR.onKeyDown); | ||
window[action]('click', _CBR.onClick); | ||
this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); | ||
// this.DOM.dropdown[action]('click', _CBR.onClick); | ||
}, | ||
callbacks : { | ||
onKeyDown(e){ | ||
var selectedElm = this.DOM.dropdown.querySelectorAll("[class$='--active']")[0], | ||
newValue = ""; | ||
switch( e.key ){ | ||
case 'ArrowDown' : | ||
case 'ArrowUp' : | ||
case 'Down' : // >IE11 | ||
case 'Up' : // >IE11 | ||
e.preventDefault(); | ||
if( selectedElm ) | ||
selectedElm = selectedElm[e.key == 'ArrowUp' || e.key == 'Up' ? "previousElementSibling" : "nextElementSibling"]; | ||
// if no element was found, loop | ||
else | ||
selectedElm = this.DOM.dropdown.children[e.key == 'ArrowUp' || e.key == 'Up' ? this.DOM.dropdown.children.length - 1 : 0]; | ||
this.dropdown.highlightOption.call(this, selectedElm); | ||
break; | ||
case 'Escape' : | ||
this.dropdown.hide.call(this); | ||
break; | ||
case 'Enter' : | ||
e.preventDefault(); | ||
newValue = selectedElm ? selectedElm.textContent : this.input.value; | ||
this.addTags( newValue, true ); | ||
this.dropdown.hide.call(this); | ||
break; | ||
case 'ArrowRight' : | ||
this.input.autocomplete.set.call(this); | ||
break; | ||
} | ||
}, | ||
onMouseOver(e){ | ||
// event delegation check | ||
if( e.target.className.includes('__item') ) | ||
this.dropdown.highlightOption.call(this, e.target); | ||
}, | ||
onClick(e){ | ||
if( e.target.className.includes('tagify__dropdown__item') ){ | ||
this.input.set.call(this) | ||
this.addTags( e.target.textContent ); | ||
} | ||
// clicked outside the dropdown, so just close it | ||
this.dropdown.hide.call(this); | ||
} | ||
} | ||
}, | ||
highlightOption( elm ){ | ||
if( !elm ) return; | ||
var className = "tagify__dropdown__item--active"; | ||
// for IE support, which doesn't allow "forEach" on "NodeList" Objects | ||
[].forEach.call( | ||
this.DOM.dropdown.querySelectorAll("[class$='--active']"), | ||
function(activeElm){ | ||
activeElm.classList.remove(className) | ||
} | ||
); | ||
// this.DOM.dropdown.querySelectorAll("[class$='--active']").forEach(activeElm => activeElm.classList.remove(className)); | ||
elm.classList.add(className); | ||
}, | ||
/** | ||
* returns an HTML string of the suggestions' list items | ||
* @return {[type]} [description] | ||
*/ | ||
filterListItems( value ){ | ||
if( !value ) return ""; | ||
var list = [], | ||
whitelist = this.settings.whitelist, | ||
suggestionsCount = this.settings.dropdown.maxItems || Infinity, | ||
whitelistItem, | ||
valueIsInWhitelist, | ||
i = 0; | ||
for( ; i < whitelist.length; i++ ){ | ||
whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value:whitelist[i] }, //normalize value as an Object | ||
valueIsInWhitelist = whitelistItem.value.toLowerCase().replace(/\s/g, '').indexOf(value.toLowerCase().replace(/\s/g, '')) == 0; // for fuzzy-search use ">=" | ||
// match for the value within each "whitelist" item | ||
if( valueIsInWhitelist && !this.isTagDuplicate(whitelistItem.value) && suggestionsCount-- ) | ||
list.push(whitelistItem); | ||
if( suggestionsCount == 0 ) break; | ||
} | ||
return list; | ||
}, | ||
/** | ||
* Creates the dropdown items' HTML | ||
* @param {Array} list [Array of Objects] | ||
* @return {String} | ||
*/ | ||
createListHTML(list){ | ||
function getItem(item){ | ||
return `<div class='tagify__dropdown__item ${item.class ? item.class : ""}' ${getAttributesString(item)}>${item.value}</div>`; | ||
}; | ||
// for a certain Tag element, add attributes. | ||
function getAttributesString(item){ | ||
var i, keys = Object.keys(item), s = ""; | ||
for( i=keys.length; i--; ){ | ||
var propName = keys[i]; | ||
if( propName != 'class' && !item.hasOwnProperty(propName) ) return; | ||
s += " " + propName + (item[propName] ? "=" + item[propName] : ""); | ||
} | ||
return s; | ||
} | ||
return list.map(getItem).join(""); | ||
} | ||
} | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
259993
16
2315
261
24
1