UNPKG

@yaireo/tagify

Version:

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

1,351 lines (1,087 loc) 81.1 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 = { pasteAsTags: false, 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( !injectedNode ) return this const DOCUMENT_FRAGMENT_NODE = 11 const insertedNodes = injectedNode.nodeType === DOCUMENT_FRAGMENT_NODE ? Array.prototype.slice.call(injectedNode.childNodes) : [injectedNode] if( !insertedNodes.length ) return this if( !range ){ this.appendMixTags(injectedNode) return this } const isValidInjectionPoint = this.DOM.scope.contains(range?.startContainer) if( !isValidInjectionPoint ) return this injectAtCaret(injectedNode, range) const caretTarget = insertedNodes[insertedNodes.length - 1] || injectedNode if( caretTarget?.parentNode ) placeCaretAfterNode(caretTarget) this.setStateSelection() 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]; }, /** * Tag element immediately before {@link Tagify#DOM.input} in the scope (sibling order). * Used for Backspace and for ArrowLeft in {@link Tagify#repositionScopeInput} when the input may sit between tags. * @returns {HTMLElement|undefined} */ getTagElmBeforeInput(){ var prev = this.DOM.input && this.DOM.input.previousElementSibling; return isNodeTag.call(this, prev) ? prev : undefined; }, /** * 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 */ /** * Parses interpolated text (e.g., "text [[{"value":"tag"}]] more text") into tags * @param {String} s - Text with interpolated tags * @param {Object} [options] - Optional settings * @param {Boolean} [options.skipDOM=false] - If true, returns a DocumentFragment instead of updating DOM * @returns {String|DocumentFragment} - HTML string (default) or DocumentFragment (if skipDOM=true) */ parseMixTags( s, options ){ var {mixTagsInterpolator, duplicates, transformTag, enforceWhitelist, maxTags, tagTextProp} = this.settings, skipDOM = options?.skipDOM, fragment = skipDOM ? document.createDocumentFragment() : null, 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; // For fragment mode: handle text before first tag if( skipDOM && i == 0 && s1 ){ fragment.appendChild(document.createTextNode(s1)) return '' } 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) if( skipDOM ){ fragment.appendChild(tagElm) // Add text after tag if( s2[1] ){ fragment.appendChild(document.createTextNode(s2[1])) } return '' } else { 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){ if( skipDOM ){ // Invalid tag - add back the interpolator and content as text fragment.appendChild(document.createTextNode(mixTagsInterpolator[0] + s1)) return '' } return i ? mixTagsInterpolator[0] + s1 : s1 } return s2.join('') }).join('') // Fragment mode: return the fragment with tags data attached if( skipDOM ){ fragment.__tagifyTagsData = tagsDataSet return fragment } // DOM mode: update the input element 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 }, /** * Converts pasted text in mix-mode into tags by detecting pattern-prefixed text * that matches items in the whitelist * @param {String} text - The pasted text to process * @returns {String} - Text with matched items wrapped in mixTagsInterpolator */ convertPastedTextToMixTags( text ){ const { pattern, whitelist, mixTagsInterpolator, mixTagsAllowedAfter, tagTextProp } = this.settings if( !pattern || !whitelist?.length ) return text // Extract all possible prefix patterns (e.g., [@, #] from /@|#/) const prefixPatterns = pattern.source ? pattern.source.split('|') : [pattern] // Build a mapping of prefix -> whitelist items const prefixWhitelistMap = {} prefixPatterns.forEach(prefix => { // Normalize prefix (remove escape chars if any) const normalizedPrefix = prefix.replace(/\\/g, '') prefixWhitelistMap[normalizedPrefix] = whitelist.map(item => { // Get the text to match against - use tagTextProp for objects, or the string itself let textValue if( typeof item === 'string' ) { textValue = item } else { // For objects, use tagTextProp (e.g., 'text' or 'value') textValue = item[tagTextProp] || item.value } // Convert to string (in case value is a number or other type) textValue = String(textValue) return { originalItem: item, value: textValue, searchValue: textValue.toLowerCase() } }) // Sort by length (longest first) for greedy matching .sort((a, b) => b.value.length - a.value.length) }) // Find all pattern prefix positions const patternRegex = new RegExp(pattern.source, 'g') const replacements = [] // Store replacements to apply in reverse order let match while ((match = patternRegex.exec(text)) !== null) { const prefix = match[0] const startIndex = match.index const afterPrefixIndex = startIndex + prefix.length // Get text after the prefix const textAfterPrefix = text.slice(afterPrefixIndex) // Try to match whitelist items (longest first) const whitelistItems = prefixWhitelistMap[prefix] if( !whitelistItems ) continue let matchedItem = null let matchedLength = 0 // Try each whitelist item starting with longest for( const item of whitelistItems )