UNPKG

similiquedeserunt

Version:

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

1,253 lines (1,176 loc) 154 kB
/** * Tagify (v 4.19.0) - tags input component * By undefined * https://github.com/yairEO/tagify * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * THE SOFTWARE IS NOT PERMISSIBLE TO BE SOLD. */ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } function _objectSpread2(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } var ZERO_WIDTH_CHAR = '\u200B'; // console.json = console.json || function(argument){ // for(var arg=0; arg < arguments.length; ++arg) // console.log( JSON.stringify(arguments[arg], null, 4) ) // } // const isEdge = /Edge/.test(navigator.userAgent) const sameStr = (s1, s2, caseSensitive, trim) => { // cast to String s1 = "" + s1; s2 = "" + s2; if (trim) { s1 = s1.trim(); s2 = s2.trim(); } return caseSensitive ? s1 == s2 : s1.toLowerCase() == s2.toLowerCase(); }; // const getUID = () => (new Date().getTime() + Math.floor((Math.random()*10000)+1)).toString(16) const removeCollectionProp = (collection, unwantedProps) => collection && Array.isArray(collection) && collection.map(v => omit(v, unwantedProps)); function omit(obj, props) { var newObj = {}, p; for (p in obj) if (props.indexOf(p) < 0) newObj[p] = obj[p]; return newObj; } function decode(s) { var el = document.createElement('div'); return s.replace(/\&#?[0-9a-z]+;/gi, function (enc) { el.innerHTML = enc; return el.innerText; }); } /** * utility method * https://stackoverflow.com/a/35385518/104380 * @param {String} s [HTML string] * @return {Object} [DOM node] */ function parseHTML(s) { var parser = new DOMParser(), node = parser.parseFromString(s.trim(), "text/html"); return node.body.firstElementChild; } /** * Removed new lines and irrelevant spaces which might affect layout, and are better gone * @param {string} s [HTML string] */ function minify(s) { return s ? s.replace(/\>[\r\n ]+\</g, "><").split(/>\s+</).join('><').trim() : ""; } function removeTextChildNodes(elm) { var iter = document.createNodeIterator(elm, NodeFilter.SHOW_TEXT, null, false), textnode; // print all text nodes while (textnode = iter.nextNode()) { if (!textnode.textContent.trim()) textnode.parentNode.removeChild(textnode); } } function getfirstTextNode(elm, action) { action = action || 'previous'; while (elm = elm[action + 'Sibling']) if (elm.nodeType == 3) return elm; } /** * utility method * https://stackoverflow.com/a/6234804/104380 */ function escapeHTML(s) { return typeof s == 'string' ? s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/`|'/g, "&#039;") : s; } /** * Checks if an argument is a javascript Object */ function isObject(obj) { var type = Object.prototype.toString.call(obj).split(' ')[1].slice(0, -1); return obj === Object(obj) && type != 'Array' && type != 'Function' && type != 'RegExp' && type != 'HTMLUnknownElement'; } /** * merge objects into a single new one * TEST: extend({}, {a:{foo:1}, b:[]}, {a:{bar:2}, b:[1], c:()=>{}}) */ function extend(o, o1, o2) { if (!(o instanceof Object)) o = {}; copy(o, o1); if (o2) copy(o, o2); function copy(a, b) { // copy o2 to o for (var key in b) if (b.hasOwnProperty(key)) { if (isObject(b[key])) { if (!isObject(a[key])) a[key] = Object.assign({}, b[key]);else copy(a[key], b[key]); continue; } if (Array.isArray(b[key])) { a[key] = Object.assign([], b[key]); continue; } a[key] = b[key]; } } return o; } /** * concatenates N arrays without dups. * If an array's item is an Object, compare by `value` */ function concatWithoutDups() { const newArr = [], existingObj = {}; for (let arr of arguments) { for (let item of arr) { // if current item is an object which has yet to be added to the new array if (isObject(item)) { if (!existingObj[item.value]) { newArr.push(item); existingObj[item.value] = 1; } } // if current item is not an object and is not in the new array else if (!newArr.includes(item)) newArr.push(item); } } return newArr; } /** * Extracted from: https://stackoverflow.com/a/37511463/104380 * @param {String} s */ function unaccent(s) { // if not supported, do not continue. // developers should use a polyfill: // https://github.com/walling/unorm if (!String.prototype.normalize) return s; if (typeof s === 'string') return s.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } /** * Meassures an element's height, which might yet have been added DOM * https://stackoverflow.com/q/5944038/104380 * @param {DOM} node */ function getNodeHeight(node) { var height, clone = node.cloneNode(true); clone.style.cssText = "position:fixed; top:-9999px; opacity:0"; document.body.appendChild(clone); height = clone.clientHeight; clone.parentNode.removeChild(clone); return height; } var isChromeAndroidBrowser = () => /(?=.*chrome)(?=.*android)/i.test(navigator.userAgent); function getUID() { return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); } function isNodeTag(node) { return node && node.classList && node.classList.contains(this.settings.classNames.tag); } /** * Get the caret position relative to the viewport * https://stackoverflow.com/q/58985076/104380 * * @returns {object} left, top distance in pixels */ function 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 }; } /** * Injects content (either string or node) at the current the current (or specificed) caret position * @param {content} string/node * @param {range} Object (optional, a range other than the current window selection) */ function injectAtCaret(content, range) { var selection = window.getSelection(); range = range || selection.getRangeAt(0); if (typeof content == 'string') content = document.createTextNode(content); if (range) { range.deleteContents(); range.insertNode(content); } return content; } /** Setter/Getter * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data * @param {Node} tagElm * @param {Object} data */ function getSetTagData(tagElm, data, override) { if (!tagElm) { console.warn("tag element doesn't exist", tagElm, data); return data; } if (data) tagElm.__tagifyTagData = override ? data : extend({}, tagElm.__tagifyTagData || {}, data); return tagElm.__tagifyTagData; } function placeCaretAfterNode(node) { if (!node || !node.parentNode) return; var nextSibling = node, sel = window.getSelection(), range = sel.getRangeAt(0); if (sel.rangeCount) { range.setStartAfter(nextSibling); range.collapse(true); // range.setEndBefore(nextSibling || node); sel.removeAllRanges(); sel.addRange(range); } } /** * iterate all tags, checking if multiple ones are close-siblings and if so, add a zero-space width character between them, * which forces the caret to be rendered when the selection is between tags. * Also do that if the tag is the first node. * @param {Array} tags */ function fixCaretBetweenTags(tags, TagifyHasFocuse) { tags.forEach(tag => { if (getSetTagData(tag.previousSibling) || !tag.previousSibling) { var textNode = document.createTextNode(ZERO_WIDTH_CHAR); tag.before(textNode); TagifyHasFocuse && placeCaretAfterNode(textNode); } }); } var DEFAULTS = { delimiters: ",", // [RegEx] split tags by any of these delimiters ("null" to cancel) Example: ",| |." pattern: null, // RegEx pattern to validate input by. Ex: /[1-9]/ tagTextProp: 'value', // tag data Object property which will be displayed as the tag's text maxTags: Infinity, // Maximum number of tags callbacks: {}, // Exposed callbacks object to be triggered on certain events addTagOnBlur: true, // automatically adds the text which was inputed as a tag when blur event happens 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 onChangeAfterBlur: true, // By default, the native way of inputs' onChange events is kept, and it only fires when the field is blured. duplicates: false, // "true" - allow duplicate tags whitelist: [], // Array of tags to suggest as the user types (can be used along with "enforceWhitelist" setting) blacklist: [], // A list of non-allowed tags enforceWhitelist: false, // Only allow tags from the whitelist userInput: true, // disable manually typing/pasting/editing tags (tags may only be added from the whitelist) keepInvalidTags: false, // if true, do not remove tags which did not pass validation createInvalidTags: true, // if false, do not create invalid tags from invalid user input mixTagsAllowedAfter: /,|\.|\:|\s/, // RegEx - Define conditions in which mix-tags content allows a tag to be added after mixTagsInterpolator: ['[[', ']]'], // Interpolation for mix mode. Everything between these will become a tag, if is a valid Object backspace: true, // false / true / "edit" skipInvalid: false, // If `true`, do not add invalid, temporary, tags before automatically removing them pasteAsTags: true, // automatically converts pasted text into tags. if "false", allows for further text editing editTags: { clicks: 2, // clicks to enter "edit-mode": 1 for single click. any other value is considered as double-click keepInvalid: true // keeps invalid edits as-is until `esc` is pressed while in focus }, // 1 or 2 clicks to edit a tag. false/null for not allowing editing transformTag: () => {}, // Takes a tag input string as argument and returns a transformed value trim: true, // whether or not the value provided should be trimmed, before being added as a tag a11y: { focusableTags: false }, mixMode: { insertAfterTag: '\u00A0' // String/Node to inject after a tag has been added (see #588) }, autoComplete: { enabled: true, // Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text 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" tabKey: false // If 'true`, pressing `tab` key would only auto-complete but not also convert to a tag (like `rightKey` does). }, classNames: { namespace: 'tagify', mixMode: 'tagify--mix', selectMode: 'tagify--select', input: 'tagify__input', focus: 'tagify--focus', tagNoAnimation: 'tagify--noAnim', tagInvalid: 'tagify--invalid', tagNotAllowed: 'tagify--notAllowed', scopeLoading: 'tagify--loading', hasMaxTags: 'tagify--hasMaxTags', hasNoTags: 'tagify--noTags', empty: 'tagify--empty', inputInvalid: 'tagify__input--invalid', dropdown: 'tagify__dropdown', dropdownWrapper: 'tagify__dropdown__wrapper', dropdownHeader: 'tagify__dropdown__header', dropdownFooter: 'tagify__dropdown__footer', dropdownItem: 'tagify__dropdown__item', dropdownItemActive: 'tagify__dropdown__item--active', dropdownItemHidden: 'tagify__dropdown__item--hidden', dropdownInital: 'tagify__dropdown--initial', tag: 'tagify__tag', tagText: 'tagify__tag-text', tagX: 'tagify__tag__removeBtn', tagLoading: 'tagify__tag--loading', tagEditing: 'tagify__tag--editable', tagFlash: 'tagify__tag--flash', tagHide: 'tagify__tag--hide' }, dropdown: { classname: '', enabled: 2, // minimum input characters to be typed for the suggestions dropdown to show maxItems: 10, searchKeys: ["value", "searchBy"], fuzzySearch: true, caseSensitive: false, accentedSearch: true, includeSelectedTags: false, // Should the suggestions list Include already-selected tags (after filtering) escapeHTML: true, // escapes HTML entities in the suggestions' rendered text highlightFirst: false, // highlights first-matched item in the list closeOnSelect: true, // closes the dropdown after selecting an item, if `enabled:0` (which means always show dropdown) clearOnSelect: true, // after selecting a suggetion, should the typed text input remain or be cleared position: 'all', // 'manual' / 'text' / 'all' appendTarget: null // defaults to document.body once DOM has been loaded }, hooks: { beforeRemoveTag: () => Promise.resolve(), beforePaste: () => Promise.resolve(), suggestionClick: () => Promise.resolve(), beforeKeyDown: () => Promise.resolve() } }; function initDropdown() { this.dropdown = {}; // auto-bind "this" to all the dropdown methods for (let p in this._dropdown) this.dropdown[p] = typeof this._dropdown[p] === 'function' ? this._dropdown[p].bind(this) : this._dropdown[p]; this.dropdown.refs(); } var _dropdown = { refs() { this.DOM.dropdown = this.parseTemplate('dropdown', [this.settings]); this.DOM.dropdown.content = this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-wrapper']"); }, getHeaderRef() { return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-header']"); }, getFooterRef() { return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-footer']"); }, getAllSuggestionsRefs() { return [...this.DOM.dropdown.content.querySelectorAll(this.settings.classNames.dropdownItemSelector)]; }, /** * shows the suggestions select box * @param {String} value [optional, filter the whitelist by this value] */ show(value) { var _s = this.settings, firstListItem, firstListItemValue, allowNewTags = _s.mode == 'mix' && !_s.enforceWhitelist, noWhitelist = !_s.whitelist || !_s.whitelist.length, noMatchListItem, isManual = _s.dropdown.position == 'manual'; // if text still exists in the input, and `show` method has no argument, then the input's text should be used value = value === undefined ? this.state.inputText : value; // ⚠️ Do not render suggestions list if: // 1. there's no whitelist (can happen while async loading) AND new tags arn't allowed // 2. dropdown is disabled // 3. loader is showing (controlled outside of this code) if (noWhitelist && !allowNewTags && !_s.templates.dropdownItemNoMatch || _s.dropdown.enable === false || this.state.isLoading || this.settings.readonly) return; clearTimeout(this.dropdownHide__bindEventsTimeout); // if no value was supplied, show all the "whitelist" items in the dropdown // @type [Array] listItems this.suggestedListItems = this.dropdown.filterListItems(value); // trigger at this exact point to let the developer the chance to manually set "this.suggestedListItems" if (value && !this.suggestedListItems.length) { this.trigger('dropdown:noMatch', value); if (_s.templates.dropdownItemNoMatch) noMatchListItem = _s.templates.dropdownItemNoMatch.call(this, { value }); } // if "dropdownItemNoMatch" was not defined, procceed regular flow. // if (!noMatchListItem) { // in mix-mode, if the value isn't included in the whilelist & "enforceWhitelist" setting is "false", // then add a custom suggestion item to the dropdown if (this.suggestedListItems.length) { if (value && allowNewTags && !this.state.editing.scope && !sameStr(this.suggestedListItems[0].value, value)) this.suggestedListItems.unshift({ value }); } else { if (value && allowNewTags && !this.state.editing.scope) { this.suggestedListItems = [{ value }]; } // hide suggestions list if no suggestion matched else { this.input.autocomplete.suggest.call(this); this.dropdown.hide(); return; } } firstListItem = this.suggestedListItems[0]; firstListItemValue = "" + (isObject(firstListItem) ? firstListItem.value : firstListItem); if (_s.autoComplete && firstListItemValue) { // only fill the sugegstion if the value of the first list item STARTS with the input value (regardless of "fuzzysearch" setting) if (firstListItemValue.indexOf(value) == 0) this.input.autocomplete.suggest.call(this, firstListItem); } } this.dropdown.fill(noMatchListItem); if (_s.dropdown.highlightFirst) { this.dropdown.highlightOption(this.DOM.dropdown.content.querySelector(_s.classNames.dropdownItemSelector)); } // bind events, exactly at this stage of the code. "dropdown.show" method is allowed to be // called multiple times, regardless if the dropdown is currently visible, but the events-binding // should only be called if the dropdown wasn't previously visible. if (!this.state.dropdown.visible) // timeout is needed for when pressing arrow down to show the dropdown, // so the key event won't get registered in the dropdown events listeners setTimeout(this.dropdown.events.binding.bind(this)); // set the dropdown visible state to be the same as the searched value. // MUST be set *before* position() is called this.state.dropdown.visible = value || true; this.state.dropdown.query = value; this.setStateSelection(); // try to positioning the dropdown (it might not yet be on the page, doesn't matter, next code handles this) if (!isManual) { // a slight delay is needed if the dropdown "position" setting is "text", and nothing was typed in the input, // so sadly the "getCaretGlobalPosition" method doesn't recognize the caret position without this delay setTimeout(() => { this.dropdown.position(); this.dropdown.render(); }); } // a delay is needed because of the previous delay reason. // this event must be fired after the dropdown was rendered & positioned setTimeout(() => { this.trigger("dropdown:show", this.DOM.dropdown); }); }, /** * Hides the dropdown (if it's not managed manually by the developer) * @param {Boolean} overrideManual */ hide(overrideManual) { var _this$DOM = this.DOM, scope = _this$DOM.scope, dropdown = _this$DOM.dropdown, isManual = this.settings.dropdown.position == 'manual' && !overrideManual; // if there's no dropdown, this means the dropdown events aren't binded if (!dropdown || !document.body.contains(dropdown) || isManual) return; window.removeEventListener('resize', this.dropdown.position); this.dropdown.events.binding.call(this, false); // unbind all events // if the dropdown is open, and the input (scope) is clicked, // the dropdown should be now "close", and the next click (on the scope) // should re-open it, and without a timeout, clicking to close will re-open immediately // clearTimeout(this.dropdownHide__bindEventsTimeout) // this.dropdownHide__bindEventsTimeout = setTimeout(this.events.binding.bind(this), 250) // re-bind main events scope.setAttribute("aria-expanded", false); dropdown.parentNode.removeChild(dropdown); // scenario: clicking the scope to show the dropdown, clicking again to hide -> calls dropdown.hide() and then re-focuses the input // which casues another onFocus event, which checked "this.state.dropdown.visible" and see it as "false" and re-open the dropdown setTimeout(() => { this.state.dropdown.visible = false; }, 100); this.state.dropdown.query = this.state.ddItemData = this.state.ddItemElm = this.state.selection = null; // if the user closed the dropdown (in mix-mode) while a potential tag was detected, flag the current tag // so the dropdown won't be shown on following user input for that "tag" if (this.state.tag && this.state.tag.value.length) { this.state.flaggedTags[this.state.tag.baseOffset] = this.state.tag; } this.trigger("dropdown:hide", dropdown); return this; }, /** * Toggles dropdown show/hide * @param {Boolean} show forces the dropdown to show */ toggle(show) { this.dropdown[this.state.dropdown.visible && !show ? 'hide' : 'show'](); }, getAppendTarget() { var _sd = this.settings.dropdown; return typeof _sd.appendTarget === 'function' ? _sd.appendTarget() : _sd.appendTarget; }, render() { // let the element render in the DOM first, to accurately measure it. // this.DOM.dropdown.style.cssText = "left:-9999px; top:-9999px;"; var ddHeight = getNodeHeight(this.DOM.dropdown), _s = this.settings, enabled = typeof _s.dropdown.enabled == 'number' && _s.dropdown.enabled >= 0, appendTarget = this.dropdown.getAppendTarget(); if (!enabled) return this; this.DOM.scope.setAttribute("aria-expanded", true); // if the dropdown has yet to be appended to the DOM, // append the dropdown to the body element & handle events if (!document.body.contains(this.DOM.dropdown)) { this.DOM.dropdown.classList.add(_s.classNames.dropdownInital); this.dropdown.position(ddHeight); appendTarget.appendChild(this.DOM.dropdown); setTimeout(() => this.DOM.dropdown.classList.remove(_s.classNames.dropdownInital)); } return this; }, /** * re-renders the dropdown content element (see "dropdownContent" in templates file) * @param {String/Array} HTMLContent - optional */ fill(HTMLContent) { HTMLContent = typeof HTMLContent == 'string' ? HTMLContent : this.dropdown.createListHTML(HTMLContent || this.suggestedListItems); var dropdownContent = this.settings.templates.dropdownContent.call(this, HTMLContent); this.DOM.dropdown.content.innerHTML = minify(dropdownContent); }, /** * Re-renders only the header & footer. * Used when selecting a suggestion and it is wanted that the suggestions dropdown stays open. * Since the list of sugegstions is not being re-rendered completely every time a suggestion is selected (the item is transitioned-out) * then the header & footer should be kept in sync with the suggestions data change */ fillHeaderFooter() { var suggestions = this.dropdown.filterListItems(this.state.dropdown.query), newHeaderElem = this.parseTemplate('dropdownHeader', [suggestions]), newFooterElem = this.parseTemplate('dropdownFooter', [suggestions]), headerRef = this.dropdown.getHeaderRef(), footerRef = this.dropdown.getFooterRef(); newHeaderElem && headerRef?.parentNode.replaceChild(newHeaderElem, headerRef); newFooterElem && footerRef?.parentNode.replaceChild(newFooterElem, footerRef); }, /** * 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); }, position(ddHeight) { var _sd = this.settings.dropdown, appendTarget = this.dropdown.getAppendTarget(); if (_sd.position == 'manual' || !appendTarget) return; var rect, top, bottom, left, width, ancestorsOffsets, isPlacedAbove, cssTop, cssLeft, ddElm = this.DOM.dropdown, isRTL = _sd.RTL, isDefaultAppendTarget = appendTarget === document.body, isSelfAppended = appendTarget === this.DOM.scope, appendTargetScrollTop = isDefaultAppendTarget ? window.pageYOffset : appendTarget.scrollTop, root = document.fullscreenElement || document.webkitFullscreenElement || document.documentElement, viewportHeight = root.clientHeight, viewportWidth = Math.max(root.clientWidth || 0, window.innerWidth || 0), positionTo = viewportWidth > 480 ? _sd.position : 'all', ddTarget = this.DOM[positionTo == 'input' ? 'input' : 'scope']; ddHeight = ddHeight || ddElm.clientHeight; function getAncestorsOffsets(p) { var top = 0, left = 0; p = p.parentNode; // when in element-fullscreen mode, do not go above the fullscreened-element while (p && p != root) { top += p.offsetTop || 0; left += p.offsetLeft || 0; p = p.parentNode; } return { top, left }; } function getAccumulatedAncestorsScrollTop() { var scrollTop = 0, p = _sd.appendTarget.parentNode; while (p) { scrollTop += p.scrollTop || 0; p = p.parentNode; } return scrollTop; } if (!this.state.dropdown.visible) return; if (positionTo == 'text') { rect = getCaretGlobalPosition(); bottom = rect.bottom; top = rect.top; left = rect.left; width = 'auto'; } else { ancestorsOffsets = getAncestorsOffsets(appendTarget); rect = ddTarget.getBoundingClientRect(); top = isSelfAppended ? -1 : rect.top - ancestorsOffsets.top; bottom = (isSelfAppended ? rect.height : rect.bottom - ancestorsOffsets.top) - 1; left = isSelfAppended ? -1 : rect.left - ancestorsOffsets.left; width = rect.width + 'px'; } // if the "append target" isn't the default, correct the `top` variable by ignoring any scrollTop of the target's Ancestors if (!isDefaultAppendTarget) { let accumulatedAncestorsScrollTop = getAccumulatedAncestorsScrollTop(); top += accumulatedAncestorsScrollTop; bottom += accumulatedAncestorsScrollTop; } top = Math.floor(top); bottom = Math.ceil(bottom); isPlacedAbove = _sd.placeAbove ?? viewportHeight - rect.bottom < ddHeight; // flip vertically if there is no space for the dropdown below the input cssTop = (isPlacedAbove ? top : bottom) + appendTargetScrollTop; // "pageXOffset" property is an alias for "scrollX" cssLeft = `left: ${left + (isRTL ? rect.width || 0 : 0) + window.pageXOffset}px;`; // rtl = rtl ?? viewportWidth - ddElm.style.cssText = `${cssLeft}; top: ${cssTop}px; min-width: ${width}; max-width: ${width}`; ddElm.setAttribute('placement', isPlacedAbove ? 'top' : 'bottom'); ddElm.setAttribute('position', positionTo); }, 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() { let bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 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, selectedElm = this.DOM.dropdown.querySelector(_s.classNames.dropdownItemActiveSelector), selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm), isMixMode = _s.mode == 'mix'; _s.hooks.beforeKeyDown(e, { tagify: this }).then(result => { console.log(e.key); 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 'Escape': case 'Esc': // IE11 this.dropdown.hide(); break; case 'ArrowRight': if (this.state.actions.ArrowLeft) 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 && selectedElm && shouldAutocompleteOnKey && !this.state.editing) { e.preventDefault(); // prevents blur so the autocomplete suggestion will not become a tag var value = this.dropdown.getMappedValue(selectedElmData); this.input.autocomplete.set.call(this, value); return false; } return true; } case 'Enter': { e.preventDefault(); _s.hooks.suggestionClick(e, { tagify: this, tagData: selectedElmData, suggestionElm: selectedElm }).then(() => { if (selectedElm) { this.dropdown.selectOption(selectedElm); // highlight next option selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp); this.dropdown.highlightOption(selectedElm); return; } else this.dropdown.hide(); if (!isMixMode) this.addTags(this.state.inputText.trim(), true); }).catch(err => 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 run this.state.actions.selectOption = true; setTimeout(() => this.state.actions.selectOption = false, 50); 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 => console.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) }); } } }, /** * Given a suggestion-item, return the data associated with it * @param {HTMLElement} tagElm * @returns Object */ getSuggestionDataByNode(tagElm) { var value = tagElm && tagElm.getAttribute('value'); return this.suggestedListItems.find(item => item.value == value) || null; }, getNextOrPrevOption(selected) { let next = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 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), aut-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) { var _s = this.settings, _s$dropdown = _s.dropdown, clearOnSelect = _s$dropdown.clearOnSelect, closeOnSelect = _s$dropdown.closeOnSelect; 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', 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 (!value || !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[_s.mode == 'mix' ? "addMixTags" : "addTags"]([tagData || this.input.raw.call(this)], clearOnSelect); } // todo: consider not doing this on mix-mode if (!this.DOM.input.parentNode) return; setTimeout(() => { this.DOM.input.focus(); this.toggleFocusClass(true); }); closeOnSelect && setTimeout(this.dropdown.hide.bind(this)); // hide selected suggestion elm.addEventListener('transitionend', () => { this.dropdown.fillHeaderFooter(); setTimeout(() => elm.remove(), 100); }, { once: true }); 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 to 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, 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 = _sd.includeSelectedTags ? whitelist : whitelist.filter(item => !this.isTagDuplicate(isObject(item) ? item.value : item)); // don't include tags which have already been added. this.state.dropdown.suggestions = list; return list.slice(0, suggestionsCount); // respect "maxItems" dropdown setting } 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 return typeof _sd.sortby == 'function' ? _sd.sortby(exactMatchesList.concat(list), niddle) : exactMatchesList.concat(list).slice(0, suggestionsCount); }, /** * 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, [_objectSpread2(_objectSpread2({}, suggestion), {}, { mappedValue }), this]); }).join(""); } }; const VERSION = 1; // current version of persisted data. if code change breaks persisted data, verison number should be bumped. const STORE_KEY = '@yaireo/tagify/'; const getPersistedData = id => key => { // if "persist" is "false", do not save to localstorage let customKey = '/' + key, persistedData, versionMatch = localStorage.getItem(STORE_KEY + id + '/v', VERSION) == VERSION; if (versionMatch) { try { persistedData = JSON.parse(localStorage[STORE_KEY + id + customKey]); } catch (err) {} } return persistedData; }; const setPersistedData = id => { if (!id) return () => {}; // for storage invalidation localStorage.setItem(STORE_KEY + id + '/v', VERSION); return (data, key) => { let customKey = '/' + key, persistedData = JSON.stringify(data); if (data && key) { localStorage.setItem(STORE_KEY + id + customKey, persistedData); dispatchEvent(new Event('storage')); } }; }; const clearPersistedData = id => key => { const base = STORE_KEY + '/' + id + '/'; // delete specific key in the storage if (key) localStorage.removeItem(base + key); // delete all keys in the storage with a specific tagify id else { for (let k in localStorage) if (k.includes(base)) localStorage.removeItem(k); } }; var TEXTS = { empty: "empty", exceed: "number of tags exceeded", pattern: "pattern mismatch", duplicate: "already exists", notAllowed: "not allowed" }; var templates = { /** * * @param {DOM Object} input Original input DOm element * @param {Object} settings Tagify instance settings Object */ wrapper(input, _s) { return `<tags class="${_s.classNames.namespace} ${_s.mode ? `${_s.classNames[_s.mode + "Mode"]}` : ""} ${input.className}" ${_s.readonly ? 'readonly' : ''} ${_s.disabled ? 'disabled' : ''} ${_s.required ? 'required' : ''} ${_s.mode === 'select' ? "spellcheck='false'" : ''} tabIndex="-1"> <span ${!_s.readonly && _s.userInput ? 'contenteditable' : ''} tabIndex="0" data-placeholder="${_s.placeholder || '&#8203;'}" aria-placeholder="${_s.placeholder || ''}" class="${_s.classNames.input}" role="textbox" aria-autocomplete="both" aria-multiline="${_s.mode == 'mix' ? true : false}"></span> &#8203; </tags>`; }, tag(tagData, _ref) { let _s = _ref.settings; return `<tag title="${tagData.title || tagData.value}" contenteditable='false' spellcheck='false' tabIndex="${_s.a11y.focusableTags ? 0 : -1}" class="${_s.classNames.tag} ${tagData.class || ""}" ${this.getAttributes(tagData)}> <x title='' class="${_s.classNames.tagX}" role='button' aria-label='remove tag'></x> <div> <span class="${_s.classNames.tagText}">${tagData[_s.tagTextProp] || tagData.value}</span> </div> </tag>`; }, dropdown(settings) { var _sd = settings.dropdown, isManual = _sd.position == 'manual'; return `<div class="${isManual ? '' : settings.classNames.dropdown} ${_sd.classname}" role="listbox" aria-labelledby="dropdo