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