@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
JavaScript
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 //+ "⁠" // 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