@yaireo/tagify
Version:
lightweight, efficient Tags input component in Vanilla JS / React / Angular [super customizable, tiny size & top performance]
558 lines (458 loc) • 25 kB
JavaScript
import { isObject, escapeHTML, extend, unaccent, logger } from './helpers'
/**
* Tagify's dropdown suggestions-related logic
*/
export default {
events : {
/**
* Events should only be binded when the dropdown is rendered and removed when isn't
* because there might be multiple Tagify instances on a certain page
* @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events]
*/
binding( bindUnbind = true ){
// references to the ".bind()" methods must be saved so they could be unbinded later
var _CB = this.dropdown.events.callbacks,
// callback-refs
_CBR = (this.listeners.dropdown = this.listeners.dropdown || {
position : this.dropdown.position.bind(this, null),
onKeyDown : _CB.onKeyDown.bind(this),
onMouseOver : _CB.onMouseOver.bind(this),
onMouseLeave : _CB.onMouseLeave.bind(this),
onClick : _CB.onClick.bind(this),
onScroll : _CB.onScroll.bind(this),
}),
action = bindUnbind ? 'addEventListener' : 'removeEventListener';
if( this.settings.dropdown.position != 'manual' ){
document[action]('scroll', _CBR.position, true)
window[action]('resize', _CBR.position)
window[action]('keydown', _CBR.onKeyDown)
}
this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver)
this.DOM.dropdown[action]('mouseleave', _CBR.onMouseLeave)
this.DOM.dropdown[action]('mousedown', _CBR.onClick)
this.DOM.dropdown.content[action]('scroll', _CBR.onScroll)
},
callbacks : {
onKeyDown(e){
// ignore keys during IME composition
if( !this.state.hasFocus || this.state.composing )
return
// get the "active" element, and if there was none (yet) active, use first child
var _s = this.settings,
includeSelectedTags = _s.dropdown.includeSelectedTags,
selectedElm = this.DOM.dropdown.querySelector(_s.classNames.dropdownItemActiveSelector),
selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm),
isMixMode = _s.mode == 'mix',
isSelectMode = _s.mode == 'select';
_s.hooks.beforeKeyDown(e, {tagify:this})
.then(result => {
switch( e.key ){
case 'ArrowDown' :
case 'ArrowUp' :
case 'Down' : // >IE11
case 'Up' : { // >IE11
e.preventDefault()
var dropdownItems = this.dropdown.getAllSuggestionsRefs(),
actionUp = e.key == 'ArrowUp' || e.key == 'Up';
if( selectedElm ) {
selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp)
}
// if no element was found OR current item is not a "real" item, loop
if( !selectedElm || !selectedElm.matches(_s.classNames.dropdownItemSelector) ){
selectedElm = dropdownItems[actionUp ? dropdownItems.length - 1 : 0];
}
this.dropdown.highlightOption(selectedElm, true)
// selectedElm.scrollIntoView({inline: 'nearest', behavior: 'smooth'})
break;
}
case 'PageUp':
case 'PageDown': {
e.preventDefault()
const dropdownItems = this.dropdown.getAllSuggestionsRefs()
const itemsPerPage = Math.floor(this.DOM.dropdown.content.clientHeight / dropdownItems[0]?.offsetHeight) || 1
const isPageUp = e.key === 'PageUp'
if (selectedElm) {
const currentIndex = dropdownItems.indexOf(selectedElm)
const targetIndex = isPageUp
? Math.max(0, currentIndex - itemsPerPage)
: Math.min(dropdownItems.length - 1, currentIndex + itemsPerPage)
selectedElm = dropdownItems[targetIndex]
} else {
selectedElm = dropdownItems[0]
}
this.dropdown.highlightOption(selectedElm, true)
break;
}
case 'Home':
case 'End': {
e.preventDefault()
const dropdownItems = this.dropdown.getAllSuggestionsRefs()
selectedElm = dropdownItems[e.key === 'Home' ? 0 : dropdownItems.length - 1]
this.dropdown.highlightOption(selectedElm, true)
break;
}
case 'Escape' :
case 'Esc': // IE11
this.dropdown.hide();
break;
case 'ArrowRight' :
// do not continue if the left arrow key was pressed while typing, because assuming the user wants to bypass any of the below logic and edit the content without intervention.
// also do not procceed if a tag should be created when the setting `autoComplete.rightKey` is set to `true`
if( this.state.actions.ArrowLeft || _s.autoComplete.rightKey )
return
case 'Tab' : {
let shouldAutocompleteOnKey = !_s.autoComplete.rightKey || !_s.autoComplete.tabKey
// in mix-mode, treat arrowRight like Enter key, so a tag will be created
if( !isMixMode && !isSelectMode && selectedElm && shouldAutocompleteOnKey && !this.state.editing && selectedElmData ){
e.preventDefault() // prevents blur so the autocomplete suggestion will not become a tag
var value = this.dropdown.getMappedValue(selectedElmData)
this.state.autoCompleteData = selectedElmData;
this.input.autocomplete.set.call(this, value)
return false
}
return true
}
case 'Enter' : {
e.preventDefault()
// temporary set the "actions" state to indicate to the main "blur" event it shouldn't execute any if its logic.
// a `100ms` is a good-enough timeout after some testing
this.state.actions.selectOption = true;
setTimeout(()=> this.state.actions.selectOption = false, 100)
_s.hooks.suggestionClick(e, {tagify:this, tagData:selectedElmData, suggestionElm:selectedElm})
.then(() => {
if( selectedElm ){
var nextOrPrevOption = includeSelectedTags ? selectedElm : this.dropdown.getNextOrPrevOption(selectedElm, !actionUp);
this.dropdown.selectOption(selectedElm, e, () => {
// highlight next option
if(nextOrPrevOption) {
var nextOrPrevOptionValue = nextOrPrevOption.getAttribute('value')
nextOrPrevOption = this.dropdown.getSuggestionNodeByValue(nextOrPrevOptionValue)
this.dropdown.highlightOption(nextOrPrevOption)
}
})
return
}
else
this.dropdown.hide()
if( !isMixMode )
this.addTags(this.state.inputText.trim(), true)
})
.catch(err => logger.warn(err))
break;
}
case 'Backspace' : {
if( isMixMode || this.state.editing.scope ) return;
const value = this.input.raw.call(this)
if( value == "" || value.charCodeAt(0) == 8203 ){
if( _s.backspace === true )
this.removeTags()
else if( _s.backspace == 'edit' )
setTimeout(this.editTag.bind(this), 0)
}
}
}
})
},
onMouseOver(e){
var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector)
// event delegation check
this.dropdown.highlightOption(ddItem)
},
onMouseLeave(e){
// de-highlight any previously highlighted option
this.dropdown.highlightOption()
},
onClick(e){
if( e.button != 0 || e.target == this.DOM.dropdown || e.target == this.DOM.dropdown.content ) return; // allow only mouse left-clicks
var selectedElm = e.target.closest(this.settings.classNames.dropdownItemSelector),
selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm)
// temporary set the "actions" state to indicate to the main "blur" event it shouldn't execute any if its logic.
// a `100ms` is a good-enough timeout after some testing
this.state.actions.selectOption = true;
setTimeout(()=> this.state.actions.selectOption = false, 100)
this.settings.hooks.suggestionClick(e, {tagify:this, tagData:selectedElmData, suggestionElm:selectedElm})
.then(() => {
if( selectedElm )
this.dropdown.selectOption(selectedElm, e)
else
this.dropdown.hide()
})
.catch(err => logger.warn(err))
},
onScroll(e){
var elm = e.target,
pos = elm.scrollTop / (elm.scrollHeight - elm.parentNode.clientHeight) * 100;
this.trigger("dropdown:scroll", {percentage:Math.round(pos)})
},
}
},
/**
* fill data into the suggestions list
* (mainly used to update the list when removing tags while the suggestions dropdown is visible, so they will be re-added to the list. not efficient)
*/
refilter( value ){
value = value || this.state.dropdown.query || ''
this.suggestedListItems = this.dropdown.filterListItems(value)
this.dropdown.fill()
if( !this.suggestedListItems.length )
this.dropdown.hide()
this.trigger("dropdown:updated", this.DOM.dropdown)
},
/**
* Given a suggestion-item, return the data associated with it
* @param {HTMLElement} tagElm
* @returns Object
*/
getSuggestionDataByNode( tagElm ){
var item, value = tagElm && tagElm.getAttribute('value')
for(var i = this.suggestedListItems.length; i--; ) {
item = this.suggestedListItems[i]
if( isObject(item) && item.value == value ) return item
// for primitive whitelist items:
else if( item == value ) return {value: item}
}
},
getSuggestionNodeByValue( value ){
var dropdownItems = this.dropdown.getAllSuggestionsRefs()
return dropdownItems.find(item => item.getAttribute('value') === value);
},
getNextOrPrevOption(selected, next = true) {
var dropdownItems = this.dropdown.getAllSuggestionsRefs(),
selectedIdx = dropdownItems.findIndex(item => item === selected);
return next ? dropdownItems[selectedIdx + 1] : dropdownItems[selectedIdx - 1]
},
/**
* mark the currently active suggestion option
* @param {Object} elm option DOM node
* @param {Boolean} adjustScroll when navigation with keyboard arrows (up/down), auto-scroll to always show the highlighted element
*/
highlightOption( elm, adjustScroll ){
var className = this.settings.classNames.dropdownItemActive,
itemData;
// focus casues a bug in Firefox with the placeholder been shown on the input element
// if( this.settings.dropdown.position != 'manual' )
// elm.focus();
if( this.state.ddItemElm ){
this.state.ddItemElm.classList.remove(className)
this.state.ddItemElm.removeAttribute("aria-selected")
}
if( !elm ){
this.state.ddItemData = null
this.state.ddItemElm = null
this.input.autocomplete.suggest.call(this)
return;
}
itemData = this.dropdown.getSuggestionDataByNode(elm)
this.state.ddItemData = itemData
this.state.ddItemElm = elm
// this.DOM.dropdown.querySelectorAll("." + this.settings.classNames.dropdownItemActive).forEach(activeElm => activeElm.classList.remove(className));
elm.classList.add(className);
elm.setAttribute("aria-selected", true)
if( adjustScroll )
elm.parentNode.scrollTop = elm.clientHeight + elm.offsetTop - elm.parentNode.clientHeight
// Try to autocomplete the typed value with the currently highlighted dropdown item
if( this.settings.autoComplete ){
this.input.autocomplete.suggest.call(this, itemData)
this.dropdown.position() // suggestions might alter the height of the tagify wrapper because of unkown suggested term length that could drop to the next line
}
},
/**
* Create a tag from the currently active suggestion option
* @param {Object} elm DOM node to select
* @param {Object} event The original Click event, if available (since keyboard ENTER key also triggers this method)
*/
selectOption( elm, event, onSelect ){
var _s = this.settings,
includeSelectedTags = _s.dropdown.includeSelectedTags,
{clearOnSelect, closeOnSelect} = _s.dropdown;
if( !elm ) {
this.addTags(this.state.inputText, true)
closeOnSelect && this.dropdown.hide()
return;
}
event = event || {}
// if in edit-mode, do not continue but instead replace the tag's text.
// the scenario is that "addTags" was called from a dropdown suggested option selected while editing
var value = elm.getAttribute('value'),
isNoMatch = value == 'noMatch',
isMixMode = _s.mode == 'mix',
tagData = this.suggestedListItems.find(item => (item.value ?? item) == value)
// The below event must be triggered, regardless of anything else which might go wrong
this.trigger('dropdown:select', {data:tagData, elm, event})
if( !tagData && !isNoMatch ){
closeOnSelect && setTimeout(this.dropdown.hide.bind(this))
return
}
if( this.state.editing ) {
let normalizedTagData = this.normalizeTags([tagData])[0]
tagData = _s.transformTag.call(this, normalizedTagData) || normalizedTagData
// normalizing value, because "tagData" might be a string, and therefore will not be able to extend the object
this.onEditTagDone(null, extend({__isValid: true}, tagData))
}
// Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing
else {
this[isMixMode ? "addMixTags" : "addTags"]([tagData || this.input.raw.call(this)], clearOnSelect)
}
if( !isMixMode && !this.DOM.input.parentNode )
return
setTimeout(() => {
this.DOM.input.focus()
this.toggleFocusClass(true)
})
closeOnSelect && setTimeout(this.dropdown.hide.bind(this))
// execute these tasks once a suggestion has been selected
if(includeSelectedTags) {
onSelect && onSelect()
}
// if the selected suggestion is removed after being selected, more things things needs to be done:
else {
elm.addEventListener('transitionend', () => {
this.dropdown.fillHeaderFooter()
setTimeout(() => {
elm.remove()
this.dropdown.refilter()
onSelect && onSelect()
}, 100)
}, {once: true})
// hide selected suggestion
elm.classList.add(this.settings.classNames.dropdownItemHidden)
}
},
// adds all the suggested items, including the ones which are not currently rendered,
// unless specified otherwise (by the "onlyRendered" argument)
selectAll( onlyRendered ){
// having suggestedListItems with items messes with "normalizeTags" when wanting
// to add all tags
this.suggestedListItems.length = 0;
this.dropdown.hide()
this.dropdown.filterListItems('');
var tagsToAdd = this.dropdown.filterListItems('');
if( !onlyRendered )
tagsToAdd = this.state.dropdown.suggestions
// some whitelist items might have already been added as tags so when addings all of them,
// skip adding already-added ones, so best to use "filterListItems" method over "settings.whitelist"
this.addTags(tagsToAdd, true)
return this
},
/**
* returns an HTML string of the suggestions' list items
* @param {String} value string t
* o filter the whitelist by
* @param {Object} options "exact" - for exact complete match
* @return {Array} list of filtered whitelist items according to the settings provided and current value
*/
filterListItems( value, options ){
var _s = this.settings,
_sd = _s.dropdown,
options = options || {},
list = [],
exactMatchesList = [],
whitelist = _s.whitelist,
suggestionsCount = _sd.maxItems >= 0 ? _sd.maxItems : Infinity,
includeSelectedTags = _sd.includeSelectedTags,
hasCustomSort = typeof _sd.sortby == 'function',
searchKeys = _sd.searchKeys,
whitelistItem,
valueIsInWhitelist,
searchBy,
isDuplicate,
niddle,
i = 0;
value = (_s.mode == 'select' && this.value.length && this.value[0][_s.tagTextProp] == value
? '' // do not filter if the tag, which is already selecetd in "select" mode, is the same as the typed text
: value);
if( !value || !searchKeys.length ){
list = includeSelectedTags
? whitelist
: whitelist.filter(item => !this.isTagDuplicate( isObject(item) ? item.value : item )) // don't include tags which have already been added.
var filteredList = hasCustomSort
? _sd.sortby(list, niddle)
: list.slice(0, suggestionsCount); // respect "maxItems" dropdown setting
this.state.dropdown.suggestions = filteredList;
return filteredList;
}
niddle = _sd.caseSensitive
? ""+value
: (""+value).toLowerCase()
// checks if ALL of the words in the search query exists in the current whitelist item, regardless of their order
function stringHasAll(s, query){
return query.toLowerCase().split(' ').every(q => s.includes(q.toLowerCase()))
}
for( ; i < whitelist.length; i++ ){
let startsWithMatch, exactMatch;
whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value:whitelist[i] } //normalize value as an Object
let itemWithoutSearchKeys = !Object.keys(whitelistItem).some(k => searchKeys.includes(k) ),
_searchKeys = itemWithoutSearchKeys ? ["value"] : searchKeys
if( _sd.fuzzySearch && !options.exact ){
searchBy = _searchKeys.reduce((values, k) => values + " " + (whitelistItem[k]||""), "").toLowerCase().trim()
if( _sd.accentedSearch ){
searchBy = unaccent(searchBy)
niddle = unaccent(niddle)
}
startsWithMatch = searchBy.indexOf(niddle) == 0
exactMatch = searchBy === niddle
valueIsInWhitelist = stringHasAll(searchBy, niddle)
}
else {
startsWithMatch = true;
valueIsInWhitelist = _searchKeys.some(k => {
var v = '' + (whitelistItem[k] || '') // if key exists, cast to type String
if( _sd.accentedSearch ){
v = unaccent(v)
niddle = unaccent(niddle)
}
if( !_sd.caseSensitive )
v = v.toLowerCase()
exactMatch = v === niddle
return options.exact
? v === niddle
: v.indexOf(niddle) == 0
})
}
isDuplicate = !_sd.includeSelectedTags && this.isTagDuplicate( isObject(whitelistItem) ? whitelistItem.value : whitelistItem )
// match for the value within each "whitelist" item
if( valueIsInWhitelist && !isDuplicate )
if( exactMatch && startsWithMatch)
exactMatchesList.push(whitelistItem)
else if( _sd.sortby == 'startsWith' && startsWithMatch )
list.unshift(whitelistItem)
else
list.push(whitelistItem)
}
this.state.dropdown.suggestions = exactMatchesList.concat(list);
// custom sorting function
var filteredList = hasCustomSort
? _sd.sortby(exactMatchesList.concat(list), niddle)
: exactMatchesList.concat(list).slice(0, suggestionsCount)
this.state.dropdown.suggestions = filteredList;
return filteredList
},
/**
* Returns the final value of a tag data (object) with regards to the "mapValueTo" dropdown setting
* @param {Object} tagData
* @returns
*/
getMappedValue(tagData){
var mapValueTo = this.settings.dropdown.mapValueTo,
value = (mapValueTo
? typeof mapValueTo == 'function' ? mapValueTo(tagData) : (tagData[mapValueTo] || tagData.value)
: tagData.value);
return value
},
/**
* Creates the dropdown items' HTML
* @param {Array} sugegstionsList [Array of Objects]
* @return {String}
*/
createListHTML( sugegstionsList ){
return extend([], sugegstionsList).map((suggestion, idx) => {
if( typeof suggestion == 'string' || typeof suggestion == 'number' )
suggestion = {value:suggestion}
var mappedValue = this.dropdown.getMappedValue(suggestion);
mappedValue = (typeof mappedValue == 'string' && this.settings.dropdown.escapeHTML)
? escapeHTML(mappedValue)
: mappedValue;
return this.settings.templates.dropdownItem.apply(this, [{...suggestion, mappedValue}, this])
}).join("")
}
}