+
{tagifyLoaded && settings?.whitelist?.length >= 1 && withSuggestions && (
@@ -257,50 +263,58 @@ const TagifyWrapper = ({
};
TagifyWrapper.propTypes = {
- name: string,
- value: oneOfType([string, array]),
- loading: bool,
- children: oneOfType([string, array]),
- onChange: func,
- readOnly: bool,
- settings: object,
- InputMode: string,
- autoFocus: bool,
- className: string,
- tagifyRef: object,
- whitelist: array,
- placeholder: string,
- defaultValue: oneOfType([string, array]),
- showDropdown: oneOfType([string, bool]),
- withSuggestions: bool,
- amountOfDuplicates: number,
- onInput: func,
- onAdd: func,
- onRemove: func,
- onEditInput: func,
- onEditBeforeUpdate: func,
- onEditUpdated: func,
- onEditStart: func,
- onEditKeydown: func,
- onInvalid: func,
- onClick: func,
- onKeydown: func,
- onFocus: func,
- onBlur: func,
- onDropdownShow: func,
- onDropdownHide: func,
- onDropdownSelect: func,
- onDropdownScroll: func,
- onDropdownNoMatch: func,
- onDropdownUpdated: func
+ name: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+ loading: PropTypes.bool,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+ onChange: PropTypes.func,
+ readOnly: PropTypes.bool,
+ settings: PropTypes.object,
+ InputMode: PropTypes.string,
+ autoFocus: PropTypes.bool,
+ className: PropTypes.string,
+ tagifyRef: PropTypes.object,
+ whitelist: PropTypes.array,
+ placeholder: PropTypes.string,
+ defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+ showDropdown: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ withSuggestions: PropTypes.bool,
+ amountOfDuplicates: PropTypes.number,
+ onInput: PropTypes.func,
+ onAdd: PropTypes.func,
+ onRemove: PropTypes.func,
+ onEditInput: PropTypes.func,
+ onEditBeforeUpdate: PropTypes.func,
+ onEditUpdated: PropTypes.func,
+ onEditStart: PropTypes.func,
+ onEditKeydown: PropTypes.func,
+ onInvalid: PropTypes.func,
+ onClick: PropTypes.func,
+ onKeydown: PropTypes.func,
+ onFocus: PropTypes.func,
+ onBlur: PropTypes.func,
+ onDropdownShow: PropTypes.func,
+ onDropdownHide: PropTypes.func,
+ onDropdownSelect: PropTypes.func,
+ onDropdownScroll: PropTypes.func,
+ onDropdownNoMatch: PropTypes.func,
+ onDropdownUpdated: PropTypes.func,
+ ariaLabel: PropTypes.string,
+ disabled: PropTypes.bool,
};
const Tags = React.memo(TagifyWrapper);
Tags.displayName = 'Tags';
export const MixedTags = ({ children, ariaLabel, ...rest }) => (
-
+
{children}
);
+
+MixedTags.propTypes = {
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+ ariaLabel: PropTypes.string,
+};
+
export default Tags;
diff --git a/packages/components/src/form/TagifyInput/tagify/tagify.js b/packages/components/src/form/TagifyInput/tagify/tagify.js
index 5414f5a9f..b44f8f4da 100644
--- a/packages/components/src/form/TagifyInput/tagify/tagify.js
+++ b/packages/components/src/form/TagifyInput/tagify/tagify.js
@@ -1,21 +1,67 @@
/**
- * Tagify (v 4.3.1) - tags input component
- * By Yair Even-Or
- * Don't sell this code. (c)
+ * Tagify (v 4.17.9) - 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.
*/
-var Tagify;
+
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? (module.exports = factory())
: typeof define === 'function' && define.amd
- ? define(factory)
- : ((global = typeof globalThis !== 'undefined' ? globalThis : global || self),
- (global.Tagify = factory()));
+ ? define(factory)
+ : ((global = typeof globalThis !== 'undefined' ? globalThis : global || self),
+ (global.Tagify = factory()));
})(this, function () {
'use strict';
+ 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,
@@ -26,70 +72,51 @@ var Tagify;
} else {
obj[key] = value;
}
-
return obj;
}
-
- function ownKeys(object, enumerableOnly) {
- var keys = Object.keys(object);
-
- if (Object.getOwnPropertySymbols) {
- var symbols = Object.getOwnPropertySymbols(object);
- if (enumerableOnly)
- symbols = symbols.filter(function (sym) {
- return Object.getOwnPropertyDescriptor(object, sym).enumerable;
- });
- keys.push.apply(keys, symbols);
+ 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 keys;
+ return (hint === 'string' ? String : Number)(input);
+ }
+ function _toPropertyKey(arg) {
+ var key = _toPrimitive(arg, 'string');
+ return typeof key === 'symbol' ? key : String(key);
}
- function _objectSpread2(target) {
- for (var i = 1; i < arguments.length; i++) {
- var source = arguments[i] != null ? arguments[i] : {};
-
- if (i % 2) {
- ownKeys(Object(source), true).forEach(function (key) {
- _defineProperty(target, key, source[key]);
- });
- } else if (Object.getOwnPropertyDescriptors) {
- Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
- } else {
- ownKeys(Object(source)).forEach(function (key) {
- Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
- });
- }
- }
+ var ZERO_WIDTH_CHAR = '\u200B';
- return target;
- }
+ // 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 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) {
@@ -104,7 +131,6 @@ var Tagify;
* @param {String} s [HTML string]
* @return {Object} [DOM node]
*/
-
function parseHTML(s) {
var parser = new DOMParser(),
node = parser.parseFromString(s.trim(), 'text/html');
@@ -115,25 +141,26 @@ var Tagify;
* 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 ]+\<').replace(/(<.*?>)|\s+/g, (m, $1) => ($1 ? $1 : ' ')) // https://stackoverflow.com/a/44841484/104380
+ ? s
+ .replace(/\>[\r\n ]+\<')
+ .split(/>\s+)
+ .join('><')
+ .trim()
: '';
}
-
function removeTextChildNodes(elm) {
var iter = document.createNodeIterator(elm, NodeFilter.SHOW_TEXT, null, false),
- textnode; // print all text nodes
+ 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;
}
@@ -141,20 +168,20 @@ var Tagify;
* utility method
* https://stackoverflow.com/a/6234804/104380
*/
-
function escapeHTML(s) {
- return s
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/`|'/g, ''');
+ return typeof s == 'string'
+ ? s
+ .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 (
@@ -170,12 +197,10 @@ var Tagify;
* 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)
@@ -185,24 +210,44 @@ var Tagify;
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:
@@ -216,7 +261,6 @@ var Tagify;
* https://stackoverflow.com/q/5944038/104380
* @param {DOM} node
*/
-
function getNodeHeight(node) {
var height,
clone = node.cloneNode(true);
@@ -226,33 +270,260 @@ var Tagify;
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)
+ (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
+ 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"
+ },
+
+ 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)
+ 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(),
+ },
+ };
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];
-
- if (this.settings.dropdown.enabled >= 0) this.dropdown.init();
+ this.dropdown.refs();
}
-
var _dropdown = {
- init() {
+ refs() {
this.DOM.dropdown = this.parseTemplate('dropdown', [this.settings]);
this.DOM.dropdown.content = this.DOM.dropdown.querySelector(
- this.settings.classNames.dropdownWrapperSelector
+ "[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]
@@ -264,34 +535,40 @@ var Tagify;
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
+ isManual = _s.dropdown.position == 'manual';
- value = value === undefined ? this.state.inputText : value; // โ ๏ธ Do not render suggestions list if:
+ // 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.state.isLoading ||
+ this.settings.readonly
)
return;
- clearTimeout(this.dropdownHide__bindEventsTimeout); // if no value was supplied, show all the "whitelist" items in the dropdown
+ clearTimeout(this.dropdownHide__bindEventsTimeout);
+
+ // if no value was supplied, show all the "whitelist" items in the dropdown
// @type [Array] listItems
// TODO: add a Setting to control items' sort order for "listItems"
+ this.suggestedListItems = this.dropdown.filterListItems(value);
- this.suggestedListItems = this.dropdown.filterListItems(value); // trigger at this exact point to let the developer the chance to manually set "this.suggestedListItems"
-
+ // 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 no defined, procceed regular flow.
- //
+ }
+ // if "dropdownItemNoMatch" was no 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
@@ -312,40 +589,44 @@ var Tagify;
value,
},
];
- } // hide suggestions list if no suggestion matched
+ }
+ // 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.children[0]); // bind events, exactly at this stage of the code. "dropdown.show" method is allowed to be
+ 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
+ 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)
+ 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
@@ -353,23 +634,29 @@ var Tagify;
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
+ }
+ // 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);
});
},
-
- hide(force) {
+ /**
+ * 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' && !force; // if there's no dropdown, this means the dropdown events aren't binded
+ 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
@@ -377,9 +664,10 @@ var Tagify;
// 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
+ 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);
@@ -387,39 +675,44 @@ var Tagify;
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"
+ 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']();
+ },
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;
- 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
+ _s = this.settings,
+ enabled = typeof _s.dropdown.enabled == 'number' && _s.dropdown.enabled >= 0;
+ 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);
-
_s.dropdown.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) {
@@ -427,12 +720,27 @@ var Tagify;
typeof HTMLContent == 'string'
? HTMLContent
: this.dropdown.createListHTML(HTMLContent || this.suggestedListItems);
- this.DOM.dropdown.content.innerHTML = minify(HTMLContent);
+ 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, so they will be re-added to the list. not efficient)
+ * (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 || '';
@@ -441,7 +749,6 @@ var Tagify;
if (!this.suggestedListItems.length) this.dropdown.hide();
this.trigger('dropdown:updated', this.DOM.dropdown);
},
-
position(ddHeight) {
var _sd = this.settings.dropdown;
if (_sd.position == 'manual') return;
@@ -453,38 +760,52 @@ var Tagify;
parentsPositions,
ddElm = this.DOM.dropdown,
placeAbove = _sd.placeAbove,
- viewportHeight = document.documentElement.clientHeight,
- viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
+ isDefaultAppendTarget = _sd.appendTarget === document.body,
+ appendTargetScrollTop = isDefaultAppendTarget
+ ? window.pageYOffset
+ : _sd.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 getParentsPositions(p) {
var left = 0,
top = 0;
- while (p) {
+ // when in element-fullscreen mode, do not go above the fullscreened-element
+ while (p && p != root) {
left += p.offsetLeft || 0;
top += p.offsetTop || 0;
p = p.parentNode;
}
-
return {
left,
top,
};
}
-
+ 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 = this.getCaretGlobalPosition();
+ rect = getCaretGlobalPosition();
bottom = rect.bottom;
top = rect.top;
left = rect.left;
width = 'auto';
} else {
- parentsPositions = getParentsPositions(this.settings.dropdown.appendTarget);
+ parentsPositions = getParentsPositions(_sd.appendTarget);
rect = ddTarget.getBoundingClientRect();
top = rect.top - parentsPositions.top;
bottom = rect.bottom - 1 - parentsPositions.top;
@@ -492,10 +813,17 @@ var Tagify;
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);
- placeAbove = placeAbove === undefined ? viewportHeight - rect.bottom < ddHeight : placeAbove; // flip vertically if there is no space for the dropdown below the input
+ placeAbove = placeAbove === undefined ? viewportHeight - rect.bottom < ddHeight : placeAbove;
+ // flip vertically if there is no space for the dropdown below the input
ddElm.style.cssText =
'left:' +
(left + window.pageXOffset) +
@@ -503,24 +831,24 @@ var Tagify;
width +
';' +
(placeAbove
- ? 'top: ' + (top + window.pageYOffset) + 'px'
- : 'top: ' + (bottom + window.pageYOffset) + 'px');
+ ? 'top: ' + (top + appendTargetScrollTop) + 'px'
+ : 'top: ' + (bottom + appendTargetScrollTop) + 'px');
ddElm.setAttribute('placement', placeAbove ? '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(bindUnbind = true) {
+ 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),
+ position: this.dropdown.position.bind(this, null),
onKeyDown: _CB.onKeyDown.bind(this),
onMouseOver: _CB.onMouseOver.bind(this),
onMouseLeave: _CB.onMouseLeave.bind(this),
@@ -528,61 +856,57 @@ var Tagify;
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 selectedElm = this.DOM.dropdown.querySelector(
- this.settings.classNames.dropdownItemActiveSelector
+ this.settings.classNames.dropdownItemActiveSelector,
),
selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm);
-
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp':
case 'Down': // >IE11
-
case 'Up': {
// >IE11
e.preventDefault();
- var dropdownItems;
- if (selectedElm)
- selectedElm =
- selectedElm[
- (e.key == 'ArrowUp' || e.key == 'Up' ? 'previous' : 'next') + 'ElementSibling'
- ]; // if no element was found, loop
-
- if (!selectedElm) {
- dropdownItems = this.DOM.dropdown.content.children;
- selectedElm =
- dropdownItems[e.key == 'ArrowUp' || e.key == 'Up' ? dropdownItems.length - 1 : 0];
+ var dropdownItems = this.dropdown.getAllSuggestionsRefs(),
+ actionUp = e.key == 'ArrowUp' || e.key == 'Up';
+ if (selectedElm) {
+ selectedElm = this.dropdown.getNextOrPrevOption(selectedElm, !actionUp);
}
- selectedElmData = this.dropdown.getSuggestionDataByNode(selectedElm);
+ // if no element was found OR current item is not a "real" item, loop
+ if (
+ !selectedElm ||
+ !selectedElm.matches(this.settings.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': {
// in mix-mode, treat arrowRight like Enter key, so a tag will be created
if (
@@ -592,15 +916,12 @@ var Tagify;
!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();
this.settings.hooks
@@ -610,18 +931,21 @@ var Tagify;
suggestionElm: selectedElm,
})
.then(() => {
- if (selectedElm) this.dropdown.selectOption(selectedElm);
- else this.dropdown.hide();
+ 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 (this.settings.mode != 'mix') this.addTags(this.state.inputText.trim(), true);
})
.catch((err) => err);
break;
}
-
case 'Backspace': {
if (this.settings.mode == 'mix' || this.state.editing.scope) return;
- let value = this.state.inputText.trim();
-
+ const value = this.input.raw.call(this);
if (value == '' || value.charCodeAt(0) == 8203) {
if (this.settings.backspace === true) this.removeTags();
else if (this.settings.backspace == 'edit') setTimeout(this.editTag.bind(this), 0);
@@ -629,18 +953,15 @@ var Tagify;
}
}
},
-
onMouseOver(e) {
- var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector); // event delegation check
-
+ var ddItem = e.target.closest(this.settings.classNames.dropdownItemSelector);
+ // event delegation check
ddItem && this.dropdown.highlightOption(ddItem);
},
-
onMouseLeave(e) {
// de-highlight any previously highlighted option
this.dropdown.highlightOption();
},
-
onClick(e) {
if (
e.button != 0 ||
@@ -650,8 +971,9 @@ var Tagify;
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
+ 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
@@ -661,12 +983,11 @@ var Tagify;
suggestionElm: selectedElm,
})
.then(() => {
- if (selectedElm) this.dropdown.selectOption(selectedElm);
+ if (selectedElm) this.dropdown.selectOption(selectedElm, e);
else this.dropdown.hide();
})
- .catch((err) => err);
+ .catch((err) => console.warn(err));
},
-
onScroll(e) {
var elm = e.target,
pos = (elm.scrollTop / (elm.scrollHeight - elm.parentNode.clientHeight)) * 100;
@@ -676,12 +997,21 @@ var Tagify;
},
},
},
-
+ /**
+ * Given a suggestion-item, return the data associated with it
+ * @param {HTMLElement} tagElm
+ * @returns Object
+ */
getSuggestionDataByNode(tagElm) {
- var idx = tagElm ? +tagElm.getAttribute('tagifySuggestionIdx') : -1;
- return this.suggestedListItems[idx] || null;
+ 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
@@ -689,7 +1019,9 @@ var Tagify;
*/
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
+ itemData;
+
+ // focus casues a bug in Firefox with the placeholder been shown on the input element
// if( this.settings.dropdown.position != 'manual' )
// elm.focus();
@@ -697,23 +1029,23 @@ var Tagify;
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.suggestedListItems[this.getNodeIndex(elm)];
+ 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));
+ 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
+ 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
@@ -723,67 +1055,93 @@ var Tagify;
/**
* 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) {
+ selectOption(elm, event) {
var _this$settings$dropdo = this.settings.dropdown,
clearOnSelect = _this$settings$dropdo.clearOnSelect,
closeOnSelect = _this$settings$dropdo.closeOnSelect;
-
if (!elm) {
this.addTags(this.state.inputText, true);
closeOnSelect && this.dropdown.hide();
return;
- } // if in edit-mode, do not continue but instead replace the tag's text.
+ }
+ 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 tagifySuggestionIdx = elm.getAttribute('tagifySuggestionIdx'),
- tagData = this.suggestedListItems[+tagifySuggestionIdx];
+ 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,
- }); // above event must be triggered, regardless of anything else which might go wrong
-
- if (!tagifySuggestionIdx || !tagData) {
- this.dropdown.hide();
+ event,
+ });
+ if (!value || (!tagData && !isNoMatch)) {
+ closeOnSelect && setTimeout(this.dropdown.hide.bind(this));
return;
}
-
- if (this.state.editing)
+ if (this.state.editing) {
+ // 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
- )
+ this.normalizeTags([tagData])[0],
+ ),
);
+ }
// Tagify instances should re-focus to the input element once an option was selected, to allow continuous typing
else {
- this[this.settings.mode == 'mix' ? 'addMixTags' : 'addTags']([tagData], clearOnSelect);
- } // todo: consider not doing this on mix-mode
+ this[this.settings.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);
});
-
- if (closeOnSelect) {
- setTimeout(this.dropdown.hide.bind(this));
- } else this.dropdown.refilter();
+ 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);
},
-
- selectAll() {
+ // 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(); // 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.dropdown.hide();
+ this.dropdown.filterListItems('');
+ var tagsToAdd = this.dropdown.filterListItems('');
+ if (!onlyRendered) tagsToAdd = this.state.dropdown.suggestions;
- this.addTags(this.dropdown.filterListItems(''), true);
+ // 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
@@ -794,13 +1152,10 @@ var Tagify;
var _s = this.settings,
_sd = _s.dropdown,
options = options || {},
- 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,
list = [],
+ exactMatchesList = [],
whitelist = _s.whitelist,
- suggestionsCount = _sd.maxItems || Infinity,
+ suggestionsCount = _sd.maxItems >= 0 ? _sd.maxItems : Infinity,
searchKeys = _sd.searchKeys,
whitelistItem,
valueIsInWhitelist,
@@ -808,26 +1163,30 @@ var Tagify;
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) {
- return (
- _s.duplicates
- ? whitelist
- : whitelist.filter((item) => !this.isTagDuplicate(isObject(item) ? item.value : item))
- ) // don't include tags which have already been added.
- .slice(0, suggestionsCount); // respect "maxItems" dropdown setting
+ 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]
@@ -837,19 +1196,20 @@ var Tagify;
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();
-
+ .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
@@ -857,23 +1217,28 @@ var Tagify;
v = unaccent(v);
niddle = unaccent(niddle);
}
-
if (!_sd.caseSensitive) v = v.toLowerCase();
- return options.exact ? v == niddle : v.indexOf(niddle) == 0;
+ exactMatch = v === niddle;
+ return options.exact ? v === niddle : v.indexOf(niddle) == 0;
});
}
-
isDuplicate =
- !_s.duplicates &&
- this.isTagDuplicate(isObject(whitelistItem) ? whitelistItem.value : whitelistItem); // match for the value within each "whitelist" item
-
- if (valueIsInWhitelist && !isDuplicate && suggestionsCount--) list.push(whitelistItem);
- if (suggestionsCount == 0) break;
+ !_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);
- return 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
@@ -888,138 +1253,80 @@ var Tagify;
: tagData.value;
return value;
},
-
/**
* Creates the dropdown items' HTML
- * @param {Array} list [Array of Objects]
+ * @param {Array} sugegstionsList [Array of Objects]
* @return {String}
*/
- createListHTML(optionsArr) {
- return extend([], optionsArr)
+ createListHTML(sugegstionsList) {
+ return extend([], sugegstionsList)
.map((suggestion, idx) => {
if (typeof suggestion == 'string' || typeof suggestion == 'number')
suggestion = {
value: suggestion,
};
- var value = this.dropdown.getMappedValue(suggestion);
- suggestion.value = value && typeof value == 'string' ? escapeHTML(value) : value;
- var tagHTMLString = this.settings.templates.dropdownItem.call(this, suggestion); // make sure the sugestion index is present as attribute, to match the data when one is selected
-
- tagHTMLString = tagHTMLString
- .replace(/\s*tagifySuggestionIdx=(["'])(.*?)\1/gim, '') // remove the "tagifySuggestionIdx" attribute if for some reason was there
- .replace('>', ` tagifySuggestionIdx="${idx}">`); // add "tagifySuggestionIdx"
-
- return tagHTMLString;
+ var mappedValue = this.dropdown.getMappedValue(suggestion);
+ mappedValue = typeof mappedValue == 'string' ? escapeHTML(mappedValue) : mappedValue;
+ return this.settings.templates.dropdownItem.apply(this, [
+ _objectSpread2(
+ _objectSpread2({}, suggestion),
+ {},
+ {
+ mappedValue,
+ },
+ ),
+ this,
+ ]);
})
.join('');
},
};
- 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,
- // Flag - automatically adds the text which was inputed as a tag when blur event happens
- duplicates: false,
- // Flag - allow tuplicate 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,
- // Flag - Only allow tags allowed in whitelist
- keepInvalidTags: false,
- // Flag - if true, do not remove tags which did not pass validation
- mixTagsAllowedAfter: /,|\.|\:|\s/,
- // RegEx - Define conditions in which mix-tags content allows a tag to be added after
- mixTagsInterpolator: ['[[', ']]'],
- // Interpolation for mix mode. Everything between this will becmoe a tag
- 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"
- },
- classNames: {
- namespace: 'tagify',
- mixMode: 'tagify--mix',
- selectMode: 'tagify--select',
- input: 'tagify__input',
- focus: 'tagify--focus',
- tag: 'tagify__tag',
- tagNoAnimation: 'tagify--noAnim',
- tagInvalid: 'tagify--invalid',
- tagNotAllowed: 'tagify--notAllowed',
- inputInvalid: 'tagify__input--invalid',
- tagX: 'tagify__tag__removeBtn',
- tagText: 'tagify__tag-text',
- dropdown: 'tagify__dropdown',
- dropdownWrapper: 'tagify__dropdown__wrapper',
- dropdownItem: 'tagify__dropdown__item',
- dropdownItemActive: 'tagify__dropdown__item--active',
- dropdownInital: 'tagify__dropdown--initial',
- scopeLoading: 'tagify--loading',
- tagLoading: 'tagify__tag--loading',
- tagEditing: 'tagify__tag--editable',
- tagFlash: 'tagify__tag--flash',
- tagHide: 'tagify__tag--hide',
- hasMaxTags: 'tagify--hasMaxTags',
- hasNoTags: 'tagify--noTags',
- empty: 'tagify--empty',
- },
- 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,
- 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 one DOM has been loaded
- },
- hooks: {
- beforeRemoveTag: () => Promise.resolve(),
- beforePaste: () => Promise.resolve(),
- suggestionClick: () => Promise.resolve(),
- },
+ 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 = {
@@ -1029,91 +1336,112 @@ var Tagify;
* @param {Object} settings Tagify instance settings Object
*/
wrapper(input, _s) {
- return `
-
+ ${_s.mode === 'select' ? "spellcheck='false'" : ''}
+ tabIndex="-1">
+
+
`;
},
-
- tag(tagData) {
- return `
-
+
- ${
- tagData[this.settings.tagTextProp] || tagData.value
- }
+ ${
+ tagData[_s.tagTextProp] || tagData.value
+ }
`;
},
-
dropdown(settings) {
var _sd = settings.dropdown,
isManual = _sd.position == 'manual',
className = `${settings.classNames.dropdown}`;
- return ``;
},
-
+ dropdownContent(HTMLContent) {
+ var _s = this.settings,
+ suggestions = this.state.dropdown.suggestions;
+ return `
+ ${_s.templates.dropdownHeader.call(this, suggestions)}
+ ${HTMLContent}
+ ${_s.templates.dropdownFooter.call(this, suggestions)}
+ `;
+ },
dropdownItem(item) {
return `${item.value}
`;
+ tabindex="0"
+ role="option">${item.mappedValue || item.value} `;
+ },
+ /**
+ * @param {Array} suggestions An array of all the matched suggested items, including those which were sliced away due to the "dropdown.maxItems" setting
+ */
+ dropdownHeader(suggestions) {
+ return ``;
+ },
+ dropdownFooter(suggestions) {
+ var hasMore = suggestions.length - this.settings.dropdown.maxItems;
+ return hasMore > 0
+ ? ``
+ : '';
},
-
dropdownItemNoMatch: null,
};
function EventDispatcher(instance) {
// Create a DOM EventTarget object
var target = document.createTextNode('');
-
function addRemove(op, events, cb) {
if (cb)
events.split(/\s+/g).forEach((name) => target[op + 'EventListener'].call(target, name, cb));
- } // Pass EventTarget interface calls to DOM EventTarget object
+ }
+ // Pass EventTarget interface calls to DOM EventTarget object
return {
off(events, cb) {
addRemove('remove', events, cb);
return this;
},
-
on(events, cb) {
if (cb && typeof cb == 'function') addRemove('add', events, cb);
return this;
},
-
trigger(eventName, data, opts) {
var e;
opts = opts || {
cloneData: true,
};
if (!eventName) return;
-
if (instance.settings.isJQueryPlugin) {
if (eventName == 'remove') eventName = 'removeTag'; // issue #222
-
jQuery(instance.DOM.originalInput).triggerHandler(eventName, [data]);
} else {
try {
@@ -1124,8 +1452,10 @@ var Tagify;
value: data,
};
eventData = opts.cloneData ? extend({}, eventData) : eventData;
- eventData.tagify = this; // TODO: move the below to the "extend" function
+ eventData.tagify = this;
+ if (data.event) eventData.event = this.cloneEvent(data.event);
+ // TODO: move the below to the "extend" function
if (data instanceof Object)
for (var prop in data)
if (data[prop] instanceof HTMLElement) eventData[prop] = data[prop];
@@ -1135,7 +1465,6 @@ var Tagify;
} catch (err) {
console.warn(err);
}
-
target.dispatchEvent(e);
}
},
@@ -1143,7 +1472,6 @@ var Tagify;
}
var deleteBackspaceTimeout;
-
function triggerChangeEvent() {
if (this.settings.mixMode.integrated) return;
var inputElm = this.DOM.originalInput,
@@ -1152,19 +1480,22 @@ var Tagify;
bubbles: true,
}); // must use "CustomEvent" and not "Event" to support IE
- if (!changed) return; // must apply this BEFORE triggering the simulated event
+ if (!changed) return;
- this.state.lastOriginalValueReported = inputElm.value; // React hack: https://github.com/facebook/react/issues/11488
+ // must apply this BEFORE triggering the simulated event
+ this.state.lastOriginalValueReported = inputElm.value;
+ // React hack: https://github.com/facebook/react/issues/11488
event.simulated = true;
if (inputElm._valueTracker) inputElm._valueTracker.setValue(Math.random());
- inputElm.dispatchEvent(event); // also trigger a Tagify event
+ inputElm.dispatchEvent(event);
- this.trigger('change', this.state.lastOriginalValueReported); // React, for some reason, clears the input's value after "dispatchEvent" is fired
+ // also trigger a Tagify event
+ this.trigger('change', this.state.lastOriginalValueReported);
+ // React, for some reason, clears the input's value after "dispatchEvent" is fired
inputElm.value = this.state.lastOriginalValueReported;
}
-
var events = {
// bind custom events which were passed in the settings
customBinding() {
@@ -1172,53 +1503,107 @@ var Tagify;
this.on(name, this.settings.callbacks[name]);
});
},
-
- binding(bindUnbind = true) {
+ binding() {
+ let bindUnbind = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
var _CB = this.events.callbacks,
_CBR,
- action = bindUnbind ? 'addEventListener' : 'removeEventListener'; // do not allow the main events to be bound more than once
+ action = bindUnbind ? 'addEventListener' : 'removeEventListener';
- if (this.state.mainEvents && bindUnbind) return; // set the binding state of the main events, so they will not be bound more than once
+ // do not allow the main events to be bound more than once
+ if (this.state.mainEvents && bindUnbind) return;
- this.state.mainEvents = bindUnbind; // everything inside gets executed only once-per instance
+ // set the binding state of the main events, so they will not be bound more than once
+ this.state.mainEvents = bindUnbind;
+ // everything inside gets executed only once-per instance
if (bindUnbind && !this.listeners.main) {
- // this event should never be unbinded:
- // IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead..
- this.DOM.input.addEventListener(
- this.isIE ? 'keydown' : 'input',
- _CB[this.isIE ? 'onInputIE' : 'onInput'].bind(this)
- );
- window.addEventListener('keydown', _CB.onWindowKeyDown.bind(this));
+ this.events.bindGlobal.call(this);
if (this.settings.isJQueryPlugin)
jQuery(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this));
- } // setup callback references so events could be removed later
+ }
+ // setup callback references so events could be removed later
_CBR = this.listeners.main = this.listeners.main || {
focus: ['input', _CB.onFocusBlur.bind(this)],
- blur: ['input', _CB.onFocusBlur.bind(this)],
keydown: ['input', _CB.onKeydown.bind(this)],
click: ['scope', _CB.onClickScope.bind(this)],
dblclick: ['scope', _CB.onDoubleClickScope.bind(this)],
paste: ['input', _CB.onPaste.bind(this)],
+ drop: ['input', _CB.onDrop.bind(this)],
+ compositionstart: ['input', _CB.onCompositionStart.bind(this)],
+ compositionend: ['input', _CB.onCompositionEnd.bind(this)],
};
-
for (var eventName in _CBR) {
- // make sure the focus/blur event is always regesitered (and never more than once)
- if (eventName == 'blur' && !bindUnbind) continue;
-
this.DOM[_CBR[eventName][0]][action](eventName, _CBR[eventName][1]);
}
- },
+ // listen to original input changes (unfortunetly this is the best way...)
+ // https://stackoverflow.com/a/1949416/104380
+ clearInterval(this.listeners.main.originalInputValueObserverInterval);
+ this.listeners.main.originalInputValueObserverInterval = setInterval(
+ _CB.observeOriginalInputValue.bind(this),
+ 500,
+ );
+
+ // observers
+ var inputMutationObserver =
+ this.listeners.main.inputMutationObserver ||
+ new MutationObserver(_CB.onInputDOMChange.bind(this));
+
+ // cleaup just-in-case
+ inputMutationObserver.disconnect();
+
+ // observe stuff
+ if (this.settings.mode == 'mix') {
+ inputMutationObserver.observe(this.DOM.input, {
+ childList: true,
+ });
+ }
+ },
+ bindGlobal(unbind) {
+ var _CB = this.events.callbacks,
+ action = unbind ? 'removeEventListener' : 'addEventListener',
+ e;
+ if (!this.listeners || (!unbind && this.listeners.global)) return; // do not re-bind
+
+ // these events are global event should never be unbinded, unless the instance is destroyed:
+ this.listeners.global = this.listeners.global || [
+ {
+ type: this.isIE ? 'keydown' : 'input',
+ // IE cannot register "input" events on contenteditable elements, so the "keydown" should be used instead..
+ target: this.DOM.input,
+ cb: _CB[this.isIE ? 'onInputIE' : 'onInput'].bind(this),
+ },
+ {
+ type: 'keydown',
+ target: window,
+ cb: _CB.onWindowKeyDown.bind(this),
+ },
+ {
+ type: 'blur',
+ target: this.DOM.input,
+ cb: _CB.onFocusBlur.bind(this),
+ },
+ {
+ type: 'click',
+ target: document,
+ cb: _CB.onClickAnywhere.bind(this),
+ },
+ ];
+ for (e of this.listeners.global) e.target[action](e.type, e.cb);
+ },
+ unbindGlobal() {
+ this.events.bindGlobal.call(this, true);
+ },
/**
* DOM events callbacks
*/
callbacks: {
onFocusBlur(e) {
- var text = e.target ? this.trim(e.target.textContent) : '',
+ var _s = this.settings,
+ text = e.target ? this.trim(e.target.textContent) : '',
// a string
- _s = this.settings,
+ currentDisplayValue = this.value?.[0]?.[_s.tagTextProp],
type = e.type,
ddEnabled = _s.dropdown.enabled >= 0,
eventData = {
@@ -1229,151 +1614,163 @@ var Tagify;
isTargetAddNewBtn = this.state.actions.addNew && ddEnabled,
isRelatedTargetX =
e.relatedTarget &&
- e.relatedTarget.classList.contains(_s.classNames.tag) &&
+ isNodeTag.call(this, e.relatedTarget) &&
this.DOM.scope.contains(e.relatedTarget),
shouldAddTags;
-
if (type == 'blur') {
if (e.relatedTarget === this.DOM.scope) {
this.dropdown.hide();
this.DOM.input.focus();
return;
}
-
this.postUpdate();
- this.triggerChangeEvent();
+ _s.onChangeAfterBlur && this.triggerChangeEvent();
}
-
if (isTargetSelectOption || isTargetAddNewBtn) return;
this.state.hasFocus = type == 'focus' ? +new Date() : false;
this.toggleFocusClass(this.state.hasFocus);
-
if (_s.mode == 'mix') {
if (type == 'focus') {
this.trigger('focus', eventData);
} else if (e.type == 'blur') {
this.trigger('blur', eventData);
this.loading(false);
- this.dropdown.hide(); // reset state which needs reseting
-
+ this.dropdown.hide();
+ // reset state which needs reseting
this.state.dropdown.visible = undefined;
this.setStateSelection();
}
-
return;
}
-
if (type == 'focus') {
- this.trigger('focus', eventData); // e.target.classList.remove('placeholder');
-
- if (_s.dropdown.enabled === 0) {
+ this.trigger('focus', eventData);
+ // e.target.classList.remove('placeholder');
+ if (_s.dropdown.enabled === 0 || !_s.userInput) {
// && _s.mode != "select"
- this.dropdown.show();
+ this.dropdown.show(this.value.length ? '' : undefined);
}
-
return;
} else if (type == 'blur') {
this.trigger('blur', eventData);
- this.loading(false); // when clicking the X button of a selected tag, it is unwanted it will be added back
+ this.loading(false);
+
+ // when clicking the X button of a selected tag, it is unwanted for it to be added back
// again in a few more lines of code (shouldAddTags && addTags)
+ if (_s.mode == 'select') {
+ if (isRelatedTargetX) {
+ this.removeTags();
+ text = '';
+ }
- if (this.settings.mode == 'select' && isRelatedTargetX) text = '';
- shouldAddTags =
- this.settings.mode == 'select' && text
- ? !this.value.length || this.value[0].value != text
- : text && !this.state.actions.selectOption && _s.addTagOnBlur; // do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown)
+ // if nothing has changed (same display value), do not add a tag
+ if (currentDisplayValue === text) text = '';
+ }
+ shouldAddTags = text && !this.state.actions.selectOption && _s.addTagOnBlur;
+ // do not add a tag if "selectOption" action was just fired (this means a tag was just added from the dropdown)
shouldAddTags && this.addTags(text, true);
- if (this.settings.mode == 'select' && !text) this.removeTags();
}
-
this.DOM.input.removeAttribute('style');
this.dropdown.hide();
},
-
+ onCompositionStart(e) {
+ this.state.composing = true;
+ },
+ onCompositionEnd(e) {
+ this.state.composing = false;
+ },
onWindowKeyDown(e) {
var focusedElm = document.activeElement,
- isTag = focusedElm.classList.contains(this.settings.classNames.tag),
+ isTag = isNodeTag.call(this, focusedElm),
isBelong = isTag && this.DOM.scope.contains(document.activeElement),
+ isReadyOnlyTag = isBelong && focusedElm.hasAttribute('readonly'),
nextTag;
- if (!isBelong) return;
+ if (!isBelong || isReadyOnlyTag) return;
nextTag = focusedElm.nextElementSibling;
-
switch (e.key) {
// remove tag if has focus
case 'Backspace': {
- this.removeTags(focusedElm);
- (nextTag ? nextTag : this.DOM.input).focus();
+ if (!this.settings.readonly) {
+ this.removeTags(focusedElm);
+ (nextTag ? nextTag : this.DOM.input).focus();
+ }
break;
}
- // edit tag if has focus
+ // edit tag if has focus
case 'Enter': {
setTimeout(this.editTag.bind(this), 0, focusedElm);
break;
}
}
},
-
onKeydown(e) {
+ var _s = this.settings;
+
+ // ignore keys during IME composition or when user input is not allowed
+ if (this.state.composing || !_s.userInput) return;
+ if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length && e.key != 'Tab') {
+ e.preventDefault();
+ }
var s = this.trim(e.target.textContent);
this.trigger('keydown', {
- originalEvent: this.cloneEvent(e),
+ event: e,
});
+
/**
* ONLY FOR MIX-MODE:
*/
-
- if (this.settings.mode == 'mix') {
+ if (_s.mode == 'mix') {
switch (e.key) {
case 'Left':
case 'ArrowLeft': {
- // when left arrow was pressed, raise a flag so when the dropdown is shown, right-arrow will be ignored
+ // when left arrow was pressed, set a flag so when the dropdown is shown, right-arrow will be ignored
// because it seems likely the user wishes to use the arrows to move the caret
this.state.actions.ArrowLeft = true;
break;
}
-
case 'Delete':
case 'Backspace': {
if (this.state.editing) return;
var sel = document.getSelection(),
deleteKeyTagDetected =
e.key == 'Delete' && sel.anchorOffset == (sel.anchorNode.length || 0),
+ prevAnchorSibling = sel.anchorNode.previousSibling,
isCaretAfterTag =
sel.anchorNode.nodeType == 1 ||
- (!sel.anchorOffset && sel.anchorNode.previousElementSibling),
- lastInputValue = decode(this.DOM.input.innerHTML),
- lastTagElems = this.getTagElms(),
- // isCaretInsideTag = sel.anchorNode.parentNode('.' + this.settings.classNames.tag),
+ (!sel.anchorOffset &&
+ prevAnchorSibling &&
+ prevAnchorSibling.nodeType == 1 &&
+ sel.anchorNode.previousSibling);
+ decode(this.DOM.input.innerHTML);
+ var lastTagElems = this.getTagElms(),
+ isZWS =
+ sel.anchorNode.length === 1 &&
+ sel.anchorNode.nodeValue == String.fromCharCode(8203),
+ // isCaretInsideTag = sel.anchorNode.parentNode('.' + _s.classNames.tag),
tagBeforeCaret,
tagElmToBeDeleted,
firstTextNodeBeforeTag;
-
- if (this.settings.backspace == 'edit' && isCaretAfterTag) {
+ if (_s.backspace == 'edit' && isCaretAfterTag) {
tagBeforeCaret =
sel.anchorNode.nodeType == 1 ? null : sel.anchorNode.previousElementSibling;
setTimeout(this.editTag.bind(this), 0, tagBeforeCaret); // timeout is needed to the last cahacrter in the edited tag won't get deleted
-
e.preventDefault(); // needed so the tag elm won't get deleted
-
return;
}
-
- if (isChromeAndroidBrowser() && isCaretAfterTag) {
+ if (isChromeAndroidBrowser() && isCaretAfterTag instanceof Element) {
firstTextNodeBeforeTag = getfirstTextNode(isCaretAfterTag);
if (!isCaretAfterTag.hasAttribute('readonly')) isCaretAfterTag.remove(); // since this is Chrome, can safetly use this "new" DOM API
+
// Android-Chrome wrongly hides the keyboard, and loses focus,
// so this hack below is needed to regain focus at the correct place:
-
this.DOM.input.focus();
setTimeout(() => {
- this.placeCaretAfterNode(firstTextNodeBeforeTag);
+ placeCaretAfterNode(firstTextNodeBeforeTag);
this.DOM.input.click();
});
return;
}
-
if (sel.anchorNode.nodeName == 'BR') return;
if ((deleteKeyTagDetected || isCaretAfterTag) && sel.anchorNode.nodeType == 1) {
if (sel.anchorOffset == 0)
@@ -1381,25 +1778,32 @@ var Tagify;
tagElmToBeDeleted = deleteKeyTagDetected // delete key pressed
? lastTagElems[0]
: null;
- else tagElmToBeDeleted = lastTagElems[sel.anchorOffset - 1]; // find out if a tag *might* be a candidate for deletion, and if so, which
+ else
+ tagElmToBeDeleted =
+ lastTagElems[Math.min(lastTagElems.length, sel.anchorOffset) - 1];
+
+ // find out if a tag *might* be a candidate for deletion, and if so, which
} else if (deleteKeyTagDetected)
tagElmToBeDeleted = sel.anchorNode.nextElementSibling;
- else if (isCaretAfterTag) tagElmToBeDeleted = isCaretAfterTag; // tagElm.hasAttribute('readonly')
+ else if (isCaretAfterTag instanceof Element) tagElmToBeDeleted = isCaretAfterTag;
+ // tagElm.hasAttribute('readonly')
if (
- sel.anchorNode.nodeType == 3 && // node at caret location is a Text node
- !sel.anchorNode.nodeValue && // has some text
+ sel.anchorNode.nodeType == 3 &&
+ // node at caret location is a Text node
+ !sel.anchorNode.nodeValue &&
+ // has some text
sel.anchorNode.previousElementSibling
)
// text node has a Tag node before it
- e.preventDefault(); // if backspace not allowed, do nothing
- // TODO: a better way to detect if nodes were deleted is to simply check the "this.value" before & after
+ e.preventDefault();
- if ((isCaretAfterTag || deleteKeyTagDetected) && !this.settings.backspace) {
+ // if backspace not allowed, do nothing
+ // TODO: a better way to detect if nodes were deleted is to simply check the "this.value" before & after
+ if ((isCaretAfterTag || deleteKeyTagDetected) && !_s.backspace) {
e.preventDefault();
return;
}
-
if (
sel.type != 'Range' &&
!sel.anchorOffset &&
@@ -1409,7 +1813,6 @@ var Tagify;
e.preventDefault();
return;
}
-
if (
sel.type != 'Range' &&
tagElmToBeDeleted &&
@@ -1417,42 +1820,63 @@ var Tagify;
) {
// allows the continuation of deletion by placing the caret on the first previous textNode.
// since a few readonly-tags might be one after the other, iteration is needed:
- this.placeCaretAfterNode(getfirstTextNode(tagElmToBeDeleted));
+
+ placeCaretAfterNode(getfirstTextNode(tagElmToBeDeleted));
return;
- } // update regarding https://github.com/yairEO/tagify/issues/762#issuecomment-786464317:
+ }
+ if (e.key == 'Delete' && isZWS && getSetTagData(sel.anchorNode.nextSibling)) {
+ this.removeTags(sel.anchorNode.nextSibling);
+ }
- clearTimeout(deleteBackspaceTimeout); // a minimum delay is needed before the node actually gets detached from the document (don't know why),
- // to know exactly which tag was deleted. This is the easiest way of knowing besides using MutationObserver
+ // update regarding https://github.com/yairEO/tagify/issues/762#issuecomment-786464317:
+ // the bug described is more severe than the fix below, therefore I disable the fix until a solution
+ // is found which work well for both cases.
+ // -------
+ // nodeType is "1" only when the caret is at the end after last tag (no text after), or before first first (no text before)
+ /*
+ if( this.isFirefox && sel.anchorNode.nodeType == 1 && sel.anchorOffset != 0 ){
+ this.removeTags() // removes last tag by default if no parameter supplied
+ // place caret inside last textNode, if exist. it's an annoying bug only in FF,
+ // if the last tag is removed, and there is a textNode before it, the caret is not placed at its end
+ placeCaretAfterNode( setRangeAtStartEnd(false, this.DOM.input) )
+ }
+ */
+ clearTimeout(deleteBackspaceTimeout);
+ // a minimum delay is needed before the node actually gets detached from the document (don't know why),
+ // to know exactly which tag was deleted. This is the easiest way of knowing besides using MutationObserver
deleteBackspaceTimeout = setTimeout(() => {
- var sel = document.getSelection(),
- currentValue = decode(this.DOM.input.innerHTML),
- prevElm = sel.anchorNode.previousElementSibling; // fixes #384, where the first and only tag will not get removed with backspace
-
- if (
- !isChromeAndroidBrowser() &&
- currentValue.length >= lastInputValue.length &&
- prevElm &&
- !prevElm.hasAttribute('readonly')
- ) {
- this.removeTags(prevElm);
- this.fixFirefoxLastTagNoCaret(); // the above "removeTag" methods removes the tag with a transition. Chrome adds a
element for some reason at this stage
-
- if (
- this.DOM.input.children.length == 2 &&
- this.DOM.input.children[1].tagName == 'BR'
- ) {
- this.DOM.input.innerHTML = '';
- this.value.length = 0;
- return true;
+ var sel = document.getSelection();
+ decode(this.DOM.input.innerHTML);
+ !deleteKeyTagDetected && sel.anchorNode.previousSibling;
+
+ // fixes #384, where the first and only tag will not get removed with backspace
+ /*
+ * [UPDATE DEC 3, 22] SEEMS BELOEW CODE IS NOT NEEDED ANY MORE
+ *
+ if( currentValue.length > lastInputValue.length && prevElm ){
+ if( isNodeTag.call(this, prevElm) && !prevElm.hasAttribute('readonly') ){
+ this.removeTags(prevElm)
+ this.fixFirefoxLastTagNoCaret()
+ // the above "removeTag" methods removes the tag with a transition. Chrome adds a
element for some reason at this stage
+ if( this.DOM.input.children.length == 2 && this.DOM.input.children[1].tagName == "BR" ){
+ this.DOM.input.innerHTML = ""
+ this.value.length = 0
+ return true
+ }
+ }
+ else
+ prevElm.remove()
}
- } // find out which tag(s) were deleted and trigger "remove" event
- // iterate over the list of tags still in the document and then filter only those from the "this.value" collection
+ */
+ // find out which tag(s) were deleted and trigger "remove" event
+ // iterate over the list of tags still in the document and then filter only those from the "this.value" collection
this.value = [].map
.call(lastTagElems, (node, nodeIdx) => {
- var tagData = this.tagData(node); // since readonly cannot be removed (it's technically resurrected if removed somehow)
+ var tagData = getSetTagData(node);
+ // since readonly cannot be removed (it's technically resurrected if removed somehow)
if (node.parentNode || tagData.readonly) return tagData;
else
this.trigger('remove', {
@@ -1463,104 +1887,103 @@ var Tagify;
})
.filter((n) => n); // remove empty items in the mapped array
}, 20); // Firefox needs this higher duration for some reason or things get buggy when deleting text from the end
-
break;
}
// currently commented to allow new lines in mixed-mode
// case 'Enter' :
- // e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
+ // // e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
}
return true;
}
-
switch (e.key) {
case 'Backspace':
- if (!this.state.dropdown.visible || this.settings.dropdown.position == 'manual') {
- if (s == '' || s.charCodeAt(0) == 8203) {
+ if (_s.mode == 'select' && _s.enforceWhitelist && this.value.length) this.removeTags();
+ else if (!this.state.dropdown.visible || _s.dropdown.position == 'manual') {
+ if (e.target.textContent == '' || s.charCodeAt(0) == 8203) {
// 8203: ZERO WIDTH SPACE unicode
- if (this.settings.backspace === true) this.removeTags();
- else if (this.settings.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); // timeout reason: when edited tag gets focused and the caret is placed at the end, the last character gets deletec (because of backspace)
+ if (_s.backspace === true) this.removeTags();
+ else if (_s.backspace == 'edit') setTimeout(this.editTag.bind(this), 0); // timeout reason: when edited tag gets focused and the caret is placed at the end, the last character gets deletec (because of backspace)
}
}
break;
-
case 'Esc':
case 'Escape':
if (this.state.dropdown.visible) return;
e.target.blur();
break;
-
case 'Down':
case 'ArrowDown':
- // if( this.settings.mode == 'select' ) // issue #333
+ // if( _s.mode == 'select' ) // issue #333
if (!this.state.dropdown.visible) this.dropdown.show();
break;
-
case 'ArrowRight': {
let tagData = this.state.inputSuggestion || this.state.ddItemData;
-
- if (tagData && this.settings.autoComplete.rightKey) {
+ if (tagData && _s.autoComplete.rightKey) {
this.addTags([tagData], true);
return;
}
-
break;
}
-
case 'Tab': {
- let selectMode = this.settings.mode == 'select';
+ let selectMode = _s.mode == 'select';
if (s && !selectMode) e.preventDefault();
else return true;
}
-
case 'Enter':
- if (this.state.dropdown.visible || e.keyCode == 229) return;
+ // manual suggestion boxes are assumed to always be visible
+ if (this.state.dropdown.visible && _s.dropdown.position != 'manual') return;
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
// because the main "keydown" event is bound before the dropdown events, this will fire first and will not *yet*
// know if an option was just selected from the dropdown menu. If an option was selected,
// the dropdown events should handle adding the tag
-
setTimeout(() => {
- if (this.state.actions.selectOption) return;
+ if (this.state.dropdown.visible || this.state.actions.selectOption) return;
this.addTags(s, true);
});
}
},
-
onInput(e) {
- if (this.settings.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e);
+ this.postUpdate(); // toggles "tagify--empty" class
+
+ var _s = this.settings;
+ if (_s.mode == 'mix') return this.events.callbacks.onMixTagsInput.call(this, e);
var value = this.input.normalize.call(this),
- showSuggestions = value.length >= this.settings.dropdown.enabled,
+ showSuggestions = value.length >= _s.dropdown.enabled,
eventData = {
value,
inputElm: this.DOM.input,
- };
- eventData.isValid = this.validateTag({
- value,
- }); // for IE; since IE doesn't have an "input" event so "keyDown" is used instead to trigger the "onInput" callback,
- // and so many keys do not change the input, and for those do not continue.
+ },
+ validation = this.validateTag({
+ value,
+ });
+ if (_s.mode == 'select') {
+ this.toggleScopeValidation(validation);
+ }
+ eventData.isValid = validation;
- if (this.state.inputText == value) return; // save the value on the input's State object
+ // for IE; since IE doesn't have an "input" event so "keyDown" is used instead to trigger the "onInput" callback,
+ // and so many keys do not change the input, and for those do not continue.
+ if (this.state.inputText == value) return;
+ // save the value on the input's State object
this.input.set.call(this, value, false); // update the input with the normalized value and run validations
- // this.setRangeAtStartEnd(); // fix caret position
+ // this.setRangeAtStartEnd(false, this.DOM.input); // fix caret position
- if (value.search(this.settings.delimiters) != -1) {
+ // if delimiters detected, add tags
+ if (value.search(_s.delimiters) != -1) {
if (this.addTags(value)) {
this.input.set.call(this); // clear the input field's value
}
- } else if (this.settings.dropdown.enabled >= 0) {
+ } else if (_s.dropdown.enabled >= 0) {
this.dropdown[showSuggestions ? 'show' : 'hide'](value);
}
-
this.trigger('input', eventData); // "input" event must be triggered at this point, before the dropdown is shown
},
onMixTagsInput(e) {
- var range,
- rangeText,
+ var rangeText,
match,
matchedPatternCount,
tag,
@@ -1573,62 +1996,65 @@ var Tagify;
tagsElems = this.getTagElms(),
fragment = document.createDocumentFragment(),
range = window.getSelection().getRangeAt(0),
- remainingTagsValues = [].map.call(tagsElems, (node) => this.tagData(node).value); // Android Chrome "keydown" event argument does not report the correct "key".
- // this workaround is needed to manually call "onKeydown" method with a synthesized event object
+ remainingTagsValues = [].map.call(tagsElems, (node) => getSetTagData(node).value);
+ // Android Chrome "keydown" event argument does not report the correct "key".
+ // this workaround is needed to manually call "onKeydown" method with a synthesized event object
if (e.inputType == 'deleteContentBackward' && isChromeAndroidBrowser()) {
this.events.callbacks.onKeydown.call(this, {
target: e.target,
key: 'Backspace',
});
- } // re-add "readonly" tags which might have been removed
+ }
+
+ // if there's a tag as the first child of the input, always make sure it has a zero-width character before it
+ // or if two tags are next to each-other, add a zero-space width character (For the caret to appear)
+ fixCaretBetweenTags(this.getTagElms());
+ // re-add "readonly" tags which might have been removed
this.value.slice().forEach((item) => {
if (item.readonly && !remainingTagsValues.includes(item.value))
fragment.appendChild(this.createTagElem(item));
});
-
if (fragment.childNodes.length) {
range.insertNode(fragment);
this.setRangeAtStartEnd(false, fragment.lastChild);
- } // check if tags were "magically" added/removed (browser redo/undo or CTRL-A -> delete)
+ }
+ // check if tags were "magically" added/removed (browser redo/undo or CTRL-A -> delete)
if (tagsElems.length != lastTagsCount) {
- this.value = [].map.call(this.getTagElms(), (node) => this.tagData(node));
+ this.value = [].map.call(this.getTagElms(), (node) => getSetTagData(node));
this.update({
withoutChangeEvent: true,
});
return;
}
-
if (this.hasMaxTags()) return true;
-
if (window.getSelection) {
- selection = window.getSelection(); // only detect tags if selection is inside a textNode (not somehow on already-existing tag)
+ selection = window.getSelection();
+ // only detect tags if selection is inside a textNode (not somehow on already-existing tag)
if (selection.rangeCount > 0 && selection.anchorNode.nodeType == 3) {
range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
range.setStart(selection.focusNode, 0);
rangeText = range.toString().slice(0, range.endOffset); // slice the range so everything AFTER the caret will be trimmed
// split = range.toString().split(_s.mixTagsAllowedAfter) // ["foo", "bar", "@baz"]
-
matchedPatternCount = rangeText.split(_s.pattern).length - 1;
match = rangeText.match(_s.pattern);
if (match)
// tag string, example: "@aaa ccc"
tag = rangeText.slice(rangeText.lastIndexOf(match[match.length - 1]));
-
if (tag) {
this.state.actions.ArrowLeft = false; // start fresh, assuming the user did not (yet) used any arrow to move the caret
-
this.state.tag = {
prefix: tag.match(_s.pattern)[0],
value: tag.replace(_s.pattern, ''), // get rid of the prefix
};
- this.state.tag.baseOffset = selection.baseOffset - this.state.tag.value.length;
- matchDelimiters = this.state.tag.value.match(_s.delimiters); // if a delimeter exists, add the value as tag (exluding the delimiter)
+ this.state.tag.baseOffset = selection.baseOffset - this.state.tag.value.length;
+ matchDelimiters = this.state.tag.value.match(_s.delimiters);
+ // if a delimeter exists, add the value as tag (exluding the delimiter)
if (matchDelimiters) {
this.state.tag.value = this.state.tag.value.replace(_s.delimiters, '');
this.state.tag.delimiters = matchDelimiters[0];
@@ -1636,39 +2062,44 @@ var Tagify;
this.dropdown.hide();
return;
}
+ showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled;
- showSuggestions = this.state.tag.value.length >= _s.dropdown.enabled; // When writeing something that might look like a tag (an email address) but isn't one - it is unwanted
- // the suggestions dropdown be shown, so the user closes it (in any way), and while continue typing,
+ // When writing something that might look like a tag (an email address) but isn't one - it is unwanted
+ // the suggestions dropdown be shown, so the user can close it (in any way), and while continue typing,
// dropdown should stay closed until another tag is typed.
// if( this.state.tag.value.length && this.state.dropdown.visible === false )
// showSuggestions = false
+
// test for similar flagged tags to the current tag
try {
matchFlaggedTag = this.state.flaggedTags[this.state.tag.baseOffset];
matchFlaggedTag =
matchFlaggedTag.prefix == this.state.tag.prefix &&
- matchFlaggedTag.value[0] == this.state.tag.value[0]; // reset
+ matchFlaggedTag.value[0] == this.state.tag.value[0];
+ // reset
if (this.state.flaggedTags[this.state.tag.baseOffset] && !this.state.tag.value)
delete this.state.flaggedTags[this.state.tag.baseOffset];
- } catch (err) {} // scenario: (do not show suggestions of previous matched tag, if more than 1 detected)
+ } catch (err) {}
+
+ // scenario: (do not show suggestions of another matched tag, if more than one detected)
// (2 tags exist) " a@a.com and @"
// (second tag is removed by backspace) " a@a.com and "
-
if (matchFlaggedTag || matchedPatternCount < this.state.mixMode.matchedPatternCount)
showSuggestions = false;
- } // no (potential) tag found
+ }
+ // no (potential) tag found
else {
this.state.flaggedTags = {};
}
-
this.state.mixMode.matchedPatternCount = matchedPatternCount;
}
- } // wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode")
- // the dropdown must be shown only after this event has been driggered, so an implementer could
- // dynamically change the whitelist.
+ }
+ // wait until the "this.value" has been updated (see "onKeydown" method for "mix-mode")
+ // the dropdown must be shown only after this event has been triggered, so an implementer could
+ // dynamically change the whitelist.
setTimeout(() => {
this.update({
withoutChangeEvent: true,
@@ -1677,28 +2108,41 @@ var Tagify;
'input',
extend({}, this.state.tag, {
textContent: this.DOM.input.textContent,
- })
+ }),
);
if (this.state.tag)
this.dropdown[showSuggestions ? 'show' : 'hide'](this.state.tag.value);
}, 10);
},
-
onInputIE(e) {
- var _this = this; // for the "e.target.textContent" to be changed, the browser requires a small delay
-
+ var _this = this;
+ // for the "e.target.textContent" to be changed, the browser requires a small delay
setTimeout(function () {
_this.events.callbacks.onInput.call(_this, e);
});
},
-
+ observeOriginalInputValue() {
+ // if, for some reason, the Tagified element is no longer in the DOM,
+ // call the "destroy" method to kill all references to timeouts/intervals
+ if (!this.DOM.originalInput.parentNode) this.destroy();
+
+ // if original input value changed for some reason (for exmaple a form reset)
+ if (this.DOM.originalInput.value != this.DOM.originalInput.tagifyValue)
+ this.loadOriginalValues();
+ },
+ onClickAnywhere(e) {
+ if (e.target != this.DOM.scope && !this.DOM.scope.contains(e.target)) {
+ this.toggleFocusClass(false);
+ this.state.hasFocus = false;
+ }
+ },
onClickScope(e) {
var _s = this.settings,
tagElm = e.target.closest('.' + _s.classNames.tag),
timeDiffFocus = +new Date() - this.state.hasFocus;
-
if (e.target == this.DOM.scope) {
- if (!this.state.hasFocus) this.DOM.input.focus();
+ // if( !this.state.hasFocus )
+ this.DOM.input.focus();
return;
} else if (e.target.classList.contains(_s.classNames.tagX)) {
this.removeTags(e.target.parentNode);
@@ -1707,39 +2151,46 @@ var Tagify;
this.trigger('click', {
tag: tagElm,
index: this.getNodeIndex(tagElm),
- data: this.tagData(tagElm),
- originalEvent: this.cloneEvent(e),
+ data: getSetTagData(tagElm),
+ event: e,
});
if (_s.editTags === 1 || _s.editTags.clicks === 1)
this.events.callbacks.onDoubleClickScope.call(this, e);
return;
- } // when clicking on the input itself
+ }
+
+ // when clicking on the input itself
else if (e.target == this.DOM.input) {
if (_s.mode == 'mix') {
// firefox won't show caret if last element is a tag (and not a textNode),
// so an empty textnode should be added
this.fixFirefoxLastTagNoCaret();
}
-
if (timeDiffFocus > 500) {
if (this.state.dropdown.visible) this.dropdown.hide();
- else if (_s.dropdown.enabled === 0 && _s.mode != 'mix') this.dropdown.show();
+ else if (_s.dropdown.enabled === 0 && _s.mode != 'mix')
+ this.dropdown.show(this.value.length ? '' : undefined);
return;
}
}
-
- if (_s.mode == 'select') !this.state.dropdown.visible && this.dropdown.show();
+ if (_s.mode == 'select' && _s.dropdown.enabled === 0 && !this.state.dropdown.visible)
+ this.dropdown.show();
},
-
// special proccess is needed for pasted content in order to "clean" it
onPaste(e) {
- var clipboardData, pastedText;
e.preventDefault();
- if (this.settings.readonly) return; // Get pasted data via clipboard API
+ var _s = this.settings,
+ selectModeWithoutInput = _s.mode == 'select' && _s.enforceWhitelist;
+ if (selectModeWithoutInput || !_s.userInput) {
+ return false;
+ }
+ var clipboardData, pastedText;
+ if (_s.readonly) return;
+ // Get pasted data via clipboard API
clipboardData = e.clipboardData || window.clipboardData;
pastedText = clipboardData.getData('Text');
- this.settings.hooks
+ _s.hooks
.beforePaste(e, {
tagify: this,
pastedText,
@@ -1747,154 +2198,172 @@ var Tagify;
})
.then((result) => {
if (result === undefined) result = pastedText;
-
if (result) {
this.injectAtCaret(result, window.getSelection().getRangeAt(0));
-
if (this.settings.mode == 'mix') {
this.events.callbacks.onMixTagsInput.call(this, e);
- } else if (this.settings.pasteAsTags) this.addTags(result, true);
- else this.state.inputText = result;
+ } else if (this.settings.pasteAsTags) {
+ this.addTags(this.state.inputText + result, true);
+ } else this.state.inputText = result;
}
})
.catch((err) => err);
},
-
+ onDrop(e) {
+ e.preventDefault();
+ },
onEditTagInput(editableElm, e) {
var tagElm = editableElm.closest('.' + this.settings.classNames.tag),
tagElmIdx = this.getNodeIndex(tagElm),
- tagData = this.tagData(tagElm),
- value = this.input.normalize.call(this, editableElm),
- hasChanged = tagElm.innerHTML != tagElm.__tagifyTagData.__originalHTML,
- isValid = this.validateTag({
- [this.settings.tagTextProp]: value,
- }); // the value could have been invalid in the first-place so make sure to re-validate it (via "addEmptyTag" method)
- // if the value is same as before-editing and the tag was valid before as well, ignore the current "isValid" result, which is false-positive
+ tagData = getSetTagData(tagElm),
+ textValue = this.input.normalize.call(this, editableElm),
+ dataForChangedProp = {
+ [this.settings.tagTextProp]: textValue,
+ __tagId: tagData.__tagId,
+ },
+ // "__tagId" is needed so validation will skip current tag when checking for dups
+ isValid = this.validateTag(dataForChangedProp),
+ // the value could have been invalid in the first-place so make sure to re-validate it (via "addEmptyTag" method)
+ hasChanged = this.editTagChangeDetected(extend(tagData, dataForChangedProp));
+ // if the value is same as before-editing and the tag was valid before as well, ignore the current "isValid" result, which is false-positive
if (!hasChanged && editableElm.originalIsValid === true) isValid = true;
tagElm.classList.toggle(this.settings.classNames.tagInvalid, isValid !== true);
tagData.__isValid = isValid;
tagElm.title = isValid === true ? tagData.title || tagData.value : isValid; // change the tag's title to indicate why is the tag invalid (if it's so)
- // show dropdown if typed text is equal or more than the "enabled" dropdown setting
- if (value.length >= this.settings.dropdown.enabled) {
+ // show dropdown if typed text is equal or more than the "enabled" dropdown setting
+ if (textValue.length >= this.settings.dropdown.enabled) {
// this check is needed apparently because doing browser "undo" will fire
// "onEditTagInput" but "this.state.editing" will be "false"
- if (this.state.editing) this.state.editing.value = value;
- this.dropdown.show(value);
+ if (this.state.editing) this.state.editing.value = textValue;
+ this.dropdown.show(textValue);
}
-
this.trigger('edit:input', {
tag: tagElm,
index: tagElmIdx,
data: extend({}, this.value[tagElmIdx], {
- newValue: value,
+ newValue: textValue,
}),
- originalEvent: this.cloneEvent(e),
+ event: e,
});
},
-
+ onEditTagPaste(tagElm, e) {
+ // Get pasted data via clipboard API
+ var clipboardData = e.clipboardData || window.clipboardData,
+ pastedText = clipboardData.getData('Text');
+ e.preventDefault();
+ var newNode = injectAtCaret(pastedText);
+ this.setRangeAtStartEnd(false, newNode);
+ },
onEditTagFocus(tagElm) {
this.state.editing = {
scope: tagElm,
input: tagElm.querySelector('[contenteditable]'),
};
},
-
onEditTagBlur(editableElm) {
- if (!this.state.hasFocus) this.toggleFocusClass(); // one scenario is when selecting a suggestion from the dropdown, when editing, and by selecting it
+ if (!this.state.hasFocus) this.toggleFocusClass();
+
+ // one scenario is when selecting a suggestion from the dropdown, when editing, and by selecting it
// the "onEditTagDone" is called directly, already replacing the tag, so the argument "editableElm"
// node isn't in the DOM anynmore because it has been replaced.
-
if (!this.DOM.scope.contains(editableElm)) return;
-
var _s = this.settings,
tagElm = editableElm.closest('.' + _s.classNames.tag),
+ tagData = getSetTagData(tagElm),
textValue = this.input.normalize.call(this, editableElm),
- originalData = this.tagData(tagElm).__originalData,
- // pre-edit data
- hasChanged = tagElm.innerHTML != tagElm.__tagifyTagData.__originalHTML,
- isValid = this.validateTag({
+ dataForChangedProp = {
[_s.tagTextProp]: textValue,
- }),
+ __tagId: tagData.__tagId,
+ },
+ // "__tagId" is needed so validation will skip current tag when checking for dups
+ originalData = tagData.__originalData,
+ // pre-edit data
+ hasChanged = this.editTagChangeDetected(extend(tagData, dataForChangedProp)),
+ isValid = this.validateTag(dataForChangedProp),
+ // "__tagId" is needed so validation will skip current tag when checking for dups
hasMaxTags,
- newTagData; // this.DOM.input.focus()
-
+ newTagData;
if (!textValue) {
this.onEditTagDone(tagElm);
return;
- } // if nothing changed revert back to how it was before editing
+ }
+ // if nothing changed revert back to how it was before editing
if (!hasChanged) {
this.onEditTagDone(tagElm, originalData);
return;
}
+ // need to know this because if "keepInvalidTags" setting is "true" and an invalid tag is edited as a valid one,
+ // but the maximum number of tags have alreay been reached, so it should not allow saving the new valid value.
+ // only if the tag was already valid before editing, ignore this check (see a few lines below)
hasMaxTags = this.hasMaxTags();
- newTagData =
- this.getWhitelistItem(textValue) ||
- extend({}, originalData, {
- [_s.tagTextProp]: textValue,
- value: textValue,
- __isValid: isValid,
- });
+ newTagData = extend({}, originalData, {
+ [_s.tagTextProp]: this.trim(textValue),
+ __isValid: isValid,
+ });
- _s.transformTag.call(this, newTagData, originalData); // MUST re-validate after tag transformation
+ // pass through optional transformer defined in settings
+ _s.transformTag.call(this, newTagData, originalData);
+
+ // MUST re-validate after tag transformation
// only validate the "tagTextProp" because is the only thing that metters for validating an edited tag.
// -- Scenarios: --
// 1. max 3 tags allowd. there are 4 tags, one has invalid input and is edited to a valid one, and now should be marked as "not allowed" because limit of tags has reached
// 2. max 3 tags allowed. there are 3 tags, one is edited, and so max-tags vaildation should be OK
-
- isValid =
- !hasMaxTags &&
- this.validateTag({
- [_s.tagTextProp]: newTagData[_s.tagTextProp],
- });
-
+ isValid = (!hasMaxTags || originalData.__isValid === true) && this.validateTag(newTagData);
if (isValid !== true) {
this.trigger('invalid', {
data: newTagData,
tag: tagElm,
message: isValid,
- }); // do nothing if invalid, stay in edit-mode until corrected or reverted by presssing esc
+ });
+ // do nothing if invalid, stay in edit-mode until corrected or reverted by presssing esc
if (_s.editTags.keepInvalid) return;
if (_s.keepInvalidTags) newTagData.__isValid = isValid;
// revert back if not specified to keep
else newTagData = originalData;
} else if (_s.keepInvalidTags) {
- // cleaup any previous leftovers if the tag was
+ // cleaup any previous leftovers if the tag was invalid
delete newTagData.title;
delete newTagData['aria-invalid'];
delete newTagData.class;
- } // tagElm.classList.toggle(_s.classNames.tagInvalid, true)
+ }
+
+ // tagElm.classList.toggle(_s.classNames.tagInvalid, true)
this.onEditTagDone(tagElm, newTagData);
},
-
onEditTagkeydown(e, tagElm) {
+ // ignore keys during IME composition
+ if (this.state.composing) return;
this.trigger('edit:keydown', {
- originalEvent: this.cloneEvent(e),
+ event: e,
});
-
switch (e.key) {
case 'Esc':
- case 'Escape':
- tagElm.innerHTML = tagElm.__tagifyTagData.__originalHTML;
-
+ case 'Escape': {
+ // revert the tag to how it was before editing
+ // replace current tag with original one (pre-edited one)
+ tagElm.parentNode.replaceChild(tagElm.__tagifyTagData.__originalHTML, tagElm);
+ this.state.editing = false;
+ }
case 'Enter':
case 'Tab':
e.preventDefault();
e.target.blur();
}
},
-
onDoubleClickScope(e) {
var tagElm = e.target.closest('.' + this.settings.classNames.tag),
+ tagData = getSetTagData(tagElm),
_s = this.settings,
isEditingTag,
isReadyOnlyTag;
- if (!tagElm) return;
+ if (!tagElm || !_s.userInput || tagData.editable === false) return;
isEditingTag = tagElm.classList.contains(this.settings.classNames.tagEditing);
isReadyOnlyTag = tagElm.hasAttribute('readonly');
if (
@@ -1909,8 +2378,96 @@ var Tagify;
this.trigger('dblclick', {
tag: tagElm,
index: this.getNodeIndex(tagElm),
- data: this.tagData(tagElm),
+ data: getSetTagData(tagElm),
+ });
+ },
+ /**
+ *
+ * @param {Object} m an object representing the observed DOM changes
+ */
+ onInputDOMChange(m) {
+ // iterate all DOM mutation
+ m.forEach((record) => {
+ // only the ADDED nodes
+ record.addedNodes.forEach((addedNode) => {
+ // fix chrome's placing '
' everytime ENTER key is pressed, and replace with just `
') {
+ addedNode.replaceWith(document.createElement('br'));
+ }
+
+ // if the added element is a div containing a tag within it (chrome does this when pressing ENTER before a tag)
+ else if (
+ addedNode.nodeType == 1 &&
+ addedNode.querySelector(this.settings.classNames.tagSelector)
+ ) {
+ let newlineText = document.createTextNode('');
+ if (
+ addedNode.childNodes[0].nodeType == 3 &&
+ addedNode.previousSibling.nodeName != 'BR'
+ )
+ newlineText = document.createTextNode('\n');
+
+ // unwrap the useless div
+ // chrome adds a BR at the end which should be removed
+ addedNode.replaceWith(...[newlineText, ...[...addedNode.childNodes].slice(0, -1)]);
+ placeCaretAfterNode(newlineText);
+ }
+
+ // if this is a tag
+ else if (isNodeTag.call(this, addedNode)) {
+ if (
+ addedNode.previousSibling?.nodeType == 3 &&
+ !addedNode.previousSibling.textContent
+ )
+ addedNode.previousSibling.remove();
+
+ // and it is the first node in a new line
+ if (addedNode.previousSibling && addedNode.previousSibling.nodeName == 'BR') {
+ // allows placing the caret just before the tag, when the tag is the first node in that line
+ addedNode.previousSibling.replaceWith('\n' + ZERO_WIDTH_CHAR);
+ let nextNode = addedNode.nextSibling,
+ anythingAfterNode = '';
+ while (nextNode) {
+ anythingAfterNode += nextNode.textContent;
+ nextNode = nextNode.nextSibling;
+ }
+
+ // when hitting ENTER for new line just before an existing tag, but skip below logic when a tag has been addded
+ anythingAfterNode.trim() && placeCaretAfterNode(addedNode.previousSibling);
+ }
+
+ // if previous sibling does not exists (meanning the addedNode is the first node in this.DOM.input)
+ // or, if the previous sibling is also a tag, add a zero-space character before (to allow showing the caret in Chrome)
+ else if (!addedNode.previousSibling || getSetTagData(addedNode.previousSibling)) {
+ addedNode.before(ZERO_WIDTH_CHAR);
+ }
+ }
+ });
+ record.removedNodes.forEach((removedNode) => {
+ // when trying to delete a tag which is in a new line and there's nothing else there (caret is after the tag)
+ if (
+ removedNode &&
+ removedNode.nodeName == 'BR' &&
+ isNodeTag.call(this, lastInputChild)
+ ) {
+ this.removeTags(lastInputChild);
+ this.fixFirefoxLastTagNoCaret();
+ }
+ });
});
+
+ // get the last child only after the above DOM modifications
+ // check these scenarios:
+ // 1. after a single line, press ENTER once - should add only 1 BR
+ // 2. presss ENTER right before a tag
+ // 3. press enter within a text node before a tag
+ var lastInputChild = this.DOM.input.lastChild;
+ if (lastInputChild && lastInputChild.nodeValue == '') lastInputChild.remove();
+
+ // make sure the last element is always a BR
+ if (!lastInputChild || lastInputChild.nodeName != 'BR') {
+ this.DOM.input.appendChild(document.createElement('br'));
+ }
},
},
};
@@ -1920,35 +2477,50 @@ var Tagify;
* @param {Object} input DOM element
* @param {Object} settings settings object
*/
-
function Tagify(input, settings) {
if (!input) {
- console.warn('Tagify: ', 'input element not found', input);
- return this;
+ console.warn('Tagify:', 'input element not found', input);
+ // return an empty mock of all methods, so the code using tagify will not break
+ // because it might be calling methods even though the input element does not exist
+ const mockInstance = new Proxy(this, {
+ get() {
+ return () => mockInstance;
+ },
+ });
+ return mockInstance;
}
-
- if (input.previousElementSibling && input.previousElementSibling.classList.contains('tagify')) {
- console.warn('Tagify: ', 'input element is already Tagified', input);
- return this;
+ if (input.__tagify) {
+ console.warn(
+ 'Tagify: ',
+ 'input element is already Tagified - Same instance is returned.',
+ input,
+ );
+ return input.__tagify;
}
-
extend(this, EventDispatcher(this));
- this.isFirefox = typeof InstallTrigger !== 'undefined';
+ this.isFirefox =
+ /firefox|fxios/i.test(navigator.userAgent) && !/seamonkey/i.test(navigator.userAgent);
this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility
- this.applySettings(input, settings || {});
+ settings = settings || {};
+ this.getPersistedData = getPersistedData(settings.id);
+ this.setPersistedData = setPersistedData(settings.id);
+ this.clearPersistedData = clearPersistedData(settings.id);
+ this.applySettings(input, settings);
this.state = {
inputText: '',
editing: false,
+ composing: false,
actions: {},
// UI actions for state-locking
mixMode: {},
dropdown: {},
flaggedTags: {}, // in mix-mode, when a string is detetced as potential tag, and the user has chocen to close the suggestions dropdown, keep the record of the tasg here
};
+
this.value = []; // tags' data
- // events' callbacks references will be stores here, so events could be unbinded
+ // events' callbacks references will be stores here, so events could be unbinded
this.listeners = {};
this.DOM = {}; // Store all relevant DOM elements in an Object
@@ -1959,16 +2531,22 @@ var Tagify;
this.events.customBinding.call(this);
this.events.binding.call(this);
input.autofocus && this.DOM.input.focus();
+ input.__tagify = this;
}
-
Tagify.prototype = {
_dropdown,
- TEXTS: {
- empty: 'empty',
- exceed: 'number of tags exceeded',
- pattern: 'pattern mismatch',
- duplicate: 'already exists',
- notAllowed: 'not allowed',
+ getSetTagData,
+ helpers: {
+ sameStr,
+ removeCollectionProp,
+ omit,
+ isObject,
+ parseHTML,
+ escapeHTML,
+ extend,
+ concatWithoutDups,
+ getUID,
+ isNodeTag,
},
customEventsList: [
'change',
@@ -1993,91 +2571,106 @@ var Tagify;
'dropdown:scroll',
],
dataProps: ['__isValid', '__removed', '__originalData', '__originalHTML', '__tagId'],
-
// internal-uasge props
+
trim(text) {
return this.settings.trim && text && typeof text == 'string' ? text.trim() : text;
},
-
// expose this handy utility function
parseHTML,
templates,
-
parseTemplate(template, data) {
template = this.settings.templates[template] || template;
- return this.parseHTML(template.apply(this, data));
+ return parseHTML(template.apply(this, data));
},
-
set whitelist(arr) {
- this.settings.whitelist = arr && Array.isArray(arr) ? arr : [];
+ const isArray = arr && Array.isArray(arr);
+ this.settings.whitelist = isArray ? arr : [];
+ this.setPersistedData(isArray ? arr : [], 'whitelist');
},
-
get whitelist() {
return this.settings.whitelist;
},
-
- applySettings(input, settings) {
- DEFAULTS.templates = this.templates;
-
- var _s = (this.settings = extend({}, DEFAULTS, settings));
-
- _s.readonly = input.hasAttribute('readonly') || input.hasAttribute('disabled'); // if "readonly" do not include an "input" element inside the Tags component
-
- _s.placeholder = input.getAttribute('placeholder') || _s.placeholder || '';
- _s.required = input.hasAttribute('required');
-
- for (let name in _s.classNames)
- Object.defineProperty(_s.classNames, name + 'Selector', {
+ generateClassSelectors(classNames) {
+ for (let name in classNames) {
+ let currentName = name;
+ Object.defineProperty(classNames, currentName + 'Selector', {
get() {
- return '.' + this[name].split(' ').join('.');
+ return '.' + this[currentName].split(' ')[0];
},
});
-
+ }
+ },
+ applySettings(input, settings) {
+ DEFAULTS.templates = this.templates;
+ var mixModeDefaults = {
+ dropdown: {
+ position: 'text',
+ },
+ };
+ var mergedDefaults = extend({}, DEFAULTS, settings.mode == 'mix' ? mixModeDefaults : {});
+ var _s = (this.settings = extend({}, mergedDefaults, settings));
+ _s.disabled = input.hasAttribute('disabled');
+ _s.readonly = _s.readonly || input.hasAttribute('readonly');
+ _s.placeholder = escapeHTML(input.getAttribute('placeholder') || _s.placeholder || '');
+ _s.required = input.hasAttribute('required');
+ this.generateClassSelectors(_s.classNames);
+ if (_s.dropdown.includeSelectedTags === undefined)
+ _s.dropdown.includeSelectedTags = _s.duplicates;
if (this.isIE) _s.autoComplete = false; // IE goes crazy if this isn't false
['whitelist', 'blacklist'].forEach((name) => {
var attrVal = input.getAttribute('data-' + name);
-
if (attrVal) {
attrVal = attrVal.split(_s.delimiters);
if (attrVal instanceof Array) _s[name] = attrVal;
}
- }); // backward-compatibility for old version of "autoComplete" setting:
+ });
+ // backward-compatibility for old version of "autoComplete" setting:
if ('autoComplete' in settings && !isObject(settings.autoComplete)) {
_s.autoComplete = DEFAULTS.autoComplete;
_s.autoComplete.enabled = settings.autoComplete;
}
-
if (_s.mode == 'mix') {
+ _s.pattern = _s.pattern || /@/;
_s.autoComplete.rightKey = true;
_s.delimiters = settings.delimiters || null; // default dlimiters in mix-mode must be NULL
+
// needed for "filterListItems". This assumes the user might have forgotten to manually
// define the same term in "dropdown.searchKeys" as defined in "tagTextProp" setting, so
// by automatically adding it, tagify is "helping" out, guessing the intesntions of the developer.
-
if (_s.tagTextProp && !_s.dropdown.searchKeys.includes(_s.tagTextProp))
_s.dropdown.searchKeys.push(_s.tagTextProp);
}
-
if (input.pattern)
try {
_s.pattern = new RegExp(input.pattern);
- } catch (e) {} // Convert the "delimiters" setting into a REGEX object
+ } catch (e) {}
- if (this.settings.delimiters) {
+ // Convert the "delimiters" setting into a REGEX object
+ if (_s.delimiters) {
+ _s._delimiters = _s.delimiters;
try {
_s.delimiters = new RegExp(this.settings.delimiters, 'g');
} catch (e) {}
- } // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode)
+ }
+ if (_s.disabled) _s.userInput = false;
+ this.TEXTS = _objectSpread2(_objectSpread2({}, TEXTS), _s.texts || {});
- if (_s.mode == 'select') _s.dropdown.enabled = 0;
- _s.dropdown.appendTarget =
- settings.dropdown && settings.dropdown.appendTarget
- ? settings.dropdown.appendTarget
- : document.body;
+ // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode)
+ if ((_s.mode == 'select' && !settings.dropdown?.enabled) || !_s.userInput) {
+ _s.dropdown.enabled = 0;
+ }
+ _s.dropdown.appendTarget = settings.dropdown?.appendTarget || document.body;
+
+ // get & merge persisted data with current data
+ let persistedWhitelist = this.getPersistedData('whitelist');
+ if (Array.isArray(persistedWhitelist))
+ this.whitelist = Array.isArray(_s.whitelist)
+ ? concatWithoutDups(_s.whitelist, persistedWhitelist)
+ : persistedWhitelist;
},
-
/**
* Returns a string of HTML element attributes
* @param {Object} data [Tag data]
@@ -2086,23 +2679,17 @@ var Tagify;
var attrs = this.getCustomAttributes(data),
s = '',
k;
-
- for (k in attrs) s += ' ' + k + (data[k] !== undefined ? `="${data[k]}"` : '');
-
+ for (k in attrs) s += ' ' + k + (data[k] !== undefined ? `="${attrs[k]}"` : '');
return s;
},
-
/**
* Returns an object of attributes to be used for the templates
*/
getCustomAttributes(data) {
// only items which are objects have properties which can be used as attributes
if (!isObject(data)) return '';
-
var output = {},
- propName,
- k;
-
+ propName;
for (propName in data) {
if (
propName.slice(0, 2) != '__' &&
@@ -2110,14 +2697,14 @@ var Tagify;
data.hasOwnProperty(propName) &&
data[propName] !== undefined
)
- output[propName] = data[propName];
+ output[propName] = escapeHTML(data[propName]);
}
return output;
},
-
setStateSelection() {
- var selection = window.getSelection(); // save last selection place to be able to inject anything from outside to that specific place
+ var selection = window.getSelection();
+ // save last selection place to be able to inject anything from outside to that specific place
var sel = {
anchorOffset: selection.anchorOffset,
anchorNode: selection.anchorNode,
@@ -2126,52 +2713,13 @@ var Tagify;
this.state.selection = sel;
return sel;
},
-
- /**
- * Get the caret position relative to the viewport
- * https://stackoverflow.com/q/58985076/104380
- *
- * @returns {object} left, top distance in pixels
- */
- getCaretGlobalPosition() {
- const sel = document.getSelection();
-
- if (sel.rangeCount) {
- const r = sel.getRangeAt(0);
- const node = r.startContainer;
- const offset = r.startOffset;
- let rect, r2;
-
- if (offset > 0) {
- r2 = document.createRange();
- r2.setStart(node, offset - 1);
- r2.setEnd(node, offset);
- rect = r2.getBoundingClientRect();
- return {
- left: rect.right,
- top: rect.top,
- bottom: rect.bottom,
- };
- }
-
- if (node.getBoundingClientRect) return node.getBoundingClientRect();
- }
-
- return {
- left: -9999,
- top: -9999,
- };
- },
-
/**
* Get specific CSS variables which are relevant to this script and parse them as needed.
* The result is saved on the instance in "this.CSSVars"
*/
getCSSVars() {
var compStyle = getComputedStyle(this.DOM.scope, null);
-
const getProp = (name) => compStyle.getPropertyValue('--' + name);
-
function seprateUnitFromValue(a) {
if (!a) return {};
a = a.trim().split(' ')[0];
@@ -2189,30 +2737,31 @@ var Tagify;
unit,
};
}
-
this.CSSVars = {
- tagHideTransition: (({ value, unit }) => (unit == 's' ? value * 1000 : value))(
- seprateUnitFromValue(getProp('tag-hide-transition'))
- ),
+ tagHideTransition: ((_ref) => {
+ let value = _ref.value,
+ unit = _ref.unit;
+ return unit == 's' ? value * 1000 : value;
+ })(seprateUnitFromValue(getProp('tag-hide-transition'))),
};
},
-
/**
* builds the HTML of this component
* @param {Object} input [DOM element which would be "transformed" into "Tags"]
*/
build(input) {
var DOM = this.DOM;
-
if (this.settings.mixMode.integrated) {
DOM.originalInput = null;
DOM.scope = input;
DOM.input = input;
} else {
DOM.originalInput = input;
+ DOM.originalInput_tabIndex = input.tabIndex;
DOM.scope = this.parseTemplate('wrapper', [input, this.settings]);
DOM.input = DOM.scope.querySelector(this.settings.classNames.inputSelector);
input.parentNode.insertBefore(DOM.scope, input);
+ input.tabIndex = -1; // do not allow focus or typing directly, once tagified
}
},
@@ -2220,68 +2769,73 @@ var Tagify;
* revert any changes made by this component
*/
destroy() {
+ this.events.unbindGlobal.call(this);
this.DOM.scope.parentNode.removeChild(this.DOM.scope);
+ this.DOM.originalInput.tabIndex = this.DOM.originalInput_tabIndex;
+ delete this.DOM.originalInput.__tagify;
this.dropdown.hide(true);
clearTimeout(this.dropdownHide__bindEventsTimeout);
+ clearInterval(this.listeners.main.originalInputValueObserverInterval);
},
-
/**
- * if the original input had any values, add them as tags
+ * if the original input has any values, add them as tags
*/
loadOriginalValues(value) {
var lastChild,
_s = this.settings;
- if (value === undefined)
- value = _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value;
- this.removeAllTags({
- withoutChangeEvent: true,
- });
+ // temporarily block firing the "change" event on the original input until
+ // this method finish removing current value and adding a new one
+ this.state.blockChangeEvent = true;
+ if (value === undefined) {
+ const persistedOriginalValue = this.getPersistedData('value');
+
+ // if the field already has a field, trust its the desired
+ // one to be rendered and do not use the persisted one
+ if (persistedOriginalValue && !this.DOM.originalInput.value) value = persistedOriginalValue;
+ else
+ value = _s.mixMode.integrated ? this.DOM.input.textContent : this.DOM.originalInput.value;
+ }
+ this.removeAllTags();
if (value) {
if (_s.mode == 'mix') {
- this.parseMixTags(value.trim());
+ this.parseMixTags(value);
lastChild = this.DOM.input.lastChild;
+
+ // fixes a Chrome bug, when the last node in `mix-mode` is a tag, the caret appears at the far-top-top, outside the field
if (!lastChild || lastChild.tagName != 'BR')
this.DOM.input.insertAdjacentHTML('beforeend', '