UNPKG

@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
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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } // ☝☝☝ 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 || '&#8203;'}" 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, "&#39;"); 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 ){