function Tagify( input, settings ){ // protection if( !input ){ console.warn('Tagify: ', 'invalid input element ', input) return this; } this.applySettings(input, settings); this.state = {}; this.value = []; // tags' data // 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.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 autoComplete : true, // Flag - tries to autocomplete the input's value while typing mixTagsAllowedAfter : /,|\.|\:|\s/, // RegEx - Define conditions in which mix-tags content is allowing a tag to be added after backspace : true, // false / true / "edit" skipInvalid : false, transformTag : ()=>{}, dropdown : { classname : '', enabled : 2, // minimum input characters needs to be typed for the dropdown to show maxItems : 10, itemTemplate : '', fuzzySearch : true } }, templates : { wrapper(input, settings){ return `` }, tag(v, tagData){ return `
${v}
` }, dropdownItem( item ){ var sanitizedValue = (item.value || item).replace(/`|'/g, "'"); return ``; } }, customEventsList : ['click', 'add', 'remove', 'invalid', 'input', 'edit'], applySettings( input, settings ){ var attr__whitelist = input.getAttribute('data-whitelist'), attr__blacklist = input.getAttribute('data-blacklist'); this.DEFAULTS.templates = this.templates; this.DEFAULTS.dropdown.itemTemplate = this.templates.dropdownItem; // regression fallback 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( this.isIE ) this.settings.autoComplete = false; // IE goes crazy if this isn't false if( attr__blacklist ){ attr__blacklist = attr__blacklist.split(this.settings.delimiters); if( attr__blacklist instanceof Array ) this.settings.blacklist = attr__blacklist; } if( attr__whitelist ){ attr__whitelist = attr__whitelist.split(this.settings.delimiters) if( attr__whitelist instanceof Array ) this.settings.whitelist = attr__whitelist; } 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(this.settings.delimiters, "g") } catch(e){} } }, /** * 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; }, /** * utility method * https://stackoverflow.com/a/25396011/104380 */ escapeHTML( s ){ var text = document.createTextNode(s), 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( input ){ var that = this, 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 = this.DOM.originalInput.value ){ // if the original input already had any value (tags) if( !value ) return; this.removeAllTags(); try{ value = JSON.parse(value) } catch(err){} if( this.settings.mode == 'mix' ){ this.parseMixTags(value); } else this.addTags(value).forEach(tag => { tag && tag.classList.add('tagify--noAnim'); }); }, /** * merge two objects into a new one * TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) */ extend(o, o1, o2){ if( !(o instanceof Object) ) o = {}; copy(o, o1); if( o2 ) copy(o, o2) function 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'; }; 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]; } } return o; }, /** * A constructor for exposing events to the outside */ 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); } } }, /** * 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'; 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 ) $(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)], dblclick : ['scope', _CB.onDoubleClickScope.bind(this)] }); for( var eventName in _CBR ){ this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]); } }, /** * DOM events callbacks */ callbacks : { onFocusBlur(e){ var s = e.target.textContent.trim(); if( this.settings.mode == 'mix' ) return; if( e.type == "focus" ){ this.DOM.scope.classList.add('tagify--focus') // e.target.classList.remove('placeholder'); if( this.settings.dropdown.enabled === 0 ){ this.dropdown.show.call(this); } } else if( e.type == "blur" ){ this.DOM.scope.classList.remove('tagify--focus'); 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(e){ var s = e.target.textContent, lastTag, tags; if( this.settings.mode == 'mix' ){ switch( e.key ){ case 'Delete': case 'Backspace' : var values = []; // find out which tag(s) were deleted and update "this.value" accordingly tags = this.DOM.input.children; // a delay is in need before the node actually is ditached from the document setTimeout(()=>{ // iterate over the list of tags still in the document and then filter only those from the "this.value" collection [].forEach.call( tags, tagElm => values.push(tagElm.getAttribute('value')) ) this.value = this.value.filter(d => values.indexOf(d.value) != -1); }, 20) break; case 'Enter' : e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 } return true; } switch( e.key ){ case 'Backspace' : if( s == "" || s.charCodeAt(0) == 8203 ){ // 8203: ZERO WIDTH SPACE unicode if( this.settings.backspace === true ) this.removeTag(); else if( this.settings.backspace == 'edit' ) this.editTag() } break; case 'Esc' : case 'Escape' : this.input.set.call(this) e.target.blur(); break; case 'ArrowRight' : case 'Tab' : if( !s ) return true; case 'Enter' : e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 this.addTags(this.input.value || s, true) } }, onInput(e){ var value = 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.call(this, value); // 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.input.setRangeAtStartEnd.call(this); // fix caret position this.trigger("input", data); 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 >= 0 ){ this.dropdown[showSuggestions ? "show" : "hide"].call(this, value); } }, onMixTagsInput( e ){ var sel, range, split, tag, showSuggestions, eventData = {}; if( this.maxTagsReached() ) 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(this.settings.mixTagsAllowedAfter); // ["foo", "bar", "@a"] tag = split[split.length-1].match(this.settings.pattern); if( tag ){ this.state.tag = { prefix : tag[0], value : tag.input.split(tag[0])[1], } tag = this.state.tag; showSuggestions = this.state.tag.value.length >= this.settings.dropdown.enabled } } } 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); } }, 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){ var tagElm = e.target.closest('tag'), tagElmIdx; if( e.target.tagName == "TAGS" ) this.DOM.input.focus(); else if( e.target.tagName == "X" ) this.removeTag( e.target.parentNode ); else if( tagElm ){ tagElmIdx = this.getNodeIndex(tagElm); this.trigger("click", { tag:tagElm, index:tagElmIdx, data:this.value[tagElmIdx] }); } }, onEditTagInput( editableElm ){ var tagElm = editableElm.closest('tag'), tagElmIdx = this.getNodeIndex(tagElm), value = this.input.normalize(editableElm), isValid = value.toLowerCase() == editableElm.originalValue.toLowerCase() || this.validateTag(value); tagElm.classList.toggle('tagify--invalid', isValid !== true); tagElm.isValid = isValid; this.trigger("input", { tag:tagElm, index:tagElmIdx, data:this.extend({}, this.value[tagElmIdx], {newValue:value}) }); }, onEditTagBlur( editableElm ){ var tagElm = editableElm.closest('tag'), tagElmIdx = this.getNodeIndex(tagElm), value = this.input.normalize(editableElm) || editableElm.originalValue, hasChanged = this.input.normalize(editableElm) != editableElm.originalValue, isValid = tagElm.isValid, tagData = {...this.value[tagElmIdx], value}, clone; if( hasChanged ){ this.settings.transformTag.call(this, tagData); // re-validate after tag transformation isValid = this.validateTag(tagData.value); } if( isValid !== undefined && isValid !== true ) return; // undo if empty editableElm.textContent = tagData.value; // update data this.value[tagElmIdx].value = tagData.value; this.update(); // cleanup (clone node to remove events) clone = editableElm.cloneNode(true); clone.removeAttribute('contenteditable'); tagElm.title = tagData.value; tagElm.classList.remove('tagify--editable'); // remove all events from the "editTag" method editableElm.parentNode.replaceChild(clone, editableElm); this.trigger("edit", { tag:tagElm, index:tagElmIdx, data:this.value[tagElmIdx] }); }, onEditTagkeydown(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 = tagElm.classList.contains('tagify--editable'), isReadyOnlyTag = tagElm.hasAttribute('readonly') if( _s.mode != 'mix' && !_s.readonly && !_s.enforceWhitelist && tagElm && !isEditingTag && !isReadyOnlyTag ) this.editTag(tagElm); } } }, editTag( tagElm = this.getLastTag() ){ var editableElm = tagElm.querySelector('.tagify__tag-text'), _CB = this.events.callbacks; if( !editableElm ){ console.warn('Cannot find element in Tag template: ', '.tagify__tag-text'); return; } tagElm.classList.add('tagify--editable'); editableElm.originalValue = editableElm.textContent; editableElm.setAttribute('contenteditable', true); editableElm.addEventListener('blur', _CB.onEditTagBlur.bind(this, editableElm)); editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm)); editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e)); editableElm.focus(); }, /** * input bridge for accessing & setting * @type {Object} */ input : { value : '', set( s = '', updateDOM = true ){ this.input.value = s; if( updateDOM ) this.DOM.input.innerHTML = s; if( !s ) this.dropdown.hide.call(this); if( s.length < 2 ) this.input.autocomplete.suggest.call(this, ''); this.input.validate.call(this); }, // https://stackoverflow.com/a/3866442/104380 setRangeAtStartEnd( start=false, node ){ var range, selection; if( !document.createRange ) return; range = document.createRange(); range.selectNodeContents(node || this.DOM.input); range.collapse(start); selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }, /** * Marks the tagify's input as "invalid" if the value did not pass "validateTag()" */ validate(){ var isValid = !this.input.value || this.validateTag.call(this, this.input.value); this.DOM.input.classList.toggle('tagify__input--invalid', isValid !== true); }, // remove any child DOM elements that aren't of type TEXT (like
) normalize( node = this.DOM.input ){ var clone = node, //.cloneNode(true), v = clone.innerText; if( "settings" in this && this.settings.delimiters ) v = v.replace(/(?:\r\n|\r|\n)/g, this.settings.delimiters.source.charAt(1)); 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( s ){ if( !s || !this.input.value ) this.DOM.input.removeAttribute("data-suggest"); else this.DOM.input.setAttribute("data-suggest", s.substring(this.input.value.length)); }, set( s ){ var dataSuggest = this.DOM.input.getAttribute('data-suggest'), suggestion = s || (dataSuggest ? this.input.value + dataSuggest : null); if( suggestion ){ this.input.set.call(this, suggestion); this.input.autocomplete.suggest.call(this, ''); this.dropdown.hide.call(this); this.input.setRangeAtStartEnd.call(this); return true; } return false; // if( suggestion && this.addTags(this.input.value + suggestion).length ){ // this.input.set.call(this); // this.dropdown.hide.call(this); // } } } }, getNodeIndex( node ){ var index = 0; if( node ) while( (node = node.previousElementSibling) ) index++; return index; }, getTagElms(){ return this.DOM.scope.querySelectorAll('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} s [text value to search for] * @return {int} [Position index of the tag. -1 is returned if tag is not found.] */ isTagDuplicate( s ){ return this.value.findIndex(item => s.trim().toLowerCase() === item.value.toLowerCase()); // return this.value.some(item => s.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 ){ var tagsElms, tagsElmsLen 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 => { if( (item.value || item).toLowerCase() === v.toLowerCase() ) return true; }); }, /** * validate a tag object BEFORE the actual tag will be created & appeneded * @param {String} s * @return {Boolean/String} ["true" if validation has passed, String for a fail] */ validateTag( s ){ var value = s.trim(), 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( this.settings.pattern && !(this.settings.pattern.test(value)) ) result = this.TEXTS.pattern; // if duplicates are not allowed and there is a duplicate else if( !this.settings.duplicates && this.isTagDuplicate(value) !== -1 ) result = this.TEXTS.duplicate; else if( this.isTagBlacklisted(value) ||(this.settings.enforceWhitelist && !this.isTagWhitelisted(value)) ) result = this.TEXTS.notAllowed; return result; }, maxTagsReached(){ 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 ){ var {whitelist, delimiters, mode} = this.settings, whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false, // checks if this is a "collection", meanning an Array of Objects isCollection = tagsItems instanceof Array && tagsItems[0] instanceof Object && "value" in tagsItems[0], temp = [], mapStringToCollection = s => s.split(delimiters).filter(n => n).map(v => ({ value:v.trim() })) // no need to continue if "tagsItems" is an Array of Objects if (isCollection){ // iterate the collection items and check for values that can be splitted into multiple tags tagsItems = [].concat(...tagsItems.map(item => mapStringToCollection(item.value).map(newItem => ({...item,...newItem})) )); return tagsItems; } if( typeof tagsItems == 'number' ) tagsItems = tagsItems.toString(); // if the value is a "simple" String, ex: "aaa, bbb, ccc" if( typeof tagsItems == 'string' ){ if( !tagsItems.trim() ) return []; // go over each tag and add it (if there were multiple ones) tagsItems = mapStringToCollection(tagsItems); } else if( !isCollection && tagsItems instanceof Array ){ tagsItems = [].concat(...tagsItems.map(item => mapStringToCollection(item))); } // search if the tag exists in the whitelist as an Object (has props), to be able to use its properties if( whitelistWithProps ){ tagsItems.forEach(item => { var matchObj = whitelist.filter( WL_item => WL_item.value.toLowerCase() == item.value.toLowerCase() ) if( matchObj[0] ) temp.push( matchObj[0] ); // set the Array (with the found Object) as the new value else if( mode != 'mix' ) temp.push(item) }) tagsItems = temp; } return tagsItems; }, parseMixTags( s ){ var collect = false, match = "", parsedMatch, value = s, html = s, tagData; for( let i = 0; i < s.length; i++ ){ if( s[i] == '[' && s[i] == s[i+1]) collect = true; if (collect) match += s[i]; if( s[i] == ']' && s[i] == s[i-1]){ collect = false; parsedMatch = match.slice(2).slice(0, -2); if( this.isTagWhitelisted(parsedMatch) && !this.settings.duplicates && this.isTagDuplicate(parsedMatch) == -1 ){ tagData = this.normalizeTags.call(this, parsedMatch)[0]; html = this.replaceMixStringWithTag(html, match, tagData).html; // value = value.replace(match, "[[" + tagData.value + "]]") } match = ""; } } this.DOM.input.innerHTML = html; this.update(); return s; }, /** * [replaceMixStringWithTag description] * @param {String} html [tagify input string, probably from a textarea] * @param {String} tag [tag string to replace with tag element] * @param {Object} tagData [value, plus any other optional attributes] * @return {Object} [HTML string & tag DOM object] */ replaceMixStringWithTag( html, tag, tagData, tagElm ){ if( tagData && html && html.indexOf(tag) != -1 ){ tagElm = this.createTagElem(tagData); this.value.push(tagData); html = html.replace(tag, tagElm.outerHTML + "⁠") // put a zero-space at the end so the caret won't jump back to the start (when the last input child is a tag) } return {html, tagElm}; }, /** * Add a tag where it might be beside textNodes */ addMixTag( tagData ){ if( !tagData || !this.state.tag ) return; var tag = this.state.tag.prefix + this.state.tag.value, iter = document.createNodeIterator(this.DOM.input, NodeFilter.SHOW_TEXT), textnode, tagElm, idx, maxLoops = 100, replacedNode; while( textnode = iter.nextNode() ){ if( !maxLoops-- ) break; if( textnode.nodeType === Node.TEXT_NODE ){ // get the index of which the tag (string) is within the textNode (if at all) idx = textnode.nodeValue.indexOf(tag); if( idx == -1 ) continue; replacedNode = textnode.splitText(idx); this.settings.transformTag.call(this, tagData); tagElm = this.createTagElem(tagData); // clean up the tag's string and put tag element instead replacedNode.nodeValue = replacedNode.nodeValue.replace(tag, ''); textnode.parentNode.insertBefore(tagElm, replacedNode); tagElm.insertAdjacentHTML('afterend', '⁠'); } } if( tagElm ){ this.value.push(tagData); this.update(); this.trigger('add', this.extend({}, {index:this.value.length, tag:tagElm}, tagData)); } this.state.tag = null; }, /** * 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 or just Array of Strings] * @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags] * @param {Boolean} skipInvalid [do not add, mark & remove invalid tags] * @return {Array} Array of DOM elements (tags) */ addTags( tagsItems, clearInput, skipInvalid = this.settings.skipInvalid ){ var tagElems = []; if( !tagsItems || !tagsItems.length ){ console.warn('[addTags]', 'no tags to add:', tagsItems) return tagElems; } tagsItems = this.normalizeTags.call(this, tagsItems); // converts Array/String/Object to an Array of Objects if( this.settings.mode == 'mix' ) return this.addMixTag(tagsItems[0]); this.DOM.input.removeAttribute('style'); tagsItems.forEach(tagData => { var tagValidation, tagElm, tagElmParams = {} // shallow-clone tagData so later modifications will not apply to the source tagData = Object.assign({}, tagData); this.settings.transformTag.call(this, tagData); ///////////////// ( validation )////////////////////// tagValidation = this.maxTagsReached() || this.validateTag.call(this, tagData.value); if( tagValidation !== true ){ if( skipInvalid ) return tagElmParams["aria-invalid"] = true tagElmParams.class = (tagData.class || '') + ' tagify--notAllowed'; tagElmParams.title = tagValidation; this.markTagByValue(tagData.value); this.trigger("invalid", {data:tagData, index:this.value.length, message:tagValidation}); } ///////////////////////////)////////////////////////// // add accessibility attributes tagElmParams.role = "tag"; if( tagData.readonly ) tagElmParams["aria-readonly"] = true // Create tag HTML element tagElm = this.createTagElem( this.extend({}, tagData, tagElmParams) ); tagElems.push(tagElm); // add the tag to the component's DOM appendTag.call(this, tagElm); if( tagValidation === true ){ // update state this.value.push(tagData); this.update(); this.DOM.scope.classList.toggle('hasMaxTags', this.value.length >= this.settings.maxTags); this.trigger('add', { tag:tagElm, index:this.value.length - 1, data:tagData }); } else if( !this.settings.keepInvalidTags ){ // remove invalid tags (if "keepInvalidTags" is set to "false") setTimeout(() => { this.removeTag(tagElm, true) }, 1000); } }) if( tagsItems.length && clearInput ){ this.input.set.call(this); } /** * appened (validated) tag to the component's DOM scope * @return {[type]} [description] */ function appendTag(tagElm){ var insertBeforeNode = this.DOM.scope.lastElementChild; if( insertBeforeNode === this.DOM.input ) this.DOM.scope.insertBefore(tagElm, insertBeforeNode); else this.DOM.scope.appendChild(tagElm); } return tagElems }, minify( html ){ return html.replace( new RegExp( "\>[\r\n ]+\<" , "g" ) , "><" ); }, /** * 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, v = this.escapeHTML(tagData.value), template = this.settings.templates.tag.call(this, v, tagData); if( this.settings.readonly ) tagData.readonly = true; template = this.minify(template); tagElm = this.parseHTML(template); return tagElm; }, /** * Removes a tag * @param {Object|String} tagElm [DOM element or a String value. if undefined or null, remove last added tag] * @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 {Number} tranDuration [Transition duration in MS] */ removeTag( tagElm = this.getLastTag(), silent, tranDuration = 250 ){ if( typeof tagElm == 'string' ) tagElm = this.getTagElmByValue(tagElm) if( !(tagElm instanceof HTMLElement) ) return; var tagData, tagIdx = this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent) if( tranDuration && tranDuration > 10 ) animation() else removeNode(); 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', { tag:tagElm, index:tagIdx, data:tagData }); this.dropdown.render.call(this); } function animation(){ tagElm.style.width = parseFloat(window.getComputedStyle(tagElm).width) + 'px'; document.body.clientTop; // force repaint for the width to take affect before the "hide" class below tagElm.classList.add('tagify--hide'); // manual timeout (hack, since transitionend cannot be used because of hover) setTimeout(removeNode, 400); } function removeNode(){ if( !tagElm.parentNode ) return tagElm.parentNode.removeChild(tagElm) } }, removeAllTags(){ this.value = []; this.update(); Array.prototype.slice.call(this.getTagElms()).forEach(elm => elm.parentNode.removeChild(elm)); }, 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) ) s += " " + propName + (data[propName] ? `="${data[propName]}"` : ""); } return s; }, /** * update the origianl (hidden) input field's value * see - https://stackoverflow.com/q/50957841/104380 */ update(){ this.DOM.originalInput.value = this.settings.mode == 'mix' ? this.getMixedTagsAsString() : this.value.length ? JSON.stringify(this.value) : "" }, getMixedTagsAsString(){ var result = ""; this.DOM.input.childNodes.forEach((node, i) => { if( node.nodeType == 1 ){ if( node.classList.contains("tagify__tag") ) result += "[[" + node.getAttribute("value") + "]]" } else result += node.textContent; }) return result; }, /** * Dropdown controller * @type {Object} */ dropdown : { init(){ this.DOM.dropdown = this.dropdown.build.call(this); }, build(){ var {position, classname} = this.settings.dropdown, _className = `${position == 'manual' ? "" : "tagify__dropdown"} ${classname}`.trim(), template = ``; return this.parseHTML(template); }, show( value ){ var listHTML, isManual = this.settings.dropdown.position == 'manual'; if( !this.settings.whitelist.length ) return; // if no value was supplied, show all the "whitelist" items in the dropdown // @type [Array] listItems // TODO: add a Setting to control items' sort order for "listItems" this.suggestedListItems = this.dropdown.filterListItems.call(this, value); // hide suggestions list if no suggestions were matched if( !this.suggestedListItems.length ){ this.input.autocomplete.suggest.call(this); this.dropdown.hide.call(this); return; } listHTML = this.dropdown.createListHTML.call(this, this.suggestedListItems); this.DOM.dropdown.innerHTML = this.minify(listHTML); // if "enforceWhitelist" is "true", highlight the first suggested item this.settings.enforceWhitelist && !isManual && this.dropdown.highlightOption.call(this, this.DOM.dropdown.querySelector('.tagify__dropdown__item')); this.DOM.scope.setAttribute("aria-expanded", true) this.trigger("dropdown:show", this.DOM.dropdown); // if the dropdown has yet to be appended to the document, // append the dropdown to the body element & handle events if( !document.body.contains(this.DOM.dropdown) ){ if( !isManual ){ this.dropdown.position.call(this); document.body.appendChild(this.DOM.dropdown); this.events.binding.call(this, false); // unbind the main events } this.dropdown.events.binding.call(this); } }, hide( force ){ var {scope, dropdown} = this.DOM, isManual = this.settings.dropdown.position == 'manual' && !force; if( !dropdown || !document.body.contains(dropdown) || isManual ) return; 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 scope.setAttribute("aria-expanded", false) dropdown.parentNode.removeChild(dropdown); this.trigger("dropdown:hide", dropdown); }, /** * renders data into the suggestions list (mainly used to update the list when removing tags, so they will be re-added to the list. not efficient) */ render(){ this.suggestedListItems = this.dropdown.filterListItems.call(this, ''); var listHTML = this.dropdown.createListHTML.call(this, this.suggestedListItems); this.DOM.dropdown.innerHTML = this.minify(listHTML); }, 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'; if( this.settings.dropdown.position != 'manual' ){ window[action]('resize', _CBR.position); window[action]('keydown', _CBR.onKeyDown); } window[action]('mousedown', _CBR.onClick); this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver); // this.DOM.dropdown[action]('click', _CBR.onClick); }, callbacks : { onKeyDown(e){ // get the "active" element, and if there was none (yet) active, use first child var activeListElm = this.DOM.dropdown.querySelector("[class$='--active']"), selectedElm = activeListElm || this.DOM.dropdown.children[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' ? "previous" : "next") + "ElementSibling"]; // if no element was found, loop if( !selectedElm ) selectedElm = this.DOM.dropdown.children[e.key == 'ArrowUp' || e.key == 'Up' ? this.DOM.dropdown.children.length - 1 : 0]; this.dropdown.highlightOption.call(this, selectedElm, true); break; case 'Escape' : case 'Esc': // IE11 this.dropdown.hide.call(this); break; case 'ArrowRight' : case 'Tab' : e.preventDefault(); if( !this.input.autocomplete.set.call(this, selectedElm ? selectedElm.textContent : null) ) return false; case 'Enter' : e.preventDefault(); if( activeListElm ){ newValue = this.suggestedListItems[this.getNodeIndex(activeListElm)] || this.input.value; this.addTags( [newValue], true ); this.dropdown.hide.call(this); setTimeout(() => this.DOM.input.focus(), 100); return false; } else{ this.addTags(this.input.value, true) } } }, onMouseOver(e){ // event delegation check if( e.target.className.includes('__item') ) this.dropdown.highlightOption.call(this, e.target); }, onClick(e){ var value, listItemElm; if( e.button != 0 || e.target == this.DOM.dropdown ) return; // allow only mouse left-clicks listItemElm = e.target.closest(".tagify__dropdown__item"); if( listItemElm ){ value = this.suggestedListItems[this.getNodeIndex(listItemElm)] || this.input.value; this.addTags([value], true); setTimeout(() => this.DOM.input.focus(), 100); } this.dropdown.hide.call(this); } } }, highlightOption( elm, adjustScroll ){ if( !elm ) return; var className = "tagify__dropdown__item--active", value; // focus casues a bug in Firefox with the placeholder been shown on the input element // if( this.settings.dropdown.position != 'manual' ) // elm.focus(); this.DOM.dropdown.querySelectorAll("[class$='--active']").forEach(activeElm => { activeElm.classList.remove(className) activeElm.removeAttribute("aria-selected") }) // this.DOM.dropdown.querySelectorAll("[class$='--active']").forEach(activeElm => activeElm.classList.remove(className)); elm.classList.add(className); elm.setAttribute("aria-selected", true) if( adjustScroll ) elm.parentNode.scrollTop = elm.clientHeight + elm.offsetTop - elm.parentNode.clientHeight // set the first item from the suggestions list as the autocomplete value if( this.settings.autoComplete && !this.settings.dropdown.fuzzySearch ){ value = this.suggestedListItems[this.getNodeIndex(elm)].value || this.input.value; this.input.autocomplete.suggest.call(this, value); } }, /** * returns an HTML string of the suggestions' list items * @return {[type]} [description] */ filterListItems( value ){ var list = [], whitelist = this.settings.whitelist, suggestionsCount = this.settings.dropdown.maxItems || Infinity, whitelistItem, valueIsInWhitelist, whitelistItemValueIndex, searchBy, isDuplicate, i = 0; if( !value ){ return whitelist .filter(item => this.isTagDuplicate(item.value || item) == -1 ) // don't include tags which have already been added. .slice(0, suggestionsCount); // respect "maxItems" dropdown setting } for( ; i < whitelist.length; i++ ){ whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value:whitelist[i] }; //normalize value as an Object searchBy = ((whitelistItem.searchBy || '') + ' ' + whitelistItem.value).toLowerCase(); whitelistItemValueIndex = searchBy.indexOf( value.toLowerCase() ); valueIsInWhitelist = this.settings.dropdown.fuzzySearch ? whitelistItemValueIndex >= 0 : whitelistItemValueIndex == 0; isDuplicate = !this.settings.duplicates && this.isTagDuplicate(whitelistItem.value) > -1; // match for the value within each "whitelist" item if( valueIsInWhitelist && !isDuplicate && 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 ){ var getItem = this.settings.templates.dropdownItem.bind(this); return list.map(getItem).join(""); } } }