@yaireo/tagify
Version:
lightweight, efficient Tags input component in Vanilla JS / React / Angular [super customizable, tiny size & top performance]
1,244 lines (1,030 loc) • 88.7 kB
JavaScript
const isFirefox = typeof InstallTrigger !== 'undefined'
const getUID = () => (new Date().getTime() + Math.floor((Math.random()*10000)+1)).toString(16)
const removeCollectionProp = (collection, unwantedProp) => collection.map(v => {
var props = {}
for( var p in v )
if( p != unwantedProp )
props[p] = v[p]
return props
})
function decode( s ) {
var el = document.createElement('div');
return s.replace(/\&#?[0-9a-z]+;/gi, function(enc){
el.innerHTML = enc;
return el.innerText
})
}
/**
* utility method
* https://stackoverflow.com/a/6234804/104380
*/
function escapeHTML( s ){
return s.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// ☝☝☝ ALL THE ABOVE WILL BE MOVED INTO SEPARATE FILES ☝☝☝
/**
* @constructor
* @param {Object} input DOM element
* @param {Object} settings settings object
*/
function Tagify( input, settings ){
// protection
if( !input ){
console.warn('Tagify: ', 'invalid input element ', input)
return this
}
this.applySettings(input, settings||{})
this.state = {
editing : {},
actions : {}, // UI actions for state-locking
dropdown: {}
};
this.value = [] // tags' data
this.tagsDataById = {}
// 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.getCSSVars()
this.loadOriginalValues()
this.events.customBinding.call(this);
this.events.binding.call(this)
input.autofocus && this.DOM.input.focus()
}
Tagify.prototype = {
isIE : window.document.documentMode, // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility
TEXTS : {
empty : "empty",
exceed : "number of tags exceeded",
pattern : "pattern mismatch",
duplicate : "already exists",
notAllowed : "not allowed"
},
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 : [], // Array of tags to suggest as the user types (can be used along with "enforceWhitelist" setting)
blacklist : [], // A list of non-allowed tags
enforceWhitelist : false, // Flag - Only allow tags allowed in whitelist
keepInvalidTags : false, // Flag - if true, do not remove tags which did not pass validation
mixTagsAllowedAfter : /,|\.|\:|\s/, // RegEx - Define conditions in which mix-tags content is allowing a tag to be added after
mixTagsInterpolator : ['[[', ']]'], // Interpolation for mix mode. Everything between this will becmoe a tag
backspace : true, // false / true / "edit"
skipInvalid : false, // If `true`, do not add invalid, temporary, tags before automatically removing them
editTags : 2, // 1 or 2 clicks to edit a tag. false/null for not allowing editing
transformTag : ()=>{}, // Takes a tag input string as argument and returns a transformed value
autoComplete : {
enabled : true, // Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text
rightKey: false, // If `true`, when Right key is pressed, use the suggested value to create a tag, else just auto-completes the input. in mixed-mode this is set to "true"
},
dropdown : {
classname : '',
enabled : 2, // minimum input characters needs to be typed for the dropdown to show
maxItems : 10,
searchKeys : [],
fuzzySearch : true,
highlightFirst: false, // highlights first-matched item in the list
closeOnSelect : true, // closes the dropdown after selecting an item, if `enabled:0` (which means always show dropdown)
position : 'all' // 'manual' / 'text' / 'all'
}
},
// Using ARIA & role attributes
// https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html
templates : {
wrapper(input, settings){
return `<tags class="tagify ${settings.mode ? "tagify--" + settings.mode : ""} ${input.className}"
${settings.readonly ? 'readonly aria-readonly="true"' : 'aria-haspopup="listbox" aria-expanded="false"'}
role="tagslist"
tabIndex="-1">
<span contenteditable data-placeholder="${settings.placeholder || '​'}" aria-placeholder="${settings.placeholder || ''}"
class="tagify__input"
role="textbox"
aria-autocomplete="both"
aria-multiline="${settings.mode=='mix'?true:false}"></span>
</tags>`
},
tag(value, tagData){
return `<tag title="${(tagData.title || value)}"
contenteditable='false'
spellcheck='false'
tabIndex="-1"
class="tagify__tag ${tagData.class ? tagData.class : ""}"
${this.getAttributes(tagData)}>
<x title='' class='tagify__tag__removeBtn' role='button' aria-label='remove tag'></x>
<div>
<span class='tagify__tag-text'>${value}</span>
</div>
</tag>`
},
dropdownItem( item ){
var mapValueTo = this.settings.dropdown.mapValueTo,
value = (mapValueTo
? typeof mapValueTo == 'function'
? mapValueTo(item)
: item[mapValueTo]
: item.value) || item.value,
sanitizedValue = (value || item).replace(/`|'/g, "'");
return `<div ${this.getAttributes(item)}
class='tagify__dropdown__item ${item.class ? item.class : ""}'
tabindex="0"
role="option">${sanitizedValue}</div>`;
}
},
customEventsList : ['add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select'],
applySettings( input, settings ){
this.DEFAULTS.templates = this.templates;
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
this.settings.placeholder = input.getAttribute('placeholder') || this.settings.placeholder || "";
if( this.isIE )
this.settings.autoComplete = false; // IE goes crazy if this isn't false
["whitelist", "blacklist"].forEach(name => {
var attrVal = input.getAttribute('data-' + name)
if( attrVal ){
attrVal = attrVal.split(this.settings.delimiters)
if( attrVal instanceof Array )
this.settings[name] = attrVal
}
})
// backward-compatibility for old version of "autoComplete" setting:
if( "autoComplete" in settings && !this.isObject(settings.autoComplete) ){
this.settings.autoComplete = this.DEFAULTS.autoComplete
this.settings.autoComplete.enabled = settings.autoComplete
}
if( input.pattern )
try { this.settings.pattern = new RegExp(input.pattern) }
catch(e){}
// Convert the "delimiters" setting into a REGEX object
if( this.settings.delimiters ){
try { this.settings.delimiters = new RegExp(this.settings.delimiters, "g") }
catch(e){}
}
// make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode)
if( this.settings.mode == 'select' )
this.settings.dropdown.enabled = 0
if( this.settings.mode == 'mix' )
this.settings.autoComplete.rightKey = true
},
/**
* Returns a string of HTML element attributes
* @param {Object} data [Tag data]
*/
getAttributes( data ){
// only items which are objects have properties which can be used as attributes
if( Object.prototype.toString.call(data) != "[object Object]" )
return '';
var keys = Object.keys(data),
s = "", propName, i;
for( i=keys.length; i--; ){
propName = keys[i];
if( propName != 'class' && data.hasOwnProperty(propName) && data[propName] )
s += " " + propName + (data[propName] ? `="${data[propName]}"` : "");
}
return s;
},
/**
* 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;
},
/**
* Get the caret position relative to the viewport
* https://stackoverflow.com/q/58985076/104380
*
* @returns {object} left, top distance in pixels
*/
getCaretGlobalPosition(){
const sel = document.getSelection()
if( sel.rangeCount ){
const r = sel.getRangeAt(0)
const node = r.startContainer
const offset = r.startOffset
let rect, r2;
if (offset > 0) {
r2 = document.createRange()
r2.setStart(node, (offset - 1))
r2.setEnd(node, offset)
rect = r2.getBoundingClientRect()
return {left:rect.right, top:rect.top, bottom:rect.bottom}
}
}
return {left:-9999, top:-9999}
},
/**
* Get specific CSS variables which are relevant to this script and parse them as needed.
* The result is saved on the instance in "this.CSSVars"
*/
getCSSVars(){
var compStyle = getComputedStyle(this.DOM.scope, null)
const getProp = name => compStyle.getPropertyValue('--'+name)
function seprateUnitFromValue(a){
if( !a ) return {}
a = a.trim().split(' ')[0]
var unit = a.split(/\d+/g).filter(n=>n).pop().trim(),
value = +a.split(unit).filter(n=>n)[0].trim()
return {value, unit}
}
this.CSSVars = {
tagHideTransition: (({value, unit}) => unit=='s' ? value * 1000 : value)(seprateUnitFromValue(getProp('tag-hide-transition')))
}
},
/**
* builds the HTML of this component
* @param {Object} input [DOM element which would be "transformed" into "Tags"]
*/
build( input ){
var DOM = this.DOM,
template = this.settings.templates.wrapper(input, this.settings)
DOM.originalInput = input
DOM.scope = this.parseHTML(template)
DOM.input = DOM.scope.querySelector('[contenteditable]')
input.parentNode.insertBefore(DOM.scope, input)
if( this.settings.dropdown.enabled >= 0 ){
this.dropdown.init.call(this)
}
},
/**
* revert any changes made by this component
*/
destroy(){
this.DOM.scope.parentNode.removeChild(this.DOM.scope);
this.dropdown.hide.call(this, true);
},
/**
* if the original input had any values, add them as tags
*/
loadOriginalValues( value ){
value = value || this.DOM.originalInput.value
// if the original input already had any value (tags)
if( !value ) return;
this.removeAllTags();
if( this.settings.mode == 'mix' )
this.parseMixTags(value.trim())
else{
try{
if( typeof JSON.parse(value) !== 'string' )
value = JSON.parse(value)
}
catch(err){}
this.addTags(value).forEach(tag => tag && tag.classList.add('tagify--noAnim'))
}
},
/**
* Checks if an argument is a javascript Object
*/
isObject(obj) {
var type = Object.prototype.toString.call(obj).split(' ')[1].slice(0, -1);
return obj === Object(obj) && type != 'Array' && type != 'Function' && type != 'RegExp' && type != 'HTMLUnknownElement';
},
/**
* merge objects into a single new one
* TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}})
*/
extend(o, o1, o2){
var that = this;
if( !(o instanceof Object) ) o = {};
copy(o, o1);
if( o2 )
copy(o, o2)
function copy(a,b){
// copy o2 to o
for( var key in b )
if( b.hasOwnProperty(key) ){
if( that.isObject(b[key]) ){
if( !that.isObject(a[key]) )
a[key] = Object.assign({}, b[key]);
else
copy(a[key], b[key])
}
else
a[key] = b[key];
}
}
return o;
},
cloneEvent(e){
var clonedEvent = {}
for( var v in e )
clonedEvent[v] = e[v]
return clonedEvent
},
/**
* A constructor for exposing events to the outside
*/
EventDispatcher( instance ){
// Create a DOM EventTarget object
var target = document.createTextNode('')
function addRemove(op, events, cb){
if( cb )
events.split(/\s+/g).forEach(name => target[op + 'EventListener'].call(target, name, cb))
}
// Pass EventTarget interface calls to DOM EventTarget object
this.off = function(events, cb){
addRemove('remove', events, cb)
return this
};
this.on = function(events, cb){
if(cb && typeof cb == 'function')
addRemove('add', events, cb)
return this
};
this.trigger = function(eventName, data){
var e;
if( !eventName ) return;
if( instance.settings.isJQueryPlugin ){
if( eventName == 'remove' ) eventName = 'removeTag' // issue #222
jQuery(instance.DOM.originalInput).triggerHandler(eventName, [data])
}
else{
try {
e = new CustomEvent(eventName, {"detail": this.extend({}, data, {tagify:this})})
}
catch(err){ console.warn(err) }
target.dispatchEvent(e);
}
}
},
/**
* Toogle loading state on/off
* @param {Boolean} isLoading
*/
loading( isLoading ){
// IE11 doesn't support toggle with second parameter
this.DOM.scope.classList[isLoading?"add":"remove"]('tagify--loading')
return this;
},
toggleFocusClass( force ){
this.DOM.scope.classList.toggle('tagify--focus', !!force)
},
/**
* DOM events listeners binding
*/
events : {
// bind custom events which were passed in the settings
customBinding(){
this.customEventsList.forEach(name => {
this.on(name, this.settings.callbacks[name])
})
},
binding( bindUnbind = true ){
var _CB = this.events.callbacks,
_CBR,
action = bindUnbind ? 'addEventListener' : 'removeEventListener';
// do not allow the main events to be bound more than once
if( this.state.mainEvents && bindUnbind )
return;
// set the binding state of the main events, so they will not be bound more than once
this.state.mainEvents = bindUnbind;
if( bindUnbind && !this.listeners.main ){
// 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 )
jQuery(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 || {
focus : ['input', _CB.onFocusBlur.bind(this)],
blur : ['input', _CB.onFocusBlur.bind(this)],
keydown : ['input', _CB.onKeydown.bind(this)],
click : ['scope', _CB.onClickScope.bind(this)],
dblclick : ['scope', _CB.onDoubleClickScope.bind(this)]
})
for( var eventName in _CBR ){
// make sure the focus/blur event is always regesitered (and never more than once)
if( eventName == 'blur' && !bindUnbind ) return;
this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]);
}
},
/**
* DOM events callbacks
*/
callbacks : {
onFocusBlur(e){
var text = e.target ? e.target.textContent.trim() : '', // a string
_s = this.settings,
type = e.type,
shouldAddTags;
// goes into this scenario only on input "blur" and a tag was clicked
if( e.relatedTarget &&
e.relatedTarget.classList.contains('tagify__tag') &&
this.DOM.scope.contains(e.relatedTarget) )
return
if( type == 'blur' && e.relatedTarget === this.DOM.scope ){
this.dropdown.hide.call(this)
this.DOM.input.focus()
return
}
if( this.state.actions.selectOption &&
(_s.dropdown.enabled || !_s.dropdown.closeOnSelect) )
return;
this.state.hasFocus = type == "focus" ? +new Date() : false
this.toggleFocusClass(this.state.hasFocus)
this.setRangeAtStartEnd(false)
if( _s.mode == 'mix' ){
if( e.type == "blur" )
this.dropdown.hide.call(this)
return
}
if( type == "focus" ){
this.trigger("focus", {relatedTarget:e.relatedTarget})
// e.target.classList.remove('placeholder');
if( _s.dropdown.enabled === 0 && _s.mode != "select" ){
this.dropdown.show.call(this)
}
return
}
else if( type == "blur" ){
this.trigger("blur", {relatedTarget:e.relatedTarget})
this.loading(false)
shouldAddTags = this.settings.mode == 'select'
? !this.value.length || this.value[0].value != text
: text && !this.state.actions.selectOption && _s.addTagOnBlur
// do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown)
shouldAddTags && this.addTags(text, true)
}
this.DOM.input.removeAttribute('style')
this.dropdown.hide.call(this)
},
onKeydown(e){
var s = e.target.textContent.trim();
this.trigger("keydown", {originalEvent:this.cloneEvent(e)});
if( this.settings.mode == 'mix' ){
switch( e.key ){
case 'Left' :
case 'ArrowLeft' : {
// when left arrow was pressed, raise a flag so when the dropdown is shown, right-arrow will be ignored
// because it seems likely the user wishes to use the arrows to move the caret
this.state.actions.ArrowLeft = true
break
}
case 'Delete':
case 'Backspace' : {
// e.preventDefault()
var selection = document.getSelection(),
values = [],
lastInputValue = decode(this.DOM.input.innerHTML);
// if( isFirefox && selection && selection.anchorOffset == 0 )
// this.removeTag(selection.anchorNode.previousSibling)
// a minimum delay is needed before the node actually gets ditached from the document (don't know why),
// to know exactly which tag was deleted. This is the easiest way of knowing besides using MutationObserver
setTimeout(()=>{
// fixes #384, where the first and only tag will not get removed with backspace
if( decode(this.DOM.input.innerHTML).length >= lastInputValue.length ){
this.removeTag(selection.anchorNode.previousElementSibling)
// the above "removeTag" methods removes the tag with a transition. Chrome adds a <br> element for some reason at this stage
if( this.DOM.input.children.length == 2 && this.DOM.input.children[1].tagName == "BR" ){
this.DOM.input.innerHTML = ""
this.value.length = 0
return true
}
}
var tagElms = this.DOM.input.querySelectorAll('.tagify__tag');
// find out which tag(s) were deleted and update "this.value" accordingly
// iterate over the list of tags still in the document and then filter only those from the "this.value" collection
[].forEach.call( tagElms, node => values.push(node.getAttribute('value')) )
this.value = this.value.filter(d => values.indexOf(d.value) != -1);
}, 50) // Firefox needs this higher duration for some reason or things get buggy when to deleting text from the end
break;
}
// currently commented to allow new lines in mixed-mode
// case 'Enter' :
// e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
}
return true
}
switch( e.key ){
case 'Backspace' :
if( !this.state.dropdown.visible ){
if( s == "" || s.charCodeAt(0) == 8203 ){ // 8203: ZERO WIDTH SPACE unicode
if( this.settings.backspace === true )
this.removeTag();
else if( this.settings.backspace == 'edit' )
setTimeout(this.editTag.bind(this), 0) // timeout reason: when edited tag gets focused and the caret is placed at the end, the last character gets deletec (because of backspace)
}
}
break;
case 'Esc' :
case 'Escape' :
if( this.state.dropdown.visible ) return
e.target.blur()
break;
case 'Down' :
case 'ArrowDown' :
// if( this.settings.mode == 'select' ) // issue #333
if( !this.state.dropdown.visible )
this.dropdown.show.call(this)
break;
case 'ArrowRight' : {
let tagData = this.state.inputSuggestion || this.state.ddItemData
if( tagData && this.settings.autoComplete.rightKey ){
this.addTags([tagData], true)
return;
}
break
}
case 'Tab' : {
if( !s || this.settings.mode == 'select' ) return true;
}
case 'Enter' :
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
// because the main "keydown" event is bound before the dropdown events, this will fire first and will not *yet*
// know if an option was just selected from the dropdown menu. If an option was selected,
// the dropdown events should handle adding the tag
setTimeout(()=>{
if( this.state.actions.selectOption )
return
this.addTags(s, true)
})
}
},
onInput(e){
var value = this.settings.mode == 'mix' ? this.DOM.input.textContent : this.input.normalize.call(this),
showSuggestions = value.length >= this.settings.dropdown.enabled,
data = {value, inputElm:this.DOM.input};
if( this.settings.mode == 'mix' )
return this.events.callbacks.onMixTagsInput.call(this, e);
if( !value ){
this.input.set.call(this, '');
return;
}
if( this.input.value == value ) return; // for IE; since IE doesn't have an "input" event so "keyDown" is used instead
data.isValid = this.validateTag(value);
this.trigger('input', data) // "input" event must be triggered at this point, before the dropdown is shown
// save the value on the input's State object
this.input.set.call(this, value, false); // update the input with the normalized value and run validations
// this.setRangeAtStartEnd(); // fix caret position
if( value.search(this.settings.delimiters) != -1 ){
if( this.addTags( value ) ){
this.input.set.call(this); // clear the input field's value
}
}
else if( this.settings.dropdown.enabled >= 0 ){
this.dropdown[showSuggestions ? "show" : "hide"].call(this, value);
}
},
onMixTagsInput( e ){
var sel, range, split, tag, showSuggestions,
_s = this.settings;
if( this.hasMaxTags() )
return true
if( window.getSelection ){
sel = window.getSelection()
if( sel.rangeCount > 0 ){
range = sel.getRangeAt(0).cloneRange()
range.collapse(true)
range.setStart(window.getSelection().focusNode, 0)
split = range.toString().split(_s.mixTagsAllowedAfter) // ["foo", "bar", "@a"]
tag = split[split.length-1].match(_s.pattern)
if( tag ){
this.state.actions.ArrowLeft = false // start fresh, assuming the user did not (yet) used any arrow to move the caret
this.state.tag = {
prefix : tag[0],
value : tag.input.split(tag[0])[1],
}
showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled
}
}
}
// wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode")
// the dropdown must be shown only after this event has been driggered, so an implementer could
// dynamically change the whitelist.
setTimeout(()=>{
this.update()
this.trigger("input", this.extend({}, this.state.tag, {textContent:this.DOM.input.textContent}))
if( this.state.tag )
this.dropdown[showSuggestions ? "show" : "hide"].call(this, this.state.tag.value);
}, 10)
},
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)
})
},
onClickScope(e){
var tagElm = e.target.closest('.tagify__tag'),
_s = this.settings,
timeDiffFocus = +new Date() - this.state.hasFocus,
tagElmIdx;
if( e.target == this.DOM.scope ){
// if( !this.state.hasFocus )
// this.dropdown.hide.call(this)
this.DOM.input.focus()
return
}
else if( e.target.classList.contains("tagify__tag__removeBtn") ){
this.removeTag( e.target.parentNode );
return
}
else if( tagElm ){
tagElmIdx = this.getNodeIndex(tagElm);
this.trigger("click", { tag:tagElm, index:tagElmIdx, data:this.value[tagElmIdx], originalEvent:this.cloneEvent(e) });
if( this.settings.editTags == 1 )
this.events.callbacks.onDoubleClickScope.call(this, e)
return
}
// when clicking on the input itself
else if( e.target == this.DOM.input && timeDiffFocus > 500 ){
if( this.state.dropdown.visible )
this.dropdown.hide.call(this)
else if( _s.dropdown.enabled === 0 && _s.mode != 'mix' )
this.dropdown.show.call(this)
return
}
if( _s.mode == 'select' )
!this.state.dropdown.visible && this.dropdown.show.call(this);
},
onEditTagInput( editableElm, e ){
var tagElm = editableElm.closest('tag'),
tagElmIdx = this.getNodeIndex(tagElm),
value = this.input.normalize.call(this, editableElm),
isValid = value.toLowerCase() == editableElm.originalValue.toLowerCase() || this.validateTag(value);
tagElm.classList.toggle('tagify--invalid', isValid !== true);
tagElm.isValid = isValid === true;
// show dropdown if typed text is equal or more than the "enabled" dropdown setting
if( value.length >= this.settings.dropdown.enabled ){
this.state.editing.value = value;
this.dropdown.show.call(this, value);
}
this.trigger("edit:input", {
tag : tagElm,
index : tagElmIdx,
data : this.extend({}, this.value[tagElmIdx], {newValue:value}),
originalEvent: this.cloneEvent(e)
})
},
onEditTagBlur( editableElm ){
if( !this.state.hasFocus )
this.toggleFocusClass()
if( !this.DOM.scope.contains(editableElm) ) return;
var tagElm = editableElm.closest('.tagify__tag'),
currentValue = this.input.normalize.call(this, editableElm),
value = currentValue || editableElm.originalValue,
hasChanged = value != editableElm.originalValue,
isValid = tagElm.isValid,
tagData = {...this.tagsDataById[tagElm.__tagifyId], value};
// this.DOM.input.focus()
if( !currentValue ){
this.removeTag(tagElm)
this.onEditTagDone()
return
}
if( hasChanged ){
this.settings.transformTag.call(this, tagData)
// re-validate after tag transformation
isValid = this.validateTag(tagData.value) === true
}
else{
this.onEditTagDone(tagElm)
return
}
if( isValid !== true )
return;
this.onEditTagDone(tagElm, tagData)
},
onEditTagkeydown(e){
this.trigger("edit:keydown", {originalEvent:this.cloneEvent(e)})
switch( e.key ){
case 'Esc' :
case 'Escape' :
e.target.textContent = e.target.originalValue;
case 'Enter' :
case 'Tab' :
e.preventDefault()
e.target.blur()
}
},
onDoubleClickScope(e){
var tagElm = e.target.closest('tag'),
_s = this.settings,
isEditingTag,
isReadyOnlyTag;
if( !tagElm ) return
isEditingTag = tagElm.classList.contains('tagify__tag--editable')
isReadyOnlyTag = tagElm.hasAttribute('readonly')
if( _s.mode != 'select' && !_s.readonly && !isEditingTag && !isReadyOnlyTag && this.settings.editTags )
this.editTag(tagElm)
this.toggleFocusClass(true)
}
}
},
/**
* @param {Node} tagElm the tag element to edit. if nothing specified, use last last
*/
editTag( tagElm, opts ){
tagElm = tagElm || this.getLastTag()
opts = opts || {}
var editableElm = tagElm.querySelector('.tagify__tag-text'),
tagIdx = this.getNodeIndex(tagElm),
tagData = this.tagsDataById[tagElm.__tagifyId],
_CB = this.events.callbacks,
that = this,
isValid = true,
delayed_onEditTagBlur = function(){ setTimeout(_CB.onEditTagBlur.bind(that), 0, editableElm) }
if( !editableElm ){
console.warn('Cannot find element in Tag template: ', '.tagify__tag-text');
return;
}
if( tagData instanceof Object && "editable" in tagData && !tagData.editable )
return
tagElm.classList.add('tagify__tag--editable')
editableElm.originalValue = editableElm.textContent
editableElm.setAttribute('contenteditable', true)
editableElm.addEventListener('blur', delayed_onEditTagBlur)
editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm))
editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e))
editableElm.focus()
this.setRangeAtStartEnd(false, editableElm)
if( !opts.skipValidation )
isValid = this.editTagToggleValidity(tagElm, tagData.value)
this.state.editing = {
scope: tagElm,
input: tagElm.querySelector("[contenteditable]")
}
this.trigger("edit:start", { tag:tagElm, index:tagIdx, data:tagData, isValid })
return this;
},
editTagToggleValidity( tagElm, value ){
var isValid = this.validateTag(value, tagElm.__tagifyId)
tagElm.classList.toggle('tagify--invalid', isValid !== true)
tagElm.isValid = isValid
return isValid;
},
onEditTagDone(tagElm, tagData){
var eventData = { tag:tagElm, index:this.getNodeIndex(tagElm), data:tagData }
this.trigger("edit:beforeUpdate", eventData)
tagElm && this.replaceTag(tagElm, tagData)
this.trigger("edit:updated", eventData)
this.dropdown.hide.call(this)
},
/**
* Replaces an exisitng tag with a new one and update the relevant state
* @param {Object} tagElm [DOM node to replace]
* @param {Object} tagData [data to create new tag from]
*/
replaceTag(tagElm, tagData){
if( !tagData || !tagData.value )
tagData = this.tagsDataById[tagElm.__tagifyId]
// if tag is invalid, make the according changes in the newly created element
tagData = tagElm.isValid === true ? tagData : this.extend(tagData, this.getInvaildTagParams(tagData, tagData))
var newTag = this.createTagElem(tagData);
// when editing a tag and selecting a dropdown suggested item, the state should be "locked"
// so "onEditTagBlur" won't run and change the tag also *after* it was just changed.
if( this.state.editing.locked ) return;
this.state.editing = { locked:true }
setTimeout(() => delete this.state.editing.locked, 500)
// update DOM
newTag.__tagifyId = tagElm.__tagifyId;
tagElm.parentNode.replaceChild(newTag, tagElm)
this.tagsDataById[tagElm.__tagifyId] = tagData
this.updateValueByDOMTags()
},
/**
* update value by traversing all valid tags
*/
updateValueByDOMTags(){
this.value = [];
[].forEach.call(this.getTagElms(), node => {
if( node.classList.contains('tagify--notAllowed') ) return
this.value.push( this.tagsDataById[node.__tagifyId] )
})
this.update()
},
/** https://stackoverflow.com/a/59156872/104380
* @param {Boolean} start indicating where to place it (start or end of the node)
* @param {Object} node DOM node to place the caret at
*/
setRangeAtStartEnd( start, node ){
node = node || this.DOM.input;
node = node.lastChild || node;
const sel = document.getSelection()
if( sel.rangeCount ){
['Start', 'End'].forEach(pos =>
sel.getRangeAt(0)["set" + pos](node, start ? 0 : node.length)
)
}
},
/**
* input bridge for accessing & setting
* @type {Object}
*/
input : {
value : '',
set( s = '', updateDOM = true ){
var hideDropdown = this.settings.dropdown.closeOnSelect
this.input.value = s;
if( updateDOM )
this.DOM.input.innerHTML = s;
if( !s && hideDropdown )
setTimeout(this.dropdown.hide.bind(this), 20) // setTimeout duration must be HIGER than the dropdown's item "onClick" method's "focus()" event, because the "hide" method re-binds the main events and it will catch the "blur" event and will cause
this.input.autocomplete.suggest.call(this);
this.input.validate.call(this);
},
/**
* Marks the tagify's input as "invalid" if the value did not pass "validateTag()"
*/
validate(){
var isValid = !this.input.value || this.validateTag(this.input.value)
if( this.settings.mode == 'select' )
this.DOM.scope.classList.toggle('tagify--invalid', isValid !== true)
else
this.DOM.input.classList.toggle('tagify__input--invalid', isValid !== true)
},
// remove any child DOM elements that aren't of type TEXT (like <br>)
normalize( node ){
var clone = node || this.DOM.input, //.cloneNode(true),
v = [];
// when a text was pasted in FF, the "this.DOM.input" element will have <br> but no newline symbols (\n), and this will
// result in tags no being properly created if one wishes to create a separate tag per newline.
clone.childNodes.forEach(n => n.nodeType==3 && v.push(n.nodeValue))
v = v.join("\n")
try{
// "delimiters" might be of a non-regex value, where this will fail ("Tags With Properties" example in demo page):
v = v.replace(/(?:\r\n|\r|\n)/g, this.settings.delimiters.source.charAt(0))
}
catch(err){}
v = v.replace(/\s/g, ' ') // replace NBSPs with spaces characters
.replace(/^\s+/, "") // trimLeft
return v
},
/**
* suggest the rest of the input's value (via CSS "::after" using "content:attr(...)")
* @param {String} s [description]
*/
autocomplete : {
suggest( data ){
if( !this.settings.autoComplete.enabled ) return;
data = data || {}
if( typeof data == 'string' )
data = {value:data}
var suggestedText = data.value || '',
suggestionStart = suggestedText.substr(0, this.input.value.length).toLowerCase(),
suggestionTrimmed = suggestedText.substring(this.input.value.length);
if( !suggestedText || !this.input.value || suggestionStart != this.input.value.toLowerCase() ){
this.DOM.input.removeAttribute("data-suggest");
delete this.state.inputSuggestion
}
else{
this.DOM.input.setAttribute("data-suggest", suggestionTrimmed);
this.state.inputSuggestion = data
}
},
/**
* sets the suggested text as the input's value & cleanup the suggestion autocomplete.
* @param {String} s [text]
*/
set( s ){
var dataSuggest = this.DOM.input.getAttribute('data-suggest'),
suggestion = s || (dataSuggest ? this.input.value + dataSuggest : null);
if( suggestion ){
if( this.settings.mode == 'mix' ){
this.replaceTextWithNode( document.createTextNode(this.state.tag.prefix + suggestion) )
}
else{
this.input.set.call(this, suggestion);
this.setRangeAtStartEnd()
}
this.input.autocomplete.suggest.call(this);
this.dropdown.hide.call(this);
return true;
}
return false;
}
}
},
getNodeIndex( node ){
var index = 0;
if( node )
while( (node = node.previousElementSibling) )
index++;
return index;
},
getTagElms(){
return this.DOM.scope.querySelectorAll('.tagify__tag')
},
getLastTag(){
var lastTag = this.DOM.scope.querySelectorAll('tag:not(.tagify--hide):not([readonly])');
return lastTag[lastTag.length - 1];
},
/**
* Searches if any tag with a certain value already exis
* @param {String/Object} v [text value / tag data object]
* @return {Boolean}
*/
isTagDuplicate( v, uid ){
// duplications are irrelevant for this scenario
if( this.settings.mode == 'select' )
return false
return this.value.some(item =>
// if this item has the same uid as the one checked, it cannot be a duplicate of itself.
// most of the time uid will be "undefined"
item.__tagifyId == uid
? false
: this.isObject(v)
? JSON.stringify(item).toLowerCase() === JSON.stringify(v).toLowerCase()
: v.trim().toLowerCase() === item.value.toLowerCase()
)
},
getTagIndexByValue( value ){
var result = [];
this.getTagElms().forEach((tagElm, i) => {
if( tagElm.textContent.trim().toLowerCase() == value.toLowerCase() )
result.push(i)
})
return result;
},
getTagElmByValue( value ){
var tagIdx = this.getTagIndexByValue(value)[0];
return this.getTagElms()[tagIdx];
},
/**
* 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 ){
tagElm = tagElm || this.getTagElmByValue(value);
// check AGAIN if "tagElm" is defined
if( tagElm ){
tagElm.classList.add('tagify--mark')
setTimeout(() => { tagElm.classList.remove('tagify--mark') }, 100)
return tagElm
}
return false
},
/**
* make sure the tag, or words in it, is not in the blacklist
*/
isTagBlacklisted( v ){
v = v.toLowerCase().trim();
return this.settings.blacklist.filter(x =>v == x.toLowerCase()).length;
},
/**
* make sure the tag, or words in it, is not in the blacklist
*/
isTagWhitelisted( v ){
return this.settings.whitelist.some(item =>
typeof v == 'string'
? v.trim().toLowerCase() === (item.value || item).toLowerCase()
: JSON.stringify(item).toLowerCase() === JSON.stringify(v).toLowerCase()
)
},
/**
* validate a tag object BEFORE the actual tag will be created & appeneded
* @param {String} s
* @param {String} uid [unique ID, to not inclue own tag when cheking for duplicates]
* @return {Boolean/String} ["true" if validation has passed, String for a fail]
*/
validateTag( s, uid ){
var value = s.trim(),
_s = this.settings,
result = true;
// check for empty value
if( !value )
result = this.TEXTS.empty;
// check if pattern should be used and if so, use it to test the value
else if( _s.pattern && !(_s.pattern.test(value)) )
result = this.TEXTS.pattern;
// if duplicates are not allowed and there is a duplicate
else if( !_s.duplicates && this.isTagDuplicate(value, uid) )
result = this.TEXTS.duplicate;
else if( this.isTagBlacklisted(value) ||(_s.enforceWhitelist && !this.isTagWhitelisted(value)) )
result = this.TEXTS.notAllowed;
return result;
},
getInvaildTagParams(tagData, validation){
return {
"aria-invalid" : true,
"class": (tagData.class || '') + ' tagify--notAllowed',
"title": validation
}
},
hasMaxTags(){
if( this.value.length >= this.settings.maxTags )
return this.TEXTS.exceed;
return false;
},
/**
* 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]
*/
normalizeTags( tagsItems ){