import { sameStr, removeCollectionProp, isObject, parseHTML, removeTextChildNodes, escapeHTML, extend } from './parts/helpers' import dropdownMethods from './parts/dropdown' import DEFAULTS from './parts/defaults' import templates from './parts/templates' import EventDispatcher from './parts/EventDispatcher' import events, { triggerChangeEvent } from './parts/events' /** * @constructor * @param {Object} input DOM element * @param {Object} settings settings object */ function Tagify( input, settings ){ if( !input ){ console.warn('Tagify: ', 'input element not found', input) return this } if( input.previousElementSibling && input.previousElementSibling.classList.contains('tagify') ){ console.warn('Tagify: ', 'input element is already Tagified', input) return this } extend(this, EventDispatcher(this)) this.isFirefox = typeof InstallTrigger !== 'undefined' this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility this.applySettings(input, settings||{}) this.state = { inputText: '', editing : false, actions : {}, // UI actions for state-locking mixMode : {}, dropdown: {}, flaggedTags: {} // in mix-mode, when a string is detetced as potential tag, and the user has chocen to close the suggestions dropdown, keep the record of the tasg here } 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.build(input) this.getCSSVars() this.loadOriginalValues() this.events.customBinding.call(this); this.events.binding.call(this) input.autofocus && this.DOM.input.focus() } Tagify.prototype = { dropdown: dropdownMethods, TEXTS : { empty : "empty", exceed : "number of tags exceeded", pattern : "pattern mismatch", duplicate : "already exists", notAllowed : "not allowed" }, DEFAULTS, customEventsList : ['change', 'add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select', 'dropdown:updated', 'dropdown:noMatch'], trim(text){ return this.settings.trim ? text.trim() : text }, // expose this handy utility function parseHTML, templates, parseTemplate(template, data){ template = this.settings.templates[template] || template; return this.parseHTML( template.apply(this, data) ) }, applySettings( input, settings ){ this.DEFAULTS.templates = this.templates; var _s = this.settings = extend({}, this.DEFAULTS, settings); _s.readonly = input.hasAttribute('readonly') // if "readonly" do not include an "input" element inside the Tags component _s.placeholder = input.getAttribute('placeholder') || _s.placeholder || "" _s.required = input.hasAttribute('required') if( this.isIE ) _s.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(_s.delimiters) if( attrVal instanceof Array ) _s[name] = attrVal } }) // backward-compatibility for old version of "autoComplete" setting: if( "autoComplete" in settings && !isObject(settings.autoComplete) ){ _s.autoComplete = this.DEFAULTS.autoComplete _s.autoComplete.enabled = settings.autoComplete } if( _s.mode == 'mix' ){ _s.autoComplete.rightKey = true _s.delimiters = settings.delimiters || null // default dlimiters in mix-mode must be NULL } if( input.pattern ) try { _s.pattern = new RegExp(input.pattern) } catch(e){} // Convert the "delimiters" setting into a REGEX object if( this.settings.delimiters ){ try { _s.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( _s.mode == 'select' ) _s.dropdown.enabled = 0 _s.dropdown.appendTarget = settings.dropdown && settings.dropdown.appendTarget ? settings.dropdown.appendTarget : document.body }, /** * 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] !== undefined ) s += " " + propName + (data[propName] !== undefined ? `="${data[propName]}"` : ""); } return s; }, /** * 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} } if( node.getBoundingClientRect ) return node.getBoundingClientRect() } 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; if( this.settings.mixMode.integrated ){ DOM.originalInput = null; DOM.scope = input; DOM.input = input; } else { DOM.originalInput = input DOM.scope = this.parseTemplate('wrapper', [input, this.settings]) DOM.input = DOM.scope.querySelector('.' + this.settings.classNames.input) 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) clearTimeout(this.dropdownHide__bindEventsTimeout) }, /** * if the original input had any values, add them as tags */ loadOriginalValues( value ){ var lastChild, _s = this.settings; value = value || (_s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value) if( value ){ this.removeAllTags() if( _s.mode == 'mix' ){ this.parseMixTags(value.trim()) lastChild = this.DOM.input.lastChild; if( !lastChild || lastChild.tagName != 'BR' ) this.DOM.input.insertAdjacentHTML('beforeend', '
') } else{ try{ if( JSON.parse(value) instanceof Array ) value = JSON.parse(value) } catch(err){} this.addTags(value).forEach(tag => tag && tag.classList.add(_s.classNames.tagNoAnimation)) } } else this.postUpdate() this.state.lastOriginalValueReported = _s.mixMode.integrated ? '' : this.DOM.originalInput.value this.state.loadedOriginalValues = true }, cloneEvent(e){ var clonedEvent = {} for( var v in e ) clonedEvent[v] = e[v] return clonedEvent }, /** * Toogle global loading state on/off * Useful when fetching async whitelist while user is typing * @param {Boolean} isLoading */ loading( isLoading ){ this.state.isLoading = isLoading // IE11 doesn't support toggle with second parameter this.DOM.scope.classList[isLoading?"add":"remove"](this.settings.classNames.scopeLoading) return this }, /** * Toogle specieif tag loading state on/off * @param {Boolean} isLoading */ tagLoading( tagElm, isLoading ){ if( tagElm ) // IE11 doesn't support toggle with second parameter tagElm.classList[isLoading?"add":"remove"](this.settings.classNames.tagLoading) return this }, /** * Toggles class on the main tagify container ("scope") * @param {String} className * @param {Boolean} force */ toggleClass( className, force ){ // force = force === undefined ? this.DOM.scope.classList.toggle(className, force) }, toggleFocusClass( force ){ this.toggleClass(this.settings.classNames.focus, !!force) }, triggerChangeEvent, events, fixFirefoxLastTagNoCaret(){ return // seems to be fixed in newer version of FF, so retiring below code (for now) var inputElm = this.DOM.input if( this.isFirefox && inputElm.childNodes.length && inputElm.lastChild.nodeType == 1 ){ inputElm.appendChild(document.createTextNode("\u200b")) this.setRangeAtStartEnd(true) return true } }, placeCaretAfterNode( node ){ if( !node ) return var nextSibling = node.nextSibling, sel = window.getSelection(), range = sel.getRangeAt(0); if (sel.rangeCount) { range.setStartBefore(nextSibling || node); range.setEndBefore(nextSibling || node); sel.removeAllRanges(); sel.addRange(range); } }, insertAfterTag( tagElm, newNode ){ newNode = newNode || this.settings.mixMode.insertAfterTag; if( !tagElm || !newNode ) return newNode = typeof newNode == 'string' ? document.createTextNode(newNode) : newNode tagElm.appendChild(newNode) tagElm.parentNode.insertBefore(newNode, tagElm.nextSibling) return newNode }, /** * Enters a tag into "edit" mode * @param {Node} tagElm the tag element to edit. if nothing specified, use last last */ editTag( tagElm, opts ){ tagElm = tagElm || this.getLastTag() opts = opts || {} this.dropdown.hide.call(this) var _s = this.settings; function getEditableElm(){ return tagElm.querySelector('.' + _s.classNames.tagText) } var editableElm = getEditableElm(), tagIdx = this.getNodeIndex(tagElm), tagData = this.tagData(tagElm), _CB = this.events.callbacks, that = this, isValid = true, delayed_onEditTagBlur = function(){ setTimeout(() => _CB.onEditTagBlur.call(that, getEditableElm())) } if( !editableElm ){ console.warn('Cannot find element in Tag template: .', _s.classNames.tagText); return; } if( tagData instanceof Object && "editable" in tagData && !tagData.editable ) return editableElm.setAttribute('contenteditable', true) tagElm.classList.add( _s.classNames.tagEditing ) // cache the original data, on the DOM node, before any modification ocurs, for possible revert this.tagData(tagElm, { __originalData: extend({}, tagData), __originalHTML: tagElm.innerHTML }) editableElm.addEventListener('focus', _CB.onEditTagFocus.bind(this, tagElm)) editableElm.addEventListener('blur', delayed_onEditTagBlur) editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm)) editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e, tagElm)) editableElm.focus() this.setRangeAtStartEnd(false, editableElm) if( !opts.skipValidation ) isValid = this.editTagToggleValidity(tagElm, tagData.value) editableElm.originalIsValid = isValid this.trigger("edit:start", { tag:tagElm, index:tagIdx, data:tagData, isValid }) return this }, editTagToggleValidity( tagElm, value ){ var tagData = this.tagData(tagElm), toggleState; if( !tagData ){ console.warn("tag has no data: ", tagElm, tagData) return; } toggleState = !!(tagData.__isValid && tagData.__isValid != true); //this.validateTag(tagData); tagElm.classList.toggle(this.settings.classNames.tagInvalid, toggleState) return tagData.__isValid }, onEditTagDone(tagElm, tagData){ tagElm = tagElm || this.state.editing.scope tagData = tagData || {} var eventData = { tag:tagElm, index:this.getNodeIndex(tagElm), previousData:this.tagData(tagElm), data:tagData }; this.trigger("edit:beforeUpdate", eventData) this.state.editing = false; delete tagData.__originalData delete tagData.__originalHTML if( tagElm && tagData[this.settings.tagTextProp] ){ this.editTagToggleValidity(tagElm) this.replaceTag(tagElm, tagData) } else if(tagElm) this.removeTags(tagElm) this.trigger("edit:updated", eventData) this.dropdown.hide.call(this) // check if any of the current tags which might have been marked as "duplicate" should be now un-marked if( this.settings.keepInvalidTags ) this.reCheckInvalidTags() }, /** * Replaces an exisitng tag with a new one. Used for updating a tag's data * @param {Object} tagElm [DOM node to replace] * @param {Object} tagData [data to create new tag from] */ replaceTag(tagElm, tagData){ if( !tagData || !tagData.value ) tagData = tagElm.__tagifyTagData // if tag is invalid, make the according changes in the newly created element if( tagData.__isValid && tagData.__isValid != true ) extend( tagData, this.getInvalidTagAttrs(tagData, tagData.__isValid) ) var newTag = this.createTagElem(tagData) // update DOM tagElm.parentNode.replaceChild(newTag, tagElm) this.updateValueByDOMTags() }, /** * update "value" (Array of Objects) by traversing all valid tags */ updateValueByDOMTags(){ this.value.length = 0; [].forEach.call(this.getTagElms(), node => { if( node.classList.contains(this.settings.classNames.tagNotAllowed) ) return this.value.push( this.tagData(node) ) }) 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 ){ start = typeof start == 'number' ? start : !!start node = node || this.DOM.input; node = node.lastChild || node; var sel = document.getSelection() try{ if( sel.rangeCount >= 1 ){ ['Start', 'End'].forEach(pos => sel.getRangeAt(0)["set" + pos](node, start ? start : node.length) ) } } catch(err){ console.warn("Tagify: ", err) } }, /** * injects nodes/text at caret position, which is saved on the "state" when "blur" event gets triggered * @param {Node} injectedNode [the node to inject at the caret position] * @param {Object} selection [optional selection Object. must have "anchorNode" & "anchorOffset"] */ injectAtCaret( injectedNode, range ){ range = range || this.state.selection.range if( !range ) return; if( typeof injectedNode == 'string' ) injectedNode = document.createTextNode(injectedNode); range.deleteContents() range.insertNode(injectedNode) this.setRangeAtStartEnd(false, injectedNode) this.updateValueByDOMTags() // updates internal "this.value" this.update() // updates original input/textarea return this }, /** * input bridge for accessing & setting * @type {Object} */ input : { set( s = '', updateDOM = true ){ var hideDropdown = this.settings.dropdown.closeOnSelect this.state.inputText = s if( updateDOM ) this.DOM.input.innerHTML = s; if( !s && hideDropdown ) this.dropdown.hide.bind(this) 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.state.inputText || this.validateTag({value:this.state.inputText}) === true; this.DOM.input.classList.toggle(this.settings.classNames.inputInvalid, !isValid) return isValid }, // remove any child DOM elements that aren't of type TEXT (like
) 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
but no newline symbols (\n), and this will // result in tags not 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 if( this.settings.trim ) v = v.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 ? ''+data.value : '', suggestionStart = suggestedText.substr(0, this.state.inputText.length).toLowerCase(), suggestionTrimmed = suggestedText.substring(this.state.inputText.length); if( !suggestedText || !this.state.inputText || suggestionStart != this.state.inputText.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.state.inputText + 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; } } }, /** * returns the index of the the tagData within the "this.value" array collection. * since values should be unique, it is suffice to only search by "value" property * @param {Object} tagData */ getTagIdx( tagData ){ return this.value.findIndex(item => item.value == tagData.value ) }, getNodeIndex( node ){ var index = 0; if( node ) while( (node = node.previousElementSibling) ) index++; return index; }, getTagElms( ...classess ){ var classname = ['.' + this.settings.classNames.tag, ...classess].join('.') return [].slice.call(this.DOM.scope.querySelectorAll(classname)) // convert nodeList to Array - https://stackoverflow.com/a/3199627/104380 }, /** * gets the last non-readonly, not-in-the-proccess-of-removal tag */ getLastTag(){ var lastTag = this.DOM.scope.querySelectorAll(`.${this.settings.classNames.tag}:not(.${this.settings.classNames.tagHide}):not([readonly])`); return lastTag[lastTag.length - 1]; }, /** Setter/Getter * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data * @param {Node} tagElm * @param {Object} data */ tagData(tagElm, data, override){ if( !tagElm ){ console.warn("tag elment doesn't exist",tagElm, data) return data } if( data ) tagElm.__tagifyTagData = override ? data : extend({}, tagElm.__tagifyTagData || {}, data) return tagElm.__tagifyTagData }, /** * Searches if any tag with a certain value already exis * @param {String/Object} v [text value / tag data object] * @return {Boolean} */ isTagDuplicate( value, caseSensitive ){ var duplications, _s = this.settings; // duplications are irrelevant for this scenario if( _s.mode == 'select' ) return false duplications = this.value.reduce((acc, item) => sameStr( this.trim(""+value), item.value, caseSensitive || _s.dropdown.caseSensitive ) ? acc+1 : acc , 0) return duplications }, getTagIndexByValue( value ){ var indices = []; this.getTagElms().forEach((tagElm, i) => { if( sameStr( this.trim(tagElm.textContent), value, this.settings.dropdown.caseSensitive ) ) indices.push(i) }) return indices; }, getTagElmByValue( value ){ var tagIdx = this.getTagIndexByValue(value)[0] return this.getTagElms()[tagIdx] }, /** * Temporarily marks a tag element (by value or Node argument) * @param {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings] */ flashTag( tagElm ){ if( tagElm ){ tagElm.classList.add(this.settings.classNames.tagFlash) setTimeout(() => { tagElm.classList.remove(this.settings.classNames.tagFlash) }, 100) } }, /** * checks if text is in the blacklist */ isTagBlacklisted( v ){ v = this.trim(v.toLowerCase()); return this.settings.blacklist.filter(x => (""+x).toLowerCase() == v).length; }, /** * checks if text is in the whitelist */ isTagWhitelisted( v ){ return !!this.getWhitelistItem(v) /* return this.settings.whitelist.some(item => typeof v == 'string' ? sameStr(this.trim(v), (item.value || item)) : sameStr(JSON.stringify(item), JSON.stringify(v)) ) */ }, /** * Returns the first whitelist item matched, by value (if match found) * @param {String} value [text to match by] */ getWhitelistItem( value, prop, whitelist ){ var result, prop = prop || 'value', _s = this.settings, whitelist = whitelist || _s.whitelist, _cs = _s.dropdown.caseSensitive; whitelist.some(_wi => { var _wiv = typeof _wi == 'string' ? _wi : _wi[prop], isSameStr = sameStr(_wiv, value, _cs) if( isSameStr ){ result = typeof _wi == 'string' ? {value:_wi} : _wi return true } }) // first iterate the whitelist, try find maches by "value" and if that fails // and a "tagTextProp" is set to be other than "value", try that also if( !result && prop == 'value' && _s.tagTextProp != 'value' ){ // if found, adds the first which matches result = this.getWhitelistItem(value, _s.tagTextProp, whitelist) } return result }, /** * 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( tagData ){ var _s = this.settings, // when validating a tag in edit-mode, need to take "tagTextProp" into consideration prop = "value" in tagData ? "value" : _s.tagTextProp, v = this.trim(tagData[prop] + ""); // check for definitive empty value if( !(tagData[prop]+"").trim() ) return this.TEXTS.empty; // check if pattern should be used and if so, use it to test the value if( _s.pattern && _s.pattern instanceof RegExp && !(_s.pattern.test(v)) ) return this.TEXTS.pattern; // if duplicates are not allowed and there is a duplicate if( !_s.duplicates && this.isTagDuplicate(v, this.state.editing) ) return this.TEXTS.duplicate; if( this.isTagBlacklisted(v) || (_s.enforceWhitelist && !this.isTagWhitelisted(v)) ) return this.TEXTS.notAllowed; return true }, getInvalidTagAttrs(tagData, validation){ return { "aria-invalid" : true, "class": `${tagData.class || ''} ${this.settings.classNames.tagNotAllowed}`.trim(), "title": validation } }, hasMaxTags(){ if( this.value.length >= this.settings.maxTags ) return this.TEXTS.exceed; return false; }, setReadonly( isReadonly ){ var _s = this.settings; document.activeElement.blur() // exists possible edit-mode _s.readonly = isReadonly this.DOM.scope[(isReadonly?'set':'remove') + 'Attribute']('readonly', true) if( _s.mode == 'mix' ){ this.DOM.input.contentEditable = !isReadonly } }, /** * 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, tagTextProp, enforceWhitelist} = this.settings, whitelistMatches = [], whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false, // checks if this is a "collection", meanning an Array of Objects isArray = tagsItems instanceof Array, mapStringToCollection = s => (s+"").split(delimiters).filter(n => n).map(v => ({ [tagTextProp]:this.trim(v) })) if( typeof tagsItems == 'number' ) tagsItems = tagsItems.toString() // if the argument 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) } // is is an Array of Strings, convert to an Array of Objects else if( isArray ){ // flatten the 2D array tagsItems = [].concat(...tagsItems.map(item => item.value ? item // mapStringToCollection(item.value).map(newItem => ({...item,...newItem})) : 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 whitelistMatchesValues = whitelistMatches.map(a=>a.value) // if suggestions are shown, they are already filtered, so it's easier to use them, // because the whitelist might also include items which have already been added var filteredList = this.dropdown.filterListItems.call(this, item[tagTextProp], { exact:true }) // also filter out items which have already been matches in previous iterations .filter(filteredItem => !whitelistMatchesValues.includes(filteredItem.value)) // get the best match out of list of possible matches. // if there was a single item in the filtered list, use that one var matchObj = filteredList.length > 1 ? this.getWhitelistItem(item[tagTextProp], tagTextProp, filteredList) : filteredList[0] if( matchObj && matchObj instanceof Object ){ whitelistMatches.push( matchObj ) // set the Array (with the found Object) as the new value } else if( mode != 'mix' && !enforceWhitelist ){ if( item.value == undefined ) item.value = item[tagTextProp] whitelistMatches.push(item) } }) // if( whitelistMatches.length ) tagsItems = whitelistMatches } return tagsItems; }, /** * Parse the initial value of a textarea (or input) element and generate mixed text w/ tags * https://stackoverflow.com/a/57598892/104380 * @param {String} s */ parseMixTags( s ){ var {mixTagsInterpolator, duplicates, transformTag, enforceWhitelist, maxTags, tagTextProp} = this.settings, tagsDataSet = []; s = s.split(mixTagsInterpolator[0]).map((s1, i) => { var s2 = s1.split(mixTagsInterpolator[1]), preInterpolated = s2[0], maxTagsReached = tagsDataSet.length == maxTags, textProp, tagData, tagElm; try{ // skip numbers and go straight to the "catch" statement if( preInterpolated == +preInterpolated ) throw Error tagData = JSON.parse(preInterpolated) } catch(err){ tagData = this.normalizeTags(preInterpolated)[0] //{value:preInterpolated} } if( !maxTagsReached && s2.length > 1 && (!enforceWhitelist || this.isTagWhitelisted(tagData.value)) && !(!duplicates && this.isTagDuplicate(tagData.value)) ){ transformTag.call(this, tagData) // in case "tagTextProp" setting is set to other than "value" and this tag does not have this prop textProp = tagData[tagTextProp] ? tagTextProp : 'value' tagData[textProp] = this.trim(tagData[textProp]) tagElm = this.createTagElem(tagData) tagsDataSet.push( tagData ) tagElm.classList.add(this.settings.classNames.tagNoAnimation) s2[0] = tagElm.outerHTML //+ "⁠" // put a zero-space at the end so the caret won't jump back to the start (when the last input's child element is a tag) this.value.push(tagData) } else if(s1) return i ? mixTagsInterpolator[0] + s1 : s1 return s2.join('') }).join('') this.DOM.input.innerHTML = s this.DOM.input.appendChild(document.createTextNode('')) this.DOM.input.normalize() this.getTagElms().forEach((elm, idx) => this.tagData(elm, tagsDataSet[idx])) this.update({withoutChangeEvent:true}) return s }, /** * For mixed-mode: replaces a text starting with a prefix with a wrapper element (tag or something) * First there *has* to be a "this.state.tag" which is a string that was just typed and is staring with a prefix */ replaceTextWithNode( newWrapperNode, strToReplace ){ if( !this.state.tag && !strToReplace ) return; strToReplace = strToReplace || this.state.tag.prefix + this.state.tag.value; var idx, nodeToReplace, selection = window.getSelection(), nodeAtCaret = selection.anchorNode, firstSplitOffset = this.state.tag.delimiters ? this.state.tag.delimiters.length : 0; // STEP 1: ex. replace #ba with the tag "bart" where "|" is where the caret is: // CURRENT STATE: "foo #ba #ba| #ba" // split the text node at the index of the caret nodeAtCaret.splitText(selection.anchorOffset - firstSplitOffset) // node 0: "foo #ba #ba|" // node 1: " #ba" // get index of LAST occurence of "#ba" idx = nodeAtCaret.nodeValue.lastIndexOf(strToReplace) nodeToReplace = nodeAtCaret.splitText(idx) // node 0: "foo #ba " // node 1: "#ba" <- nodeToReplace newWrapperNode && nodeAtCaret.parentNode.replaceChild(newWrapperNode, nodeToReplace) // must NOT normalize contenteditable or it will cause unwanetd issues: // https://monosnap.com/file/ZDVmRvq5upYkidiFedvrwzSswegWk7 // nodeAtCaret.parentNode.normalize() return true; }, /** * For selecting a single option (not used for multiple tags, but for "mode:select" only) * @param {Object} tagElm Tag DOM node * @param {Object} tagData Tag data */ selectTag( tagElm, tagData ){ if( this.settings.enforceWhitelist && !this.isTagWhitelisted(tagData.value) ) return this.input.set.call(this, tagData.value, true) // place the caret at the end of the input, only if a dropdown option was selected (and not by manually typing another value and clicking "TAB") if( this.state.actions.selectOption ) setTimeout(this.setRangeAtStartEnd.bind(this)) if( this.getLastTag() ) this.replaceTag(this.getLastTag(), tagData) else this.appendTag(tagElm) this.value[0] = tagData this.trigger('add', { tag:tagElm, data:tagData }) this.update() return [tagElm] }, /** * add an empty "tag" element in an editable state */ addEmptyTag( initialData ){ var tagData = extend({ value:"" }, initialData || {}), tagElm = this.createTagElem(tagData) this.tagData(tagElm, tagData) // add the tag to the component's DOM this.appendTag(tagElm) this.editTag(tagElm, {skipValidation:true}) }, /** * 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 = [], _s = this.settings; if( !tagsItems || tagsItems.length == 0 ){ // is mode is "select" clean all tags if( _s.mode == 'select' ) this.removeAllTags() return tagElems } // converts Array/String/Object to an Array of Objects tagsItems = this.normalizeTags(tagsItems) if( _s.mode == 'mix' ){ return this.addMixTags(tagsItems) } if( _s.mode == 'select' ) clearInput = false this.DOM.input.removeAttribute('style') tagsItems.forEach(tagData => { var tagElm, tagElmParams = {}, originalData = Object.assign({}, tagData, {value:tagData.value+""}); // shallow-clone tagData so later modifications will not apply to the source tagData = Object.assign({}, originalData) tagData.__isValid = this.hasMaxTags() || this.validateTag(tagData) _s.transformTag.call(this, tagData) if( tagData.__isValid !== true ){ if( skipInvalid ) return // originalData is kept because it might be that this tag is invalid because it is a duplicate of another, // and if that other tags is edited/deleted, this one should be re-validated and if is no more a duplicate - restored extend(tagElmParams, this.getInvalidTagAttrs(tagData, tagData.__isValid), {__preInvalidData:originalData}) if( tagData.__isValid == this.TEXTS.duplicate ) // mark, for a brief moment, the tag (this this one) which THIS CURRENT tag is a duplcate of this.flashTag( this.getTagElmByValue(tagData.value) ) } ///////////////////////////////////////////////////// if( tagData.readonly ) tagElmParams["aria-readonly"] = true // Create tag HTML element tagElm = this.createTagElem( extend({}, tagData, tagElmParams) ) tagElems.push(tagElm) // mode-select overrides if( _s.mode == 'select' ){ return this.selectTag(tagElm, tagData) } // add the tag to the component's DOM this.appendTag(tagElm) if( tagData.__isValid && tagData.__isValid === true ){ // update state this.value.push(tagData) this.update() this.trigger('add', {tag:tagElm, index:this.value.length - 1, data:tagData}) } else{ this.trigger("invalid", {data:tagData, index:this.value.length, tag:tagElm, message:tagData.__isValid}) if( !_s.keepInvalidTags ) // remove invalid tags (if "keepInvalidTags" is set to "false") setTimeout(() => this.removeTags(tagElm, true), 1000) } this.dropdown.position.call(this) // reposition the dropdown because the just-added tag might cause a new-line }) if( tagsItems.length && clearInput ){ this.input.set.call(this); } this.dropdown.refilter.call(this) return tagElems }, /** * Adds a mix-content tag * @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings] */ addMixTags( tagsItems ){ var _s = this.settings, tagElm, createdFromDelimiters = this.state.tag.delimiters _s.transformTag.call(this, tagsItems[0]) tagsItems[0].prefix = tagsItems[0].prefix || this.state.tag ? this.state.tag.prefix : (_s.pattern.source||_s.pattern)[0]; // TODO: should check if the tag is valid tagElm = this.createTagElem(tagsItems[0]) // tries to replace a taged textNode with a tagElm, and if not able, // insert the new tag to the END if "addTags" was called from outside if( !this.replaceTextWithNode(tagElm) ){ this.DOM.input.appendChild(tagElm) } setTimeout(()=> tagElm.classList.add(this.settings.classNames.tagNoAnimation), 300) this.value.push(tagsItems[0]) this.update() // fixes a firefox bug where if the last child of the input is a tag and not a text, the input cannot get focus (by Tab key) !createdFromDelimiters && setTimeout(() => { var elm = this.insertAfterTag(tagElm) || tagElm; this.placeCaretAfterNode(elm) }, this.isFirefox ? 100 : 0) this.state.tag = null this.trigger('add', extend({}, {tag:tagElm}, {data:tagsItems[0]})) return tagElm }, /** * appened (validated) tag to the component's DOM scope */ 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); }, /** * 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, templateData = extend({}, tagData, { value:escapeHTML(tagData.value+"") }); if( this.settings.readonly ) tagData.readonly = true tagElm = this.parseTemplate('tag', [templateData]) // crucial for proper caret placement when deleting content. if textNodes are allowed as children of // a tag element, a browser bug casues the caret to misplaced inside the tag element (especcially affects "readonly" tags) removeTextChildNodes(tagElm) // while( tagElm.lastChild.nodeType == 3 ) // tagElm.lastChild.parentNode.removeChild(tagElm.lastChild) this.tagData(tagElm, tagData) return tagElm; }, /** * find all invalid tags and re-check them */ reCheckInvalidTags(){ var _s = this.settings, selector = `.${_s.classNames.tag}.${_s.classNames.tagNotAllowed}`, tagElms = this.DOM.scope.querySelectorAll(selector); [].forEach.call(tagElms, node => { var tagData = this.tagData(node), wasNodeDuplicate = node.getAttribute('title') == this.TEXTS.duplicate, isNodeValid = this.validateTag(tagData) === true; // if this tag node was marked as a dulpicate, unmark. (might have been marked as "notAllowed" for other reasons) if( wasNodeDuplicate && isNodeValid ){ if( tagData.__preInvalidData ) tagData = tagData.__preInvalidData else // start fresh tagData = {value:tagData.value} this.replaceTag(node, tagData) } }) }, /** * Removes a tag * @param {Array|Node|String} tagElms [DOM element(s) or a String value. if undefined or null, remove last added tag] * @param {Boolean} silent [A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify] * @param {Number} tranDuration [Transition duration in MS] * TODO: Allow multiple tags to be removed at-once */ removeTags( tagElms, silent, tranDuration ){ var tagsToRemove; tagElms = tagElms && tagElms instanceof HTMLElement ? [tagElms] : tagElms instanceof Array ? tagElms : tagElms ? [tagElms] : [this.getLastTag()] // normalize tagElms array values: // 1. removing invalid items // 2, if an item is String try to get the matching Tag HTML node // 3. get the tag data // 4. return a collection of Objects tagsToRemove = tagElms.reduce((elms, tagElm) => { if( tagElm && typeof tagElm == 'string') tagElm = this.getTagElmByValue(tagElm) if( tagElm ) // because the DOM node might be removed by async animation, the state will be updated while // the node might still be in the DOM, so the "update" method should know which nodes to ignore elms.push({ node: tagElm, idx: this.getTagIdx(this.tagData(tagElm)), // this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent) data: this.tagData(tagElm, {'__removed':true}) }) return elms }, []) tranDuration = typeof tranDuration == "number" ? tranDuration : this.CSSVars.tagHideTransition if( this.settings.mode == 'select' ){ tranDuration = 0; this.input.set.call(this) } // if only a single tag is to be removed if( tagsToRemove.length == 1 ){ if( tagsToRemove[0].node.classList.contains(this.settings.classNames.tagNotAllowed) ) silent = true } if( !tagsToRemove.length ) return; this.settings.hooks.beforeRemoveTag(tagsToRemove, {tagify:this}) .then(() => { function removeNode( tag ){ if( !tag.node.parentNode ) return tag.node.parentNode.removeChild(tag.node) if( !silent ){ // this.removeValueById(tagData.__uid) this.trigger('remove', { tag:tag.node, index:tag.idx, data:tag.data }) this.dropdown.refilter.call(this) this.dropdown.position.call(this) this.DOM.input.normalize() // best-practice when in mix-mode (safe to do always anyways) // check if any of the current tags which might have been marked as "duplicate" should be now un-marked if( this.settings.keepInvalidTags ) this.reCheckInvalidTags() } else if( this.settings.keepInvalidTags ) this.trigger('remove', { tag:tag.node, index:tag.idx }) } function animation( tag ){ tag.node.style.width = parseFloat(window.getComputedStyle(tag.node).width) + 'px' document.body.clientTop // force repaint for the width to take affect before the "hide" class below tag.node.classList.add(this.settings.classNames.tagHide) // manual timeout (hack, since transitionend cannot be used because of hover) setTimeout(removeNode.bind(this), tranDuration, tag) } if( tranDuration && tranDuration > 10 && tagsToRemove.length == 1 ) animation.call(this, tagsToRemove[0]) else tagsToRemove.forEach(removeNode.bind(this)) // update state regardless of animation if( !silent ){ tagsToRemove.forEach(tag => { // remove "__removed" so the comparison in "getTagIdx" could work var tagData = Object.assign({}, tag.data) // shallow clone delete tagData.__removed var tagIdx = this.getTagIdx(tagData) if( tagIdx > -1 ) this.value.splice(tagIdx, 1) }) // that.removeValueById(tagData.__uid) this.update() // update the original input with the current value } } ) .catch(reason => {}) }, removeAllTags(){ this.value = [] if( this.settings.mode == 'mix' ) this.DOM.input.innerHTML = '' else Array.prototype.slice.call(this.getTagElms()).forEach(elm => elm.parentNode.removeChild(elm)) this.dropdown.position.call(this) if( this.settings.mode == 'select' ) this.input.set.call(this) this.update() }, postUpdate(){ var classNames = this.settings.classNames, hasValue = this.settings.mode == 'mix' ? this.settings.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value : this.value.length; this.toggleClass(classNames.hasMaxTags, this.value.length >= this.settings.maxTags) this.toggleClass(classNames.hasNoTags, !this.value.length) this.toggleClass(classNames.empty, !hasValue) }, /** * update the origianl (hidden) input field's value * see - https://stackoverflow.com/q/50957841/104380 */ update( args ){ var inputElm = this.DOM.originalInput, { withoutChangeEvent } = args || {}, value = removeCollectionProp(this.value, ['__isValid', '__removed']); if( !this.settings.mixMode.integrated ){ inputElm.value = this.settings.mode == 'mix' ? this.getMixedTagsAsString(value) : value.length ? this.settings.originalInputValueFormat ? this.settings.originalInputValueFormat(value) : JSON.stringify(value) : "" } this.postUpdate() if( !withoutChangeEvent && this.state.loadedOriginalValues ) this.triggerChangeEvent() }, getMixedTagsAsString(){ var result = "", that = this, i = 0, _interpolator = this.settings.mixTagsInterpolator; function iterateChildren(rootNode){ rootNode.childNodes.forEach((node) => { if( node.nodeType == 1 ){ if( node.classList.contains(that.settings.classNames.tag) && that.tagData(node) ){ if( that.tagData(node).__removed ) return; else result += _interpolator[0] + JSON.stringify( node.__tagifyTagData ) + _interpolator[1] return } if( node.tagName == 'BR' && (node.parentNode == that.DOM.input || node.parentNode.childNodes.length == 1 ) ){ result += "\r\n"; } else if( node.tagName == 'DIV' || node.tagName == 'P' ){ result += "\r\n"; iterateChildren(node) } } else result += node.textContent; }) } iterateChildren(this.DOM.input) return result; } } // legacy support for changed methods names Tagify.prototype.removeTag = Tagify.prototype.removeTags export default Tagify