UNPKG

@yaireo/tagify

Version:

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

1 lines 1.34 MB
{"version":3,"file":"react.tagify.jsx","sources":["src/parts/constants.js","src/parts/helpers.js","src/parts/defaults.js","src/parts/suggestions.js","src/parts/dropdown.js","src/parts/events.js","src/parts/persist.js","src/parts/texts.js","src/parts/templates.js","src/tagify.js","src/parts/EventDispatcher.js","src/react.tagify.jsx"],"sourcesContent":["export var ZERO_WIDTH_CHAR = '\\u200B';\nexport var ZERO_WIDTH_UNICODE_CHAR = `&#8203;`","import {ZERO_WIDTH_CHAR} from './constants'\n\nexport const logger = {\n isEnabled() { return window.TAGIFY_DEBUG ?? true},\n log(...args){ this.isEnabled() && console.log('[Tagify]:', ...args) },\n warn(...args) { this.isEnabled() && console.warn('[Tagify]:', ...args) }\n}\n\n// console.json = console.json || function(argument){\n// for(var arg=0; arg < arguments.length; ++arg)\n// console.log( JSON.stringify(arguments[arg], null, 4) )\n// }\n\n// const isEdge = /Edge/.test(navigator.userAgent)\nexport const sameStr = (s1, s2, caseSensitive, trim) => {\n // cast to String\n s1 = \"\"+s1;\n s2 = \"\"+s2;\n\n if( trim ){\n s1 = s1.trim()\n s2 = s2.trim()\n }\n\n return caseSensitive\n ? s1 == s2\n : s1.toLowerCase() == s2.toLowerCase()\n}\n\n\n// const getUID = () => (new Date().getTime() + Math.floor((Math.random()*10000)+1)).toString(16)\nexport const removeCollectionProp = (collection, unwantedProps) => collection && Array.isArray(collection) && collection.map(v => omit(v, unwantedProps))\n\nexport function omit(obj, props){\n var newObj = {}, p;\n for( p in obj )\n if( props.indexOf(p) < 0 )\n newObj[p] = obj[p]\n return newObj\n}\n\nexport function decode( s ) {\n var el = document.createElement('div');\n return s.replace(/\\&#?[0-9a-z]+;/gi, function(enc){\n el.innerHTML = enc;\n return el.innerText\n })\n}\n\n/**\n * utility method\n * https://stackoverflow.com/a/35385518/104380\n * @param {String} s [HTML string]\n * @return {Object} [DOM node]\n */\nexport function parseHTML( s ){\n var parser = new DOMParser(),\n node = parser.parseFromString(s.trim(), \"text/html\");\n\n return node.body.firstElementChild;\n}\n\n/**\n * Removed new lines and irrelevant spaces which might affect layout, and are better gone\n * @param {string} s [HTML string]\n */\nexport function minify( s ){\n return s ? s\n .replace(/\\>[\\r\\n ]+\\</g, \"><\")\n .split(/>\\s+</).join('><').trim()\n : \"\"\n}\n\nexport function removeTextChildNodes( elm ){\n var iter = document.createNodeIterator(elm, NodeFilter.SHOW_TEXT, null, false),\n textnode;\n\n // print all text nodes\n while (textnode = iter.nextNode()){\n if( !textnode.textContent.trim() )\n textnode.parentNode.removeChild(textnode)\n }\n}\n\nexport function getfirstTextNode( elm, action ){\n action = action || 'previous';\n while ( elm = elm[action + 'Sibling'] )\n if( elm.nodeType == 3 )\n return elm\n}\n\n/**\n * utility method\n * https://stackoverflow.com/a/6234804/104380\n */\nexport function escapeHTML( s ){\n return typeof s == 'string' ? s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/`|'/g, \"&#039;\")\n : s;\n}\n\n/**\n * Checks if an argument is a javascript Object\n */\nexport function isObject(obj) {\n var type = Object.prototype.toString.call(obj).split(' ')[1].slice(0, -1);\n return obj === Object(obj) && type != 'Array' && type != 'Function' && type != 'RegExp' && type != 'HTMLUnknownElement';\n}\n\n/**\n * merge objects into a single new one\n * TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}})\n */\nexport function extend( o, o1, o2) {\n if( !(o instanceof Object) ) o = {};\n\n copy(o, o1);\n if( o2 )\n copy(o, o2)\n\n function copy(a,b){\n // copy o2 to o\n for( var key in b )\n if( b.hasOwnProperty(key) ){\n if( isObject(b[key]) ){\n if( !isObject(a[key]) )\n a[key] = Object.assign({}, b[key])\n else\n copy(a[key], b[key])\n\n continue;\n }\n\n if( Array.isArray(b[key]) ){\n a[key] = Object.assign([], b[key])\n continue\n }\n\n a[key] = b[key]\n }\n }\n\n return o\n}\n\n/**\n * concatenates N arrays without dups.\n * If an array's item is an Object, compare by `value`\n */\nexport function concatWithoutDups(){\n const newArr = [],\n existingObj = {};\n\n for( let arr of arguments ) {\n for( let item of arr ) {\n // if current item is an object which has yet to be added to the new array\n if( isObject(item) ){\n if( !existingObj[item.value] ){\n newArr.push(item)\n existingObj[item.value] = 1\n }\n }\n\n // if current item is not an object and is not in the new array\n else if( !newArr.includes(item) )\n newArr.push(item)\n }\n }\n\n return newArr\n}\n\n/**\n * Extracted from: https://stackoverflow.com/a/37511463/104380\n * @param {String} s\n */\nexport function unaccent( s ){\n // if not supported, do not continue.\n // developers should use a polyfill:\n // https://github.com/walling/unorm\n if( !String.prototype.normalize )\n return s\n\n if (typeof(s) === 'string')\n return s.normalize(\"NFD\").replace(/[\\u0300-\\u036f]/g, \"\")\n}\n\n/**\n * Meassures an element's height, which might yet have been added DOM\n * https://stackoverflow.com/q/5944038/104380\n * @param {DOM} node\n */\nexport function getNodeHeight( node ){\n var height, clone = node.cloneNode(true)\n clone.style.cssText = \"position:fixed; top:-9999px; opacity:0\"\n document.body.appendChild(clone)\n height = clone.clientHeight\n clone.parentNode.removeChild(clone)\n return height\n}\n\nexport var isChromeAndroidBrowser = () => /(?=.*chrome)(?=.*android)/i.test(navigator.userAgent)\n\nexport function getUID() {\n return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>\n (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)\n )\n}\n\nexport function isNodeTag(node){\n return node && node.classList && node.classList.contains(this.settings.classNames.tag)\n}\n\nexport function isWithinNodeTag(node){\n return node && node.closest(this.settings.classNames.tagSelector)\n}\n\n/**\n* Get the caret position relative to the viewport\n* https://stackoverflow.com/q/58985076/104380\n*\n* @returns {object} left, top distance in pixels\n*/\nexport function getCaretGlobalPosition(){\n const sel = document.getSelection()\n\n if( sel.rangeCount ){\n const r = sel.getRangeAt(0)\n const node = r.startContainer\n const offset = r.startOffset\n let rect, r2;\n\n if (offset > 0) {\n r2 = document.createRange()\n r2.setStart(node, offset - 1)\n r2.setEnd(node, offset)\n rect = r2.getBoundingClientRect()\n return {left:rect.right, top:rect.top, bottom:rect.bottom}\n }\n\n if( node.getBoundingClientRect )\n return node.getBoundingClientRect()\n }\n\n return {left:-9999, top:-9999}\n}\n\n/**\n * Injects content (either string or node) at the current the current (or specificed) caret position\n * @param {content} string/node\n * @param {range} Object (optional, a range other than the current window selection)\n */\nexport function injectAtCaret(content, range){\n var selection = window.getSelection();\n range = range || selection.getRangeAt(0)\n\n if( typeof content == 'string' )\n content = document.createTextNode(content)\n\n if( range ) {\n range.deleteContents()\n range.insertNode(content)\n }\n\n return content\n}\n\n/** Setter/Getter\n * Each tag DOM node contains a custom property called \"__tagifyTagData\" which hosts its data\n * @param {Node} tagElm\n * @param {Object} data\n */\nexport function getSetTagData(tagElm, data, override){\n if( !tagElm ){\n logger.warn(\"tag element doesn't exist\",{tagElm, data})\n return data\n }\n\n if( data )\n tagElm.__tagifyTagData = override\n ? data\n : extend({}, tagElm.__tagifyTagData || {}, data)\n\n return tagElm.__tagifyTagData\n}\n\nexport function placeCaretAfterNode( node ){\n if( !node || !node.parentNode ) return\n\n var nextSibling = node,\n sel = window.getSelection(),\n range = sel.getRangeAt(0);\n\n if (sel.rangeCount) {\n range.setStartAfter(nextSibling);\n range.collapse(true)\n // range.setEndBefore(nextSibling || node);\n sel.removeAllRanges();\n sel.addRange(range);\n }\n}\n\n/**\n * iterate all tags, checking if multiple ones are close-siblings and if so, add a zero-space width character between them,\n * which forces the caret to be rendered when the selection is between tags.\n * Also do that if the tag is the first node.\n * @param {Array} tags\n */\nexport function fixCaretBetweenTags(tags, TagifyHasFocuse) {\n tags.forEach(tag => {\n if( getSetTagData(tag.previousSibling) || !tag.previousSibling ) {\n var textNode = document.createTextNode(ZERO_WIDTH_CHAR)\n tag.before(textNode)\n TagifyHasFocuse && placeCaretAfterNode(textNode)\n }\n })\n}\n\n","export default {\r\n delimiters : \",\", // [RegEx] split tags by any of these delimiters (\"null\" to cancel) Example: \",| |.\"\r\n pattern : null, // RegEx pattern to validate input by. Ex: /[1-9]/\r\n tagTextProp : 'value', // tag data Object property which will be displayed as the tag's text\r\n maxTags : Infinity, // Maximum number of tags\r\n callbacks : {}, // Exposed callbacks object to be triggered on certain events\r\n addTagOnBlur : true, // automatically adds the text which was inputed as a tag when blur event happens\r\n addTagOn : ['blur', 'tab', 'enter'], // if the tagify field (in a normal mode) has any non-tag input in it, convert it to a tag on any of these events: blur away from the field, click \"tab\"/\"enter\" key\r\n onChangeAfterBlur : true, // By default, the native way of inputs' onChange events is kept, and it only fires when the field is blured.\r\n duplicates : false, // \"true\" - allow duplicate tags\r\n whitelist : [], // Array of tags to suggest as the user types (can be used along with \"enforceWhitelist\" setting)\r\n blacklist : [], // A list of non-allowed tags\r\n enforceWhitelist : false, // Only allow tags from the whitelist\r\n userInput : true, // disable manually typing/pasting/editing tags (tags may only be added from the whitelist)\r\n focusable : true, // Allow the component as a whole to recieve focus. There are implementations of Tagify without external border and so 'focusability' causes unwanted behaviour\r\n keepInvalidTags : false, // if true, do not remove tags which did not pass validation\r\n createInvalidTags : true, // if false, do not create invalid tags from invalid user input\r\n mixTagsAllowedAfter : /,|\\.|\\:|\\s/, // RegEx - Define conditions in which mix-tags content allows a tag to be added after\r\n mixTagsInterpolator : ['[[', ']]'], // Interpolation for mix mode. Everything between these will become a tag, if is a valid Object\r\n backspace : true, // false / true / \"edit\"\r\n skipInvalid : false, // If `true`, do not add invalid, temporary, tags before automatically removing them\r\n pasteAsTags : true, // automatically converts pasted text into tags. if \"false\", allows for further text editing\r\n\r\n editTags : {\r\n clicks : 2, // clicks to enter \"edit-mode\": 1 for single click. any other value is considered as double-click\r\n keepInvalid : true // keeps invalid edits as-is until `esc` is pressed while in focus\r\n }, // 1 or 2 clicks to edit a tag. false/null for not allowing editing\r\n transformTag : ()=>{}, // Takes a tag input string as argument and returns a transformed value\r\n trim : true, // whether or not the value provided should be trimmed, before being added as a tag\r\n a11y: {\r\n focusableTags: false\r\n },\r\n\r\n mixMode: {\r\n insertAfterTag : '\\u00A0', // String/Node to inject after a tag has been added (see #588)\r\n },\r\n\r\n autoComplete: {\r\n enabled: true, // Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text\r\n rightKey: false, // If `true`, when Right key is pressed, use the suggested value to create a tag, else just auto-completes the input. in mixed-mode this is set to \"true\"\r\n tabKey: false, // If 'true`, pressing `tab` key would only auto-complete but not also convert to a tag (like `rightKey` does).\r\n },\r\n\r\n classNames: {\r\n namespace : 'tagify',\r\n mixMode : 'tagify--mix',\r\n selectMode : 'tagify--select',\r\n input : 'tagify__input',\r\n focus : 'tagify--focus',\r\n tagNoAnimation : 'tagify--noAnim',\r\n tagInvalid : 'tagify--invalid',\r\n tagNotAllowed : 'tagify--notAllowed',\r\n scopeLoading : 'tagify--loading',\r\n hasMaxTags : 'tagify--hasMaxTags',\r\n hasNoTags : 'tagify--noTags',\r\n empty : 'tagify--empty',\r\n inputInvalid : 'tagify__input--invalid',\r\n dropdown : 'tagify__dropdown',\r\n dropdownWrapper : 'tagify__dropdown__wrapper',\r\n dropdownHeader : 'tagify__dropdown__header',\r\n dropdownFooter : 'tagify__dropdown__footer',\r\n dropdownItem : 'tagify__dropdown__item',\r\n dropdownItemActive : 'tagify__dropdown__item--active',\r\n dropdownItemHidden : 'tagify__dropdown__item--hidden',\r\n dropdownItemSelected : 'tagify__dropdown__item--selected',\r\n dropdownInital : 'tagify__dropdown--initial',\r\n tag : 'tagify__tag',\r\n tagText : 'tagify__tag-text',\r\n tagX : 'tagify__tag__removeBtn',\r\n tagLoading : 'tagify__tag--loading',\r\n tagEditing : 'tagify__tag--editable',\r\n tagFlash : 'tagify__tag--flash',\r\n tagHide : 'tagify__tag--hide',\r\n\r\n },\r\n\r\n dropdown: {\r\n classname : '',\r\n enabled : 2, // minimum input characters to be typed for the suggestions dropdown to show\r\n maxItems : 10,\r\n searchKeys : [\"value\", \"searchBy\"],\r\n fuzzySearch : true,\r\n caseSensitive : false,\r\n accentedSearch : true,\r\n includeSelectedTags: false, // Should the suggestions list Include already-selected tags (after filtering)\r\n escapeHTML : true, // escapes HTML entities in the suggestions' rendered text\r\n highlightFirst : true, // highlights first-matched item in the list\r\n closeOnSelect : true, // closes the dropdown after selecting an item, if `enabled:0` (which means always show dropdown)\r\n clearOnSelect : true, // after selecting a suggetion, should the typed text input remain or be cleared\r\n position : 'all', // 'manual' / 'text' / 'all'\r\n appendTarget : null // defaults to document.body once DOM has been loaded\r\n },\r\n\r\n hooks: {\r\n beforeRemoveTag: () => Promise.resolve(),\r\n beforePaste: () => Promise.resolve(),\r\n suggestionClick: () => Promise.resolve(),\r\n beforeKeyDown: () => Promise.resolve(),\r\n }\r\n}","import { isObject, escapeHTML, extend, unaccent, logger } from './helpers'\n\n\n/**\n * Tagify's dropdown suggestions-related logic\n */\n\nexport default {\n events : {\n /**\n * Events should only be binded when the dropdown is rendered and removed when isn't\n * because there might be multiple Tagify instances on a certain page\n * @param {Boolean} bindUnbind [optional. true when wanting to unbind all the events]\n */\n binding( bindUnbind = true ){\n // references to the \".bind()\" methods must be saved so they could be unbinded later\n var _CB = this.dropdown.events.callbacks,\n // callback-refs\n _CBR = (this.listeners.dropdown = this.listeners.dropdown || {\n position : this.dropdown.position.bind(this, null),\n onKeyDown : _CB.onKeyDown.bind(this),\n onMouseOver : _CB.onMouseOver.bind(this),\n onMouseLeave : _CB.onMouseLeave.bind(this),\n onClick : _CB.onClick.bind(this),\n onScroll : _CB.onScroll.bind(this),\n }),\n action = bindUnbind ? 'addEventListener' : 'removeEventListener';\n\n if( this.settings.dropdown.position != 'manual' ){\n document[action]('scroll', _CBR.position, true)\n window[action]('resize', _CBR.position)\n window[action]('keydown', _CBR.onKeyDown)\n }\n\n this.DOM.dropdown[action]('mouseover', _CBR.onMouseOver)\n this.DOM.dropdown[action]('mouseleave', _CBR.onMouseLeave)\n this.DOM.dropdown[action]('mousedown', _CBR.onClick)\n this.DOM.dropdown.content[action]('scroll', _CBR.onScroll)\n },\n\n callbacks : {\n onKeyDown(e){\n // ignore keys during IME composition\n if( !this.state.hasFocus || this.state.composing )\n return\n\n // get the \"active\" element, and if there was none (yet) active, use first child\n var _s = this.settings,\n selectedElm = this.DOM.dropdown.querySelector(_s.classNames.dropdownItemActiveSelector),\n selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm),\n isMixMode = _s.mode == 'mix',\n isSelectMode = _s.mode == 'select';\n\n _s.hooks.beforeKeyDown(e, {tagify:this})\n .then(result => {\n switch( e.key ){\n case 'ArrowDown' :\n case 'ArrowUp' :\n case 'Down' : // >IE11\n case 'Up' : { // >IE11\n e.preventDefault()\n var dropdownItems = this.dropdown.getAllSuggestionsRefs(),\n actionUp = e.key == 'ArrowUp' || e.key == 'Up';\n\n if( selectedElm ) {\n selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp)\n }\n\n // if no element was found OR current item is not a \"real\" item, loop\n if( !selectedElm || !selectedElm.matches(_s.classNames.dropdownItemSelector) ){\n selectedElm = dropdownItems[actionUp ? dropdownItems.length - 1 : 0];\n }\n\n this.dropdown.highlightOption(selectedElm, true)\n // selectedElm.scrollIntoView({inline: 'nearest', behavior: 'smooth'})\n break;\n }\n case 'Escape' :\n case 'Esc': // IE11\n this.dropdown.hide();\n break;\n\n case 'ArrowRight' :\n // 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.\n // also do not procceed if a tag should be created when the setting `autoComplete.rightKey` is set to `true`\n if( this.state.actions.ArrowLeft || _s.autoComplete.rightKey )\n return\n case 'Tab' : {\n let shouldAutocompleteOnKey = !_s.autoComplete.rightKey || !_s.autoComplete.tabKey\n\n // in mix-mode, treat arrowRight like Enter key, so a tag will be created\n if( !isMixMode && !isSelectMode && selectedElm && shouldAutocompleteOnKey && !this.state.editing && selectedElmData ){\n e.preventDefault() // prevents blur so the autocomplete suggestion will not become a tag\n var value = this.dropdown.getMappedValue(selectedElmData)\n\n this.input.autocomplete.set.call(this, value)\n return false\n }\n return true\n }\n case 'Enter' : {\n e.preventDefault()\n\n _s.hooks.suggestionClick(e, {tagify:this, tagData:selectedElmData, suggestionElm:selectedElm})\n .then(() => {\n if( selectedElm ){\n this.dropdown.selectOption(selectedElm)\n // highlight next option\n selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp)\n this.dropdown.highlightOption(selectedElm)\n return\n }\n else\n this.dropdown.hide()\n\n if( !isMixMode )\n this.addTags(this.state.inputText.trim(), true)\n })\n .catch(err => logger.warn(err))\n\n break;\n }\n case 'Backspace' : {\n if( isMixMode || this.state.editing.scope ) return;\n\n const value = this.input.raw.call(this)\n\n if( value == \"\" || value.charCodeAt(0) == 8203 ){\n if( _s.backspace === true )\n this.removeTags()\n else if( _s.backspace == 'edit' )\n setTimeout(this.editTag.bind(this), 0)\n }\n }\n }\n })\n },\n\n onMouseOver(e){\n var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector)\n // event delegation check\n this.dropdown.highlightOption(ddItem)\n },\n\n onMouseLeave(e){\n // de-highlight any previously highlighted option\n this.dropdown.highlightOption()\n },\n\n onClick(e){\n if( e.button != 0 || e.target == this.DOM.dropdown || e.target == this.DOM.dropdown.content ) return; // allow only mouse left-clicks\n\n var selectedElm = e.target.closest(this.settings.classNames.dropdownItemSelector),\n selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm)\n\n // temporary set the \"actions\" state to indicate to the main \"blur\" event it shouldn't run\n this.state.actions.selectOption = true;\n setTimeout(()=> this.state.actions.selectOption = false, 50)\n\n this.settings.hooks.suggestionClick(e, {tagify:this, tagData:selectedElmData, suggestionElm:selectedElm})\n .then(() => {\n if( selectedElm )\n this.dropdown.selectOption(selectedElm, e)\n else\n this.dropdown.hide()\n })\n .catch(err => logger.warn(err))\n },\n\n onScroll(e){\n var elm = e.target,\n pos = elm.scrollTop / (elm.scrollHeight - elm.parentNode.clientHeight) * 100;\n\n this.trigger(\"dropdown:scroll\", {percentage:Math.round(pos)})\n },\n }\n },\n\n /**\n * fill data into the suggestions list\n * (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)\n */\n refilter( value ){\n value = value || this.state.dropdown.query || ''\n this.suggestedListItems = this.dropdown.filterListItems(value)\n\n this.dropdown.fill()\n\n if( !this.suggestedListItems.length )\n this.dropdown.hide()\n\n this.trigger(\"dropdown:updated\", this.DOM.dropdown)\n },\n\n /**\n * Given a suggestion-item, return the data associated with it\n * @param {HTMLElement} tagElm\n * @returns Object\n */\n getSuggestionDataByNode( tagElm ){\n var item, value = tagElm && tagElm.getAttribute('value')\n\n for(var i = this.suggestedListItems.length; i--; ) {\n item = this.suggestedListItems[i]\n if( isObject(item) && item.value == value ) return item\n // for primitive whitelist items:\n else if( item == value ) return {value: item}\n }\n },\n\n getNextOrPrevOption(selected, next = true) {\n var dropdownItems = this.dropdown.getAllSuggestionsRefs(),\n selectedIdx = dropdownItems.findIndex(item => item === selected);\n\n return next ? dropdownItems[selectedIdx + 1] : dropdownItems[selectedIdx - 1]\n },\n\n /**\n * mark the currently active suggestion option\n * @param {Object} elm option DOM node\n * @param {Boolean} adjustScroll when navigation with keyboard arrows (up/down), aut-scroll to always show the highlighted element\n */\n highlightOption( elm, adjustScroll ){\n var className = this.settings.classNames.dropdownItemActive,\n itemData;\n\n // focus casues a bug in Firefox with the placeholder been shown on the input element\n // if( this.settings.dropdown.position != 'manual' )\n // elm.focus();\n\n if( this.state.ddItemElm ){\n this.state.ddItemElm.classList.remove(className)\n this.state.ddItemElm.removeAttribute(\"aria-selected\")\n }\n\n if( !elm ){\n this.state.ddItemData = null\n this.state.ddItemElm = null\n this.input.autocomplete.suggest.call(this)\n return;\n }\n\n itemData = this.dropdown.getSuggestionDataByNode(elm)\n this.state.ddItemData = itemData\n this.state.ddItemElm = elm\n\n // this.DOM.dropdown.querySelectorAll(\".\" + this.settings.classNames.dropdownItemActive).forEach(activeElm => activeElm.classList.remove(className));\n elm.classList.add(className);\n elm.setAttribute(\"aria-selected\", true)\n\n if( adjustScroll )\n elm.parentNode.scrollTop = elm.clientHeight + elm.offsetTop - elm.parentNode.clientHeight\n\n // Try to autocomplete the typed value with the currently highlighted dropdown item\n if( this.settings.autoComplete ){\n this.input.autocomplete.suggest.call(this, itemData)\n 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\n }\n },\n\n /**\n * Create a tag from the currently active suggestion option\n * @param {Object} elm DOM node to select\n * @param {Object} event The original Click event, if available (since keyboard ENTER key also triggers this method)\n */\n selectOption( elm, event ){\n var _s = this.settings,\n {clearOnSelect, closeOnSelect} = _s.dropdown;\n\n if( !elm ) {\n this.addTags(this.state.inputText, true)\n closeOnSelect && this.dropdown.hide()\n return;\n }\n\n event = event || {}\n\n // if in edit-mode, do not continue but instead replace the tag's text.\n // the scenario is that \"addTags\" was called from a dropdown suggested option selected while editing\n\n var value = elm.getAttribute('value'),\n isNoMatch = value == 'noMatch',\n isMixMode = _s.mode == 'mix',\n tagData = this.suggestedListItems.find(item => (item.value ?? item) == value)\n\n // The below event must be triggered, regardless of anything else which might go wrong\n this.trigger('dropdown:select', {data:tagData, elm, event})\n\n if( !value || !tagData && !isNoMatch ){\n closeOnSelect && setTimeout(this.dropdown.hide.bind(this))\n return\n }\n\n if( this.state.editing ) {\n let normalizedTagData = this.normalizeTags([tagData])[0]\n tagData = _s.transformTag.call(this, normalizedTagData) || normalizedTagData\n\n // normalizing value, because \"tagData\" might be a string, and therefore will not be able to extend the object\n this.onEditTagDone(null, extend({__isValid: true}, tagData))\n }\n // Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing\n else {\n this[isMixMode ? \"addMixTags\" : \"addTags\"]([tagData || this.input.raw.call(this)], clearOnSelect)\n }\n\n if( !isMixMode && !this.DOM.input.parentNode )\n return\n\n setTimeout(() => {\n this.DOM.input.focus()\n this.toggleFocusClass(true)\n })\n\n closeOnSelect && setTimeout(this.dropdown.hide.bind(this))\n\n // execute these tasks once a suggestion has been selected\n elm.addEventListener('transitionend', () => {\n this.dropdown.fillHeaderFooter()\n setTimeout(() => {\n elm.remove()\n this.dropdown.refilter()\n }, 100)\n }, {once: true})\n\n // hide selected suggestion\n elm.classList.add(this.settings.classNames.dropdownItemHidden)\n },\n\n // adds all the suggested items, including the ones which are not currently rendered,\n // unless specified otherwise (by the \"onlyRendered\" argument)\n selectAll( onlyRendered ){\n // having suggestedListItems with items messes with \"normalizeTags\" when wanting\n // to add all tags\n this.suggestedListItems.length = 0;\n this.dropdown.hide()\n\n this.dropdown.filterListItems('');\n\n var tagsToAdd = this.dropdown.filterListItems('');\n\n if( !onlyRendered )\n tagsToAdd = this.state.dropdown.suggestions\n\n // some whitelist items might have already been added as tags so when addings all of them,\n // skip adding already-added ones, so best to use \"filterListItems\" method over \"settings.whitelist\"\n this.addTags(tagsToAdd, true)\n return this\n },\n\n /**\n * returns an HTML string of the suggestions' list items\n * @param {String} value string to filter the whitelist by\n * @param {Object} options \"exact\" - for exact complete match\n * @return {Array} list of filtered whitelist items according to the settings provided and current value\n */\n filterListItems( value, options ){\n var _s = this.settings,\n _sd = _s.dropdown,\n options = options || {},\n list = [],\n exactMatchesList = [],\n whitelist = _s.whitelist,\n suggestionsCount = _sd.maxItems >= 0 ? _sd.maxItems : Infinity,\n includeSelectedTags = _sd.includeSelectedTags || _s.mode == 'select',\n searchKeys = _sd.searchKeys,\n whitelistItem,\n valueIsInWhitelist,\n searchBy,\n isDuplicate,\n niddle,\n i = 0;\n\n value = (_s.mode == 'select' && this.value.length && this.value[0][_s.tagTextProp] == value\n ? '' // do not filter if the tag, which is already selecetd in \"select\" mode, is the same as the typed text\n : value);\n\n if( !value || !searchKeys.length ){\n list = includeSelectedTags\n ? whitelist\n : whitelist.filter(item => !this.isTagDuplicate( isObject(item) ? item.value : item )) // don't include tags which have already been added.\n\n this.state.dropdown.suggestions = list;\n return list.slice(0, suggestionsCount); // respect \"maxItems\" dropdown setting\n }\n\n niddle = _sd.caseSensitive\n ? \"\"+value\n : (\"\"+value).toLowerCase()\n\n // checks if ALL of the words in the search query exists in the current whitelist item, regardless of their order\n function stringHasAll(s, query){\n return query.toLowerCase().split(' ').every(q => s.includes(q.toLowerCase()))\n }\n\n for( ; i < whitelist.length; i++ ){\n let startsWithMatch, exactMatch;\n\n whitelistItem = whitelist[i] instanceof Object ? whitelist[i] : { value:whitelist[i] } //normalize value as an Object\n\n let itemWithoutSearchKeys = !Object.keys(whitelistItem).some(k => searchKeys.includes(k) ),\n _searchKeys = itemWithoutSearchKeys ? [\"value\"] : searchKeys\n\n if( _sd.fuzzySearch && !options.exact ){\n searchBy = _searchKeys.reduce((values, k) => values + \" \" + (whitelistItem[k]||\"\"), \"\").toLowerCase().trim()\n\n if( _sd.accentedSearch ){\n searchBy = unaccent(searchBy)\n niddle = unaccent(niddle)\n }\n\n startsWithMatch = searchBy.indexOf(niddle) == 0\n exactMatch = searchBy === niddle\n valueIsInWhitelist = stringHasAll(searchBy, niddle)\n }\n\n else {\n startsWithMatch = true;\n valueIsInWhitelist = _searchKeys.some(k => {\n var v = '' + (whitelistItem[k] || '') // if key exists, cast to type String\n\n if( _sd.accentedSearch ){\n v = unaccent(v)\n niddle = unaccent(niddle)\n }\n\n if( !_sd.caseSensitive )\n v = v.toLowerCase()\n\n exactMatch = v === niddle\n\n return options.exact\n ? v === niddle\n : v.indexOf(niddle) == 0\n })\n }\n\n isDuplicate = !_sd.includeSelectedTags && this.isTagDuplicate( isObject(whitelistItem) ? whitelistItem.value : whitelistItem )\n\n // match for the value within each \"whitelist\" item\n if( valueIsInWhitelist && !isDuplicate )\n if( exactMatch && startsWithMatch)\n exactMatchesList.push(whitelistItem)\n else if( _sd.sortby == 'startsWith' && startsWithMatch )\n list.unshift(whitelistItem)\n else\n list.push(whitelistItem)\n }\n\n this.state.dropdown.suggestions = exactMatchesList.concat(list);\n\n // custom sorting function\n return typeof _sd.sortby == 'function'\n ? _sd.sortby(exactMatchesList.concat(list), niddle)\n : exactMatchesList.concat(list).slice(0, suggestionsCount)\n },\n\n /**\n * Returns the final value of a tag data (object) with regards to the \"mapValueTo\" dropdown setting\n * @param {Object} tagData\n * @returns\n */\n getMappedValue(tagData){\n var mapValueTo = this.settings.dropdown.mapValueTo,\n value = (mapValueTo\n ? typeof mapValueTo == 'function' ? mapValueTo(tagData) : (tagData[mapValueTo] || tagData.value)\n : tagData.value);\n\n return value\n },\n\n /**\n * Creates the dropdown items' HTML\n * @param {Array} sugegstionsList [Array of Objects]\n * @return {String}\n */\n createListHTML( sugegstionsList ){\n return extend([], sugegstionsList).map((suggestion, idx) => {\n if( typeof suggestion == 'string' || typeof suggestion == 'number' )\n suggestion = {value:suggestion}\n\n var mappedValue = this.dropdown.getMappedValue(suggestion);\n\n mappedValue = (typeof mappedValue == 'string' && this.settings.dropdown.escapeHTML)\n ? escapeHTML(mappedValue)\n : mappedValue;\n\n return this.settings.templates.dropdownItem.apply(this, [{...suggestion, mappedValue}, this])\n }).join(\"\")\n }\n}","import { sameStr, isObject, minify, getNodeHeight, getCaretGlobalPosition } from './helpers'\r\nimport suggestionsMethods from './suggestions'\r\n\r\nexport function initDropdown(){\r\n this.dropdown = {}\r\n\r\n // auto-bind \"this\" to all the dropdown methods\r\n for( let p in this._dropdown )\r\n this.dropdown[p] = typeof this._dropdown[p] === 'function'\r\n ? this._dropdown[p].bind(this)\r\n : this._dropdown[p]\r\n\r\n this.dropdown.refs()\r\n this.DOM.dropdown.__tagify = this\r\n}\r\n\r\nexport default {\r\n ...suggestionsMethods,\r\n\r\n refs(){\r\n this.DOM.dropdown = this.parseTemplate('dropdown', [this.settings])\r\n this.DOM.dropdown.content = this.DOM.dropdown.querySelector(\"[data-selector='tagify-suggestions-wrapper']\")\r\n },\r\n\r\n getHeaderRef(){\r\n return this.DOM.dropdown.querySelector(\"[data-selector='tagify-suggestions-header']\")\r\n },\r\n\r\n getFooterRef(){\r\n return this.DOM.dropdown.querySelector(\"[data-selector='tagify-suggestions-footer']\")\r\n },\r\n\r\n getAllSuggestionsRefs(){\r\n return [...this.DOM.dropdown.content.querySelectorAll(this.settings.classNames.dropdownItemSelector)]\r\n },\r\n\r\n /**\r\n * shows the suggestions select box\r\n * @param {String} value [optional, filter the whitelist by this value]\r\n */\r\n show( value ){\r\n var _s = this.settings,\r\n firstListItem,\r\n firstListItemValue,\r\n allowNewTags = _s.mode == 'mix' && !_s.enforceWhitelist,\r\n noWhitelist = !_s.whitelist || !_s.whitelist.length,\r\n noMatchListItem,\r\n isManual = _s.dropdown.position == 'manual';\r\n\r\n // if text still exists in the input, and `show` method has no argument, then the input's text should be used\r\n value = value === undefined ? this.state.inputText : value\r\n\r\n // ⚠️ Do not render suggestions list if:\r\n // 1. there's no whitelist (can happen while async loading) AND new tags arn't allowed\r\n // 2. dropdown is disabled\r\n // 3. loader is showing (controlled outside of this code)\r\n if( (noWhitelist && !allowNewTags && !_s.templates.dropdownItemNoMatch)\r\n || _s.dropdown.enable === false\r\n || this.state.isLoading\r\n || this.settings.readonly )\r\n return;\r\n\r\n clearTimeout(this.dropdownHide__bindEventsTimeout)\r\n\r\n // if no value was supplied, show all the \"whitelist\" items in the dropdown\r\n // @type [Array] listItems\r\n this.suggestedListItems = this.dropdown.filterListItems(value)\r\n\r\n // trigger at this exact point to let the developer the chance to manually set \"this.suggestedListItems\"\r\n if( value && !this.suggestedListItems.length ){\r\n this.trigger('dropdown:noMatch', value)\r\n\r\n if( _s.templates.dropdownItemNoMatch )\r\n noMatchListItem = _s.templates.dropdownItemNoMatch.call(this, {value})\r\n }\r\n\r\n // if \"dropdownItemNoMatch\" was not defined, procceed regular flow.\r\n //\r\n if( !noMatchListItem ){\r\n // in mix-mode, if the value isn't included in the whilelist & \"enforceWhitelist\" setting is \"false\",\r\n // then add a custom suggestion item to the dropdown\r\n if( this.suggestedListItems.length ){\r\n if( value && allowNewTags && !this.state.editing.scope && !sameStr(this.suggestedListItems[0].value, value) )\r\n this.suggestedListItems.unshift({value})\r\n }\r\n else{\r\n if( value && allowNewTags && !this.state.editing.scope ){\r\n this.suggestedListItems = [{value}]\r\n }\r\n // hide suggestions list if no suggestion matched\r\n else{\r\n this.input.autocomplete.suggest.call(this);\r\n this.dropdown.hide()\r\n return;\r\n }\r\n }\r\n\r\n firstListItem = this.suggestedListItems[0]\r\n firstListItemValue = \"\"+(isObject(firstListItem) ? firstListItem.value : firstListItem)\r\n\r\n if( _s.autoComplete && firstListItemValue ){\r\n // only fill the sugegstion if the value of the first list item STARTS with the input value (regardless of \"fuzzysearch\" setting)\r\n if( firstListItemValue.indexOf(value) == 0 )\r\n this.input.autocomplete.suggest.call(this, firstListItem)\r\n }\r\n }\r\n\r\n this.dropdown.fill(noMatchListItem)\r\n\r\n if( _s.dropdown.highlightFirst ) {\r\n this.dropdown.highlightOption(this.DOM.dropdown.content.querySelector(_s.classNames.dropdownItemSelector))\r\n }\r\n\r\n // bind events, exactly at this stage of the code. \"dropdown.show\" method is allowed to be\r\n // called multiple times, regardless if the dropdown is currently visible, but the events-binding\r\n // should only be called if the dropdown wasn't previously visible.\r\n if( !this.state.dropdown.visible )\r\n // timeout is needed for when pressing arrow down to show the dropdown,\r\n // so the key event won't get registered in the dropdown events listeners\r\n setTimeout(this.dropdown.events.binding.bind(this))\r\n\r\n // set the dropdown visible state to be the same as the searched value.\r\n // MUST be set *before* position() is called\r\n this.state.dropdown.visible = value || true\r\n this.state.dropdown.query = value\r\n\r\n this.setStateSelection()\r\n\r\n // try to positioning the dropdown (it might not yet be on the page, doesn't matter, next code handles this)\r\n if( !isManual ){\r\n // a slight delay is needed if the dropdown \"position\" setting is \"text\", and nothing was typed in the input,\r\n // so sadly the \"getCaretGlobalPosition\" method doesn't recognize the caret position without this delay\r\n setTimeout(() => {\r\n this.dropdown.position()\r\n this.dropdown.render()\r\n })\r\n }\r\n\r\n // a delay is needed because of the previous delay reason.\r\n // this event must be fired after the dropdown was rendered & positioned\r\n setTimeout(() => {\r\n this.trigger(\"dropdown:show\", this.DOM.dropdown)\r\n })\r\n },\r\n\r\n /**\r\n * Hides the dropdown (if it's not managed manually by the developer)\r\n * @param {Boolean} overrideManual\r\n */\r\n hide( overrideManual ){\r\n var {scope, dropdown} = this.DOM,\r\n isManual = this.settings.dropdown.position == 'manual' && !overrideManual;\r\n\r\n // if there's no dropdown, this means the dropdown events aren't binded\r\n if( !dropdown || !document.body.contains(dropdown) || isManual ) return;\r\n\r\n window.removeEventListener('resize', this.dropdown.position)\r\n this.dropdown.events.binding.call(this, false) // unbind all events\r\n\r\n // if the dropdown is open, and the input (scope) is clicked,\r\n // the dropdown should be now \"close\", and the next click (on the scope)\r\n // should re-open it, and without a timeout, clicking to close will re-open immediately\r\n // clearTimeout(this.dropdownHide__bindEventsTimeout)\r\n // this.dropdownHide__bindEventsTimeout = setTimeout(this.events.binding.bind(this), 250) // re-bind main events\r\n\r\n\r\n scope.setAttribute(\"aria-expanded\", false)\r\n dropdown.parentNode.removeChild(dropdown)\r\n\r\n // scenario: clicking the scope to show the dropdown, clicking again to hide -> calls dropdown.hide() and then re-focuses the input\r\n // which casues another onFocus event, which checked \"this.state.dropdown.visible\" and see it as \"false\" and re-open the dropdown\r\n setTimeout(() => {\r\n this.state.dropdown.visible = false\r\n }, 100)\r\n\r\n this.state.dropdown.query =\r\n this.state.ddItemData =\r\n this.state.ddItemElm =\r\n this.state.selection = null\r\n\r\n // if the user closed the dropdown (in mix-mode) while a potential tag was detected, flag the current tag\r\n // so the dropdown won't be shown on following user input for that \"tag\"\r\n if( this.state.tag && this.state.tag.value.length ){\r\n this.state.flaggedTags[this.state.tag.baseOffset] = this.state.tag\r\n }\r\n\r\n this.trigger(\"dropdown:hide\", dropdown)\r\n\r\n return this\r\n },\r\n\r\n /**\r\n * Toggles dropdown show/hide\r\n * @param {Boolean} show forces the dropdown to show\r\n */\r\n toggle(show){\r\n this.dropdown[this.state.dropdown.visible && !show ? 'hide' : 'show']()\r\n },\r\n\r\n getAppendTarget() {\r\n var _sd = this.settings.dropdown;\r\n return typeof _sd.appendTarget === 'function' ? _sd.appendTarget() : _sd.appendTarget;\r\n },\r\n\r\n render(){\r\n // let the element render in the DOM first, to accurately measure it.\r\n // this.DOM.dropdown.style.cssText = \"left:-9999px; top:-9999px;\";\r\n var ddHeight = getNodeHeight(this.DOM.dropdown),\r\n _s = this.settings,\r\n enabled = typeof _s.dropdown.enabled == 'number' && _s.dropdown.enabled >= 0,\r\n appendTarget = this.dropdown.getAppendTarget();\r\n\r\n if( !enabled ) return this;\r\n\r\n this.DOM.scope.setAttribute(\"aria-expanded\", true)\r\n\r\n // if the dropdown has yet to be appended to the DOM,\r\n // append the dropdown to the body element & handle events\r\n if( !document.body.contains(this.DOM.dropdown) ){\r\n this.DOM.dropdown.classList.add( _s.classNames.dropdownInital )\r\n this.dropdown.position(ddHeight)\r\n appendTarget.appendChild(this.DOM.dropdown)\r\n\r\n setTimeout(() =>\r\n this.DOM.dropdown.classList.remove( _s.classNames.dropdownInital )\r\n )\r\n }\r\n\r\n return this\r\n },\r\n\r\n /**\r\n * re-renders the dropdown content element (see \"dropdownContent\" in templates file)\r\n * @param {String/Array} HTMLContent - optional\r\n */\r\n fill( HTMLContent ){\r\n HTMLContent = typeof HTMLContent == 'string'\r\n ? HTMLContent\r\n : this.dropdown.createListHTML(HTMLContent || this.suggestedListItems)\r\n\r\n var dropdownContent = this.settings.templates.dropdownContent.call(this, HTMLContent)\r\n\r\n this.DOM.dropdown.content.innerHTML = minify(dropdownContent)\r\n },\r\n\r\n /**\r\n * Re-renders only the header & footer.\r\n * Used when selecting a suggestion and it is wanted that the suggestions dropdown stays open.\r\n * Since the list of sugegstions is not being re-rendered completely every time a suggestion is selected (the item is transitioned-out)\r\n * then the header & footer should be kept in sync with the suggestions data change\r\n */