UNPKG

@yaireo/tagify

Version:

lightweight, efficient Tags input component in Vanilla JS / React / Angular [super customizable, tiny size & top performance]

1,359 lines (1,085 loc) 70 kB
import { sameStr, removeCollectionProp, omit, isObject, parseHTML, removeTextChildNodes, escapeHTML, extend, concatWithoutDups, getUID, isNodeTag, injectAtCaret, placeCaretAfterNode, getSetTagData, fixCaretBetweenTags, logger } from './parts/helpers' import DEFAULTS from './parts/defaults' import _dropdown, { initDropdown } from './parts/dropdown' import { getPersistedData, setPersistedData, clearPersistedData } from './parts/persist' import TEXTS from './parts/texts' import templates from './parts/templates' import EventDispatcher from './parts/EventDispatcher' import events, { triggerChangeEvent } from './parts/events' import { UPDATE_DELAY } from './parts/constants' /** * @constructor * @param {Object} input DOM element * @param {Object} settings settings object */ function Tagify( input, settings ){ if( !input ){ logger.warn('input element not found', input) // return an empty mock of all methods, so the code using tagify will not break // because it might be calling methods even though the input element does not exist const mockInstance = new Proxy(this, { get(){ return () => mockInstance } }) return mockInstance } if( input.__tagify ){ logger.warn('input element is already Tagified - Same instance is returned.', input) return input.__tagify } extend(this, EventDispatcher(this)) this.isFirefox = (/firefox|fxios/i).test(navigator.userAgent) && !(/seamonkey/i).test(navigator.userAgent) this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility settings = settings || {}; this.getPersistedData = getPersistedData(settings.id) this.setPersistedData = setPersistedData(settings.id) this.clearPersistedData = clearPersistedData(settings.id) this.applySettings(input, settings) this.state = { inputText: '', editing : false, composing: 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) initDropdown.call(this) this.getCSSVars() this.loadOriginalValues() this.events.customBinding.call(this) this.events.binding.call(this) input.autofocus && this.DOM.input.focus() input.__tagify = this } Tagify.prototype = { _dropdown, placeCaretAfterNode, getSetTagData, helpers: {sameStr, removeCollectionProp, omit, isObject, parseHTML, escapeHTML, extend, concatWithoutDups, getUID, isNodeTag}, customEventsList : ['change', 'add', 'remove', 'invalid', 'input', 'paste', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:beforeUpdate', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select', 'dropdown:updated', 'dropdown:noMatch', 'dropdown:scroll'], dataProps: ['__isValid', '__removed', '__originalData', '__originalHTML', '__tagId'], // internal-uasge props trim(text){ return this.settings.trim && text && typeof text == "string" ? text.trim() : text }, // expose this handy utility function parseHTML, templates, parseTemplate(template, data){ template = this.settings.templates[template] || template; return parseHTML( template.apply(this, data) ) }, set whitelist( arr ){ const isArray = arr && Array.isArray(arr) this.settings.whitelist = isArray ? arr : [] this.setPersistedData(isArray ? arr : [], 'whitelist') }, get whitelist(){ return this.settings.whitelist }, set userInput( state ){ this.settings.userInput = !!state this.setContentEditable(!!state) }, get userInput(){ return this.settings.userInput }, generateClassSelectors(classNames){ for( let name in classNames ) { let currentName = name; Object.defineProperty(classNames, currentName + "Selector" , { get(){ return "." + this[currentName].split(" ")[0] } }) } }, applySettings( input, settings ){ DEFAULTS.templates = this.templates var mixModeDefaults = { dropdown: { position: "text" } } var mergedDefaults = extend({}, DEFAULTS, (settings.mode == 'mix' ? mixModeDefaults : {})); var _s = this.settings = extend({}, mergedDefaults, settings) _s.disabled = input.hasAttribute('disabled') _s.readonly = _s.readonly || input.hasAttribute('readonly') _s.placeholder = escapeHTML(input.getAttribute('placeholder') || _s.placeholder || "") _s.required = input.hasAttribute('required') this.generateClassSelectors(_s.classNames) 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 = DEFAULTS.autoComplete _s.autoComplete.enabled = settings.autoComplete } if( _s.mode == 'mix' ){ _s.pattern = _s.pattern || /@/; _s.autoComplete.rightKey = true _s.delimiters = settings.delimiters || null // default dlimiters in mix-mode must be NULL // needed for "filterListItems". This assumes the user might have forgotten to manually // define the same term in "dropdown.searchKeys" as defined in "tagTextProp" setting, so // by automatically adding it, tagify is "helping" out, guessing the intesntions of the developer. if( _s.tagTextProp && !_s.dropdown.searchKeys.includes(_s.tagTextProp) ) _s.dropdown.searchKeys.push(_s.tagTextProp) } if( input.pattern ) try { _s.pattern = new RegExp(input.pattern) } catch(e){} // Convert the "delimiters" setting into a REGEX object if( _s.delimiters ){ _s._delimiters = _s.delimiters; try { _s.delimiters = new RegExp(this.settings.delimiters, "g") } catch(e){} } if( _s.disabled || _s.readonly ) _s.userInput = false; this.TEXTS = {...TEXTS, ...(_s.texts || {})} // it makes sense to enable "includeSelectedTags" in "select-mode" if( _s.mode == 'select' ){ _s.dropdown.includeSelectedTags = true } // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode) if( (_s.mode == 'select' && !settings.dropdown?.enabled) || !_s.userInput ){ _s.dropdown.enabled = 0 } // additional override if( _s.disabled ) { _s.dropdown.enabled = false; } _s.dropdown.appendTarget = settings.dropdown?.appendTarget || document.body; if ( _s.dropdown.includeSelectedTags === undefined ) _s.dropdown.includeSelectedTags = _s.duplicates; // get & merge persisted data with current data let persistedWhitelist = this.getPersistedData('whitelist'); if( Array.isArray(persistedWhitelist)) this.whitelist = Array.isArray(_s.whitelist) ? concatWithoutDups(_s.whitelist, persistedWhitelist) : persistedWhitelist; }, /** * Returns a string of HTML element attributes * @param {Object} data [Tag data] */ getAttributes( data ){ var attrs = this.getCustomAttributes(data), s = '', k; for( k in attrs ) s += " " + k + (data[k] !== undefined ? `="${attrs[k]}"` : ""); return s; }, /** * Returns an object of attributes to be used for the templates */ getCustomAttributes( data ){ // only items which are objects have properties which can be used as attributes if( !isObject(data) ) return ''; var output = {}, propName; for( propName in data ){ if( propName.slice(0,2) != '__' && propName != 'class' && data.hasOwnProperty(propName) && data[propName] !== undefined ) output[propName] = escapeHTML(data[propName]) } return output }, setStateSelection(){ var selection = window.getSelection() // save last selection place to be able to inject anything from outside to that specific place var sel = { anchorOffset: selection.anchorOffset, anchorNode : selection.anchorNode, range : selection.getRangeAt && selection.rangeCount && selection.getRangeAt(0) } this.state.selection = sel return sel }, /** * 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, labelWrapper = input.closest('label'); if( this.settings.mixMode.integrated ){ DOM.originalInput = null; DOM.scope = input; DOM.input = input; } else { DOM.originalInput = input DOM.originalInput_tabIndex = input.tabIndex DOM.scope = this.parseTemplate('wrapper', [input, this.settings]) DOM.input = DOM.scope.querySelector(this.settings.classNames.inputSelector) input.parentNode.insertBefore(DOM.scope, input) input.tabIndex = -1; // do not allow focus or typing directly, once tagified } // fixes tagify nested inside a <label> tag from getting focus when clicked on if( labelWrapper ) labelWrapper.setAttribute('for', '') }, /** * revert any changes made by this component */ destroy(){ this.events.unbindGlobal.call(this) this.DOM.scope.parentNode?.removeChild(this.DOM.scope) this.DOM.originalInput.tabIndex = this.DOM.originalInput_tabIndex delete this.DOM.originalInput.__tagify this.dropdown.hide(true) this.removeAllCustomListeners() clearTimeout(this.dropdownHide__bindEventsTimeout) clearInterval(this.listeners.main.originalInputValueObserverInterval) }, /** * if the original input has any values, add them as tags */ loadOriginalValues( value ){ var lastChild, _s = this.settings // temporarily block firing the "change" event on the original input until // this method finish removing current value and adding a new one this.state.blockChangeEvent = true if( value === undefined ){ const persistedOriginalValue = this.getPersistedData('value') // if the field already has a field, trust its the desired // one to be rendered and do not use the persisted one if( persistedOriginalValue && !this.DOM.originalInput.value ) value = persistedOriginalValue else value = _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value } this.removeAllTags() if( value ){ if( _s.mode == 'mix' ){ this.parseMixTags(value) lastChild = this.DOM.input.lastChild // fixes a Chrome bug, when the last node in `mix-mode` is a tag, the caret appears at the far-top-top, outside the field if( !lastChild || lastChild.tagName != 'BR' ) this.DOM.input.insertAdjacentHTML('beforeend', '<br>') } else{ try{ if( JSON.parse(value) instanceof Array ) value = JSON.parse(value) } catch(err){} this.addTags(value, true).forEach(tag => tag && tag.classList.add(_s.classNames.tagNoAnimation)) } } else this.postUpdate() this.state.lastOriginalValueReported = _s.mixMode.integrated ? '' : this.DOM.originalInput.value }, cloneEvent(e){ var clonedEvent = {} for( var v in e ) if( v != 'path' ) 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 a 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 ){ if( typeof className == 'string' ) this.DOM.scope.classList.toggle(className, force) }, toggleScopeValidation( validation ){ var isValid = validation === true || validation === undefined; // initially it is undefined if( !this.settings.required && validation && validation === this.TEXTS.empty) isValid = true this.toggleClass(this.settings.classNames.tagInvalid, !isValid) this.DOM.scope.title = isValid ? '' : validation }, toggleFocusClass( force ){ this.toggleClass(this.settings.classNames.focus, !!force) }, /** * Sets the templates placeholder after initialization * @param {String} str */ setPlaceholder(str) { ['data', 'aria'].forEach(p => this.DOM.input.setAttribute(`${p}-placeholder`, str)) }, 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, inputElm) // return true // } }, /** 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 ){ if( !node ) return; start = typeof start == 'number' ? start : !!start node = node.lastChild || node; var sel = document.getSelection() // do not force caret placement if the current selection (focus) is on another element (not this tagify instance) if( sel.focusNode instanceof Element && !this.DOM.input.contains(sel.focusNode) ) { return true } try{ if( sel.rangeCount >= 1 ){ ['Start', 'End'].forEach(pos => sel.getRangeAt(0)["set" + pos](node, start ? start : node.length) ) } } catch(err){ console.warn(err) } }, insertAfterTag( tagElm, newNode ){ newNode = newNode || this.settings.mixMode.insertAfterTag; if( !tagElm || !tagElm.parentNode || !newNode ) return newNode = typeof newNode == 'string' ? document.createTextNode(newNode) : newNode tagElm.parentNode.insertBefore(newNode, tagElm.nextSibling) return newNode }, // compares all "__originalData" property values with the current "tagData" properties // and returns "true" if something changed. editTagChangeDetected(tagData) { var originalData = tagData.__originalData; for( var prop in originalData ) if( !this.dataProps.includes(prop) && tagData[prop] != originalData[prop] ) return true return false; // not changed }, // returns the node which has the actual tag's content getTagTextNode(tagElm){ return tagElm.querySelector(this.settings.classNames.tagTextSelector) }, // sets the text of a tag setTagTextNode(tagElm, HTML){ this.getTagTextNode(tagElm).innerHTML = escapeHTML(HTML) }, /** * 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 || {} var _s = this.settings, editableElm = this.getTagTextNode(tagElm), tagIdx = this.getNodeIndex(tagElm), tagData = getSetTagData(tagElm), _CB = this.events.callbacks, isValid = true, isSelectMode = _s.mode == 'select' // select mode is a bit different as clicking the tagify's content once will get into edit-mode if a value // is already selected, and there cannot be a dropdown already open at this point. !isSelectMode && this.dropdown.hide() if( !editableElm ){ logger.warn('Cannot find element in Tag template: .', _s.classNames.tagTextSelector); return; } if( tagData instanceof Object && "editable" in tagData && !tagData.editable ) return // cache the original data, on the DOM node, before any modification ocurs, for possible revert tagData = getSetTagData(tagElm, { __originalData: extend({}, tagData), __originalHTML: tagElm.cloneNode(true) }) // re-set the tagify custom-prop on the clones element (because cloning removed it) getSetTagData(tagData.__originalHTML, tagData.__originalData) editableElm.setAttribute('contenteditable', true) tagElm.classList.add( _s.classNames.tagEditing ) // because "editTag" method can be called manually, make sure that "state.editing" is set correctly this.events.callbacks.onEditTagFocus.call(this, tagElm) editableElm.addEventListener('click' , _CB.onEditTagClick.bind(this, tagElm)) editableElm.addEventListener('blur' , _CB.onEditTagBlur.bind(this, this.getTagTextNode(tagElm))) editableElm.addEventListener('input' , _CB.onEditTagInput.bind(this, editableElm)) editableElm.addEventListener('paste' , _CB.onEditTagPaste.bind(this, editableElm)) editableElm.addEventListener('keydown' , e => _CB.onEditTagkeydown.call(this, e, tagElm)) editableElm.addEventListener('compositionstart' , _CB.onCompositionStart.bind(this)) editableElm.addEventListener('compositionend' , _CB.onCompositionEnd.bind(this)) if( !opts.skipValidation ) isValid = this.editTagToggleValidity(tagElm) editableElm.originalIsValid = isValid this.trigger("edit:start", { tag:tagElm, index:tagIdx, data:tagData, isValid }) editableElm.focus() !isSelectMode && this.setRangeAtStartEnd(false, editableElm) // place the caret at the END of the editable tag text _s.dropdown.enabled === 0 && !isSelectMode && this.dropdown.show() this.state.hasFocus = true return this }, /** * If a tag is invalid, for any reason, set its class to "not allowed" (see defaults file) * @param {Node} tagElm required * @param {Object} tagData optional * @returns true if valid, a string (reason) if not */ editTagToggleValidity( tagElm, tagData ){ var tagData = tagData || getSetTagData(tagElm), isValid; if( !tagData ){ logger.warn("tag has no data: ", tagElm, tagData) return; } isValid = !("__isValid" in tagData) || tagData.__isValid === true if( !isValid ){ this.removeTagsFromValue(tagElm) } this.update() //this.validateTag(tagData); tagElm.classList.toggle(this.settings.classNames.tagNotAllowed, !isValid) tagData.__isValid = isValid; return tagData.__isValid }, onEditTagDone(tagElm, tagData){ tagElm = tagElm || this.state.editing.scope tagData = tagData || {} var _s = this.settings, eventData = { tag : tagElm, index : this.getNodeIndex(tagElm), previousData: getSetTagData(tagElm), data : tagData } this.trigger("edit:beforeUpdate", eventData, {cloneData:false}) this.state.editing = false; delete tagData.__originalData delete tagData.__originalHTML // some scenarrios like in the one in the demos page with textarea that has 2 whitelists, one of the whitelist might be // an array of objects with a property defined the same as the `tagTextProp` setting (if used) but another whitelist // might be simpler - just an array of primitives. function veryfyTagTextProp() { var tagTextProp = tagData[_s.tagTextProp]; // 'tagTextProp' might also be the number 0 so checking for `undefined` here: if( tagTextProp !== undefined ) { tagTextProp += ''; // cast possible number into a string return !!tagTextProp.trim?.() } if( !(_s.tagTextProp in tagData) ) return !!tagData.value } if( tagElm && tagElm.parentNode ){ if( veryfyTagTextProp() ){ tagElm = this.replaceTag(tagElm, tagData) this.editTagToggleValidity(tagElm, tagData) if( _s.a11y.focusableTags ) tagElm.focus() else if( _s.mode != 'select' ) // place caret after edited tag placeCaretAfterNode(tagElm) } else this.removeTags(tagElm) } this.trigger("edit:updated", eventData) _s.dropdown.closeOnSelect && this.dropdown.hide() // 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.value === undefined ) 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 newTagElm = this.createTagElem(tagData) // update DOM tagElm.parentNode.replaceChild(newTagElm, tagElm) this.updateValueByDOMTags() return newTagElm }, /** * update "value" (Array of Objects) by traversing all valid tags */ updateValueByDOMTags(){ this.value.length = 0; var clsNames = this.settings.classNames, tagNotAllowedClassName = clsNames.tagNotAllowed.split(' ')[0], skipNodesWithClassNames = [tagNotAllowedClassName, clsNames.tagHide]; [].forEach.call(this.getTagElms(), node => { if ([...node.classList].some(cls => skipNodesWithClassNames.includes(cls))) return; this.value.push( getSetTagData(node) ) }) this.update() this.dropdown.refilter() }, /** * 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 range Object. must have "anchorNode" & "anchorOffset"] */ injectAtCaret( injectedNode, range ){ range = range || this.state.selection?.range if(typeof injectedNode === 'string') injectedNode = document.createTextNode(injectedNode) if( !range && injectedNode ) { this.appendMixTags(injectedNode) return this; } let node = injectAtCaret(injectedNode, range) this.setRangeAtStartEnd(false, node) this.updateValueByDOMTags() // updates internal "this.value" this.update() // updates original input/textarea return this }, /** * input bridge for accessing & setting * @type {Object} */ input : { set( value = '', updateDOM = true ){ var _s = this.settings, hideDropdown = _s.dropdown.closeOnSelect this.state.inputText = value if( updateDOM ) { this.DOM.input.innerHTML = escapeHTML(""+value); value && this.toggleClass(_s.classNames.empty, !this.DOM.input.innerHTML) // remove the "empty" (is exists) class only if a value was added } if( !value && hideDropdown ) this.dropdown.hide.bind(this) this.input.autocomplete.suggest.call(this); this.input.validate.call(this); }, raw(){ return this.DOM.input.textContent }, /** * 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 <br>) normalize( node, options ){ 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 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 return options?.trim ? this.trim(v) : 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 || {value:''} if (typeof data !== 'object') data = { value: data }; var suggestedText = this.dropdown.getMappedValue(data); if( typeof suggestedText === 'number' ) return var inputText = this.state.inputText.toLowerCase(), suggestionStart = suggestedText.substr(0, this.state.inputText.length).toLowerCase(), suggestionTrimmed = suggestedText.substring(this.state.inputText.length); if( !suggestedText || !this.state.inputText || suggestionStart != inputText ){ 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(false, this.DOM.input) } this.input.autocomplete.suggest.call(this); this.dropdown.hide(); 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.__tagId == (tagData||{}).__tagId ) }, getNodeIndex( node ){ var index = 0; if( node ) while( (node = node.previousElementSibling) ) index++; return index; }, getTagElms( ...classess ){ var classname = '.' + [...this.settings.classNames.tag.split(' '), ...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 _sc = this.settings.classNames, tagNodes = this.DOM.scope.querySelectorAll(`${_sc.tagSelector}:not(.${_sc.tagHide}):not([readonly])`); return tagNodes[tagNodes.length - 1]; }, /** * Searches if any tag with a certain value already exis * @param {String/Object} value [text value / tag data object] * @param {Boolean} caseSensitive * @return {Number} */ isTagDuplicate( value, caseSensitive, tagId ){ var dupsCount = 0; for( let item of this.value ) { let isSameStr = sameStr( this.trim(""+value), item.value, caseSensitive ); if( isSameStr && tagId != item.__tagId ) dupsCount++; } return dupsCount }, getTagIndexByValue( value ){ var indices = [], isCaseSensitive = this.settings.dropdown.caseSensitive; this.getTagElms().forEach((tagElm, i) => { if( tagElm.__tagifyTagData && sameStr( this.trim(tagElm.__tagifyTagData.value), value, isCaseSensitive ) ) 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; whitelist.some(_wi => { // whitelist item value. Can be either a String, Number or an Object (with a `value` property) var _wiv = typeof _wi == 'object' ? (_wi[prop] || _wi.value) : _wi, isSameStr = sameStr(_wiv, value, _s.dropdown.caseSensitive, _s.trim) if( isSameStr ){ result = typeof _wi == 'object' ? _wi : {value:_wi} return true } }) // first iterate the whitelist, try find matches 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.mode != 'mix' && _s.pattern && _s.pattern instanceof RegExp && !(_s.pattern.test(v)) ) return this.TEXTS.pattern; // check for duplicates if( !_s.duplicates && this.isTagDuplicate(v, _s.dropdown.caseSensitive, tagData.__tagId) ) return this.TEXTS.duplicate; if( this.isTagBlacklisted(v) || (_s.enforceWhitelist && !this.isTagWhitelisted(v)) ) return this.TEXTS.notAllowed; if( _s.validate ) return _s.validate(tagData) return true }, getInvalidTagAttrs(tagData, validation){ return { "aria-invalid" : true, "class": `${tagData.class || ''} ${this.settings.classNames.tagNotAllowed}`.trim(), "title": validation } }, hasMaxTags(){ return this.value.length >= this.settings.maxTags ? this.TEXTS.exceed : false }, setReadonly( toggle, attrribute ){ var _s = this.settings this.DOM.scope.contains(document.activeElement) && document.activeElement.blur() // exit possible edit-mode _s[attrribute || 'readonly'] = toggle this.DOM.scope[(toggle ? 'set' : 'remove') + 'Attribute'](attrribute || 'readonly', true) this.settings.userInput = true; this.setContentEditable(!toggle) if( !toggle ) { // first unbind all events this.events.binding.call(this, true) // re-bind all events this.events.binding.call(this) } }, setContentEditable(state){ this.DOM.scope.querySelectorAll("[data-can-editable]").forEach(elm => { elm.contentEditable = state elm.tabIndex = !!state ? 0 : -1; }) }, setDisabled( isDisabled ){ this.setReadonly(isDisabled, 'disabled') }, /** * 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} = this.settings, whitelistMatches = [], whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false, // checks if this is a "collection", meanning an Array of Objects isArray = Array.isArray(tagsItems), isCollection = isArray && tagsItems[0].value, mapStringToCollection = s => (s+"").split(delimiters).reduce((acc, v) => { const trimmed = this.trim(v) trimmed && acc.push({ [tagTextProp]:trimmed, value:trimmed }) return acc }, []) 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) } // if is an Array of Strings, convert to an Array of Objects else if( isArray ){ // flatten the 2D array tagsItems = tagsItems.reduce((acc, item) => { if( isObject(item) ) { var itemCopy = extend({}, item) // if 'tagTextProp' property does not exist in the item, use `value` instead if(!(tagTextProp in itemCopy)) tagTextProp = 'value' itemCopy[tagTextProp] = this.trim(itemCopy[tagTextProp]) // discard empty tags but allow `0` as a valid value if( itemCopy[tagTextProp] || itemCopy[tagTextProp] === 0 ) acc.push(itemCopy) // mapStringToCollection(item.value).map(newItem => ({...item,...newItem})) } else if(item != null && item !== '' && item !== undefined) { acc.push( ...mapStringToCollection(item) ) } return acc }, []) } // search if the tag exists in the whitelist as an Object (has props), // to be able to use its properties. // skip matching collections with whitelist items as they are considered "whole" if( whitelistWithProps && !isCollection ){ 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 }) if( !this.settings.duplicates ) // also filter out items which have already been matched in previous iterations filteredList = filteredList.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' ){ 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} } transformTag.call(this, tagData) if( !maxTagsReached && s2.length > 1 && (!enforceWhitelist || this.isTagWhitelisted(tagData.value)) && !(!duplicates && this.isTagDuplicate(tagData.value)) ){ // 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 //+ "&#8288;" // 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() var tagNodes = this.getTagElms() tagNodes.forEach((elm, idx) => getSetTagData(elm, tagsDataSet[idx])) this.update({withoutChangeEvent:true}) fixCaretBetweenTags(tagNodes, this.state.hasFocus) 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 = this.state.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) if( idx == -1 ) return true; 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 unwanted issues: // https://monosnap.com/file/ZDVmRvq5upYkidiFedvrwzSswegWk7 // nodeAtCaret.parentNode.normalize() return true; }, /** * Validate a tag's data and create a new tag node * @param {*} tagData * @param {*} options * @returns Object */ prepareNewTagNode(tagData, options) { options = options || {} var tagElm, _s = this.settings, aggregatedInvalidInput = [], tagElmParams = {}, originalData = Object.assign({}, tagData, {value:tagData.value+""}); // shallow-clone tagData so later modifications will not apply to the source tagData = Object.assign({}, originalData) _s.transformTag.call(this, tagData) tagData.__isValid = this.hasMaxTags() || this.validateTag(tagData) if( tagData.__isValid !== true ){ if( options.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( !_s.createInvalidTags ){ aggregatedInvalidInput.push(tagData.value) return } } if( 'readonly' in tagData ){ if( tagData.readonly ) tagElmParams["aria-readonly"] = true // if "readonly" is "false", remove it from the tagData so it won't be added as an attribute in the template else delete tagData.readonly } // Create tag HTML element tagElm = this.createTagElem(tagData, tagElmParams) return {tagElm, tagData, aggregatedInvalidInput} }, /** * Logic to happen once a tag has just been injected into the DOM * @param {Node} tagElm * @param {Object} tagData */ postProcessNewTagNode(tagElm, tagData) { var _s = this.settings, isValid = tagData.__isValid; if( isValid && isValid === true ){ // update state this.value.push(tagData) } else{ this.trigger('invalid', {data:tagData, index:this.value.length, tag:tagElm, message:isValid}) if( !_s.keepInvalidTags ) // remove invalid tags (if "keepInvalidTags" is set to "false") setTimeout(() => this.removeTags(tagElm, true), 1000) } this.dropdown.position() // reposition the dropdown because the just-added tag might cause a new-line }, /** * 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 ){ var _s = this.settings if( _s.enforceWhitelist && !this.isTagWhitelisted(tagData.value) ) return // this.input.set.call(this, tagData[_s.tagTextProp] || 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(false, this.DOM.input)) var lastTagElm = this.getLastTag() if( lastTagElm ) this.replaceTag(lastTagElm, tagData) else this.appendTag(tagElm) // if( _s.enforceWhitelist ) // this.setContentEditable(false); th