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
JavaScript
/**
* 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/`|'/g, "'") : 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 || '​'}" aria-placeholder="${_s.placeholder || ''}"
class="${_s.classNames.input}"
role="textbox"
aria-autocomplete="both"
aria-multiline="${_s.mode == 'mix' ? true : false}"></span>
​
</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