bootstrap5-tags
Version:
Replace select[multiple] with nices badges for Bootstrap 5
1,826 lines (1,653 loc) • 71.4 kB
JavaScript
/**
* Bootstrap 5 (and 4!) tags
* https://github.com/lekoala/bootstrap5-tags
* @license MIT
*/
// #region config
/**
* @callback EventCallback
* @param {Event} event
* @param {Tags} inst
* @returns {void}
*/
/**
* @callback ServerCallback
* @param {Response} response
* @param {Tags} inst
* @returns {Promise}
*/
/**
* @callback ErrorCallback
* @param {Error} e
* @param {AbortSignal} signal
* @param {Tags} inst
* @returns {void}
*/
/**
* @callback ModalItemCallback
* @param {String} value
* @param {Tags} inst
* @returns {Promise}
*/
/**
* @callback RenderCallback
* @param {Suggestion} item
* @param {String} label
* @param {Tags} inst
* @returns {String}
*/
/**
* @callback ItemCallback
* @param {Suggestion} item
* @param {Tags} inst
* @returns {void}
*/
/**
* @callback ValueCallback
* @param {String} value
* @param {Tags} inst
* @returns {void}
*/
/**
* @callback AddCallback
* @param {String} value
* @param {Object} data
* @param {Tags} inst
* @returns {void|Boolean}
*/
/**
* @callback DataCallback
* @param {*} src
* @param {Tags} inst
* @returns {void|Boolean}
*/
/**
* @callback CreateCallback
* @param {HTMLOptionElement} option
* @param {Tags} inst
* @returns {void}
*/
/**
* @typedef Config
* @property {Array<Suggestion|SuggestionGroup>} items Source items
* @property {Boolean} allowNew Allows creation of new tags
* @property {Boolean} showAllSuggestions Show all suggestions even if they don't match. Disables validation.
* @property {String} badgeStyle Color of the badge (color can be configured per option as well)
* @property {Boolean} allowClear Show a clear icon
* @property {Boolean} clearEnd Place clear icon at the end
* @property {Array} selected A list of initially selected values
* @property {String} regex Regex for new tags
* @property {Array|String} separator A list (pipe separated) of characters that should act as separator (default is using enter key)
* @property {Number} max Limit to a maximum of tags (0 = no limit)
* @property {String} placeholder Provides a placeholder if none are provided as the first empty option
* @property {String} clearLabel Text as clear tooltip
* @property {String} searchLabel Default placeholder
* @property {Boolean} showDropIcon Show dropdown icon
* @property {Boolean} keepOpen Keep suggestions open after selection, clear on focus out
* @property {Boolean} allowSame Allow same tags used multiple times
* @property {String} baseClass Customize the class applied to badges
* @property {Boolean} addOnBlur Add new tags on blur (only if allowNew is enabled)
* @property {Boolean} showDisabled Show disabled tags
* @property {Boolean} hideNativeValidation Hide native validation tooltips
* @property {Number} suggestionsThreshold Number of chars required to show suggestions
* @property {Number} maximumItems Maximum number of items to display
* @property {Boolean} autoselectFirst Always select the first item
* @property {Boolean} updateOnSelect Update input value on selection (doesn't play nice with autoselectFirst)
* @property {Boolean} highlightTyped Highlight matched part of the suggestion
* @property {String} highlightClass Class applied to the mark element
* @property {Boolean} fullWidth Match the width on the input field
* @property {Boolean} fixed Use fixed positioning (solve overflow issues)
* @property {Boolean} fuzzy Fuzzy search
* @property {Boolean} startsWith Must start with the string. Defaults to false (it matches any position).
* @property {Boolean} singleBadge Show badge for single elements
* @property {Array} activeClasses By default: ["bg-primary", "text-white"]
* @property {String} labelField Key for the label
* @property {String} valueField Key for the value
* @property {Array} searchFields Key for the search
* @property {String} queryParam Name of the param passed to endpoint (query by default)
* @property {String} server Endpoint for data provider
* @property {String} serverMethod HTTP request method for data provider, default is GET
* @property {String|Object} serverParams Parameters to pass along to the server. You can specify a "related" key with the id of a related field.
* @property {String} serverDataKey By default: data
* @property {Object} fetchOptions Any other fetch options (https://developer.mozilla.org/en-US/docs/Web/API/fetch#syntax)
* @property {Boolean} liveServer Should the endpoint be called each time on input
* @property {Boolean} noCache Prevent caching by appending a timestamp
* @property {Boolean} allowHtml Allow html in input (can lead to script injection)
* @property {Function} inputFilter Function to filter input
* @property {Function} sanitizer Alternative function to sanitize content
* @property {Number} debounceTime Debounce time for live server
* @property {String} notFoundMessage Display a no suggestions found message. Leave empty to disable
* @property {RenderCallback} onRenderItem Callback function that returns the suggestion
* @property {ItemCallback} onSelectItem Callback function to call on selection
* @property {ValueCallback} onClearItem Callback function to call on clear
* @property {CreateCallback} onCreateItem Callback function when an item is created
* @property {EventCallback} onBlur Callback function on blur
* @property {DataCallback} onDataLoaded Callback function on data load
* @property {EventCallback} onFocus Callback function on focus
* @property {AddCallback} onCanAdd Callback function to validate item. Return false to show validation message.
* @property {ServerCallback} onServerResponse Callback function to process server response. Must return a Promise
* @property {ErrorCallback} onServerError Callback function to process server errors.
* @property {ModalItemCallback} confirmClear Allow modal confirmation of clear. Must return a Promise
* @property {ModalItemCallback} confirmAdd Allow modal confirmation of add. Must return a Promise
*/
/**
* @typedef Suggestion
* @property {String} value Can be overriden by config valueField
* @property {String} label Can be overriden by config labelField
* @property {String} title
* @property {Boolean} disabled
* @property {Object} data
* @property {Boolean} [selected]
* @property {Number} [group_id]
*/
/**
* @typedef SuggestionGroup
* @property {String} group
* @property {Array} items
*/
/**
* @type {Config}
*/
const DEFAULTS = {
items: [],
allowNew: false,
showAllSuggestions: false,
badgeStyle: "primary",
allowClear: false,
clearEnd: false,
selected: [],
regex: "",
separator: [],
max: 0,
clearLabel: "Clear",
searchLabel: "Type a value",
showDropIcon: true,
keepOpen: false,
allowSame: false,
baseClass: "",
placeholder: "",
addOnBlur: false,
showDisabled: false,
hideNativeValidation: false,
suggestionsThreshold: -1,
maximumItems: 0,
autoselectFirst: true,
updateOnSelect: false,
highlightTyped: false,
highlightClass: "",
fullWidth: true,
fixed: false,
fuzzy: false,
startsWith: false,
singleBadge: false,
activeClasses: ["bg-primary", "text-white"],
labelField: "label",
valueField: "value",
searchFields: ["label"],
queryParam: "query",
server: "",
serverMethod: "GET",
serverParams: {},
serverDataKey: "data",
fetchOptions: {},
liveServer: false,
noCache: true,
allowHtml: false,
debounceTime: 300,
notFoundMessage: "",
inputFilter: (str) => str,
sanitizer: (str) => sanitize(str),
onRenderItem: (item, label, inst) => {
if (inst.config("allowHtml")) {
return label;
}
return inst.config("sanitizer")(label);
},
onSelectItem: (item, inst) => {},
onClearItem: (value, inst) => {},
onCreateItem: (option, inst) => {},
onBlur: (event, inst) => {},
onDataLoaded: (src, inst) => {},
onFocus: (event, inst) => {},
onCanAdd: (text, data, inst) => {},
confirmClear: (item, inst) => Promise.resolve(),
confirmAdd: (item, inst) => Promise.resolve(),
onServerResponse: (response, inst) => {
return response.json();
},
onServerError: (e, signal, inst) => {
// Current version of Firefox rejects the promise with a DOMException
if (e.name === "AbortError" || signal.aborted) {
return;
}
console.error(e);
},
};
// #endregion
// #region constants
const CLASS_PREFIX = "tags-";
const LOADING_CLASS = "is-loading";
const ACTIVE_CLASS = "is-active";
const INVALID_CLASS = "is-invalid";
const MAX_REACHED_CLASS = "is-max-reached";
const SHOW_CLASS = "show";
const VALUE_ATTRIBUTE = "data-value";
const NEXT = "next";
const PREV = "prev";
const FOCUS_CLASS = "form-control-focus"; // should match form-control:focus
const PLACEHOLDER_CLASS = "form-placeholder-shown"; // should match :placeholder-shown
const DISABLED_CLASS = "form-control-disabled"; // should match form-control:disabled
const INSTANCE_MAP = new WeakMap();
let counter = 0;
//@ts-ignore
let tooltip = window.bootstrap && window.bootstrap.Tooltip;
// #endregion
// #region functions
/**
* @param {Function} func
* @param {number} timeout
* @returns {Function}
*/
function debounce(func, timeout = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
//@ts-ignore
func.apply(this, args);
}, timeout);
};
}
/**
* @param {string} text
* @param {string} size
* @returns {Number}
*/
function calcTextWidth(text, size = null) {
const span = ce("span");
document.body.appendChild(span);
span.style.fontSize = size || "inherit";
span.style.height = "auto";
span.style.width = "auto";
span.style.position = "absolute";
span.style.whiteSpace = "no-wrap";
span.innerHTML = sanitize(text);
const width = Math.ceil(span.clientWidth);
document.body.removeChild(span);
return width;
}
/**
* @link https://stackoverflow.com/questions/3043775/how-to-escape-html
* @param {string} text
* @returns {string}
*/
function sanitize(text) {
return text.replace(/[\x26\x0A\<>'"]/g, function (r) {
return "&#" + r.charCodeAt(0) + ";";
});
}
/**
* @param {String} str
* @returns {String}
*/
function removeDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
/**
* @param {String|Number} str
* @returns {String}
*/
function normalize(str) {
if (!str) {
return "";
}
return removeDiacritics(str.toString()).toLowerCase();
}
/**
* A simple fuzzy match algorithm that checks if chars are matched
* in order in the target string
*
* @param {String} str
* @param {String} lookup
* @returns {Boolean}
*/
function fuzzyMatch(str, lookup) {
if (str.indexOf(lookup) >= 0) {
return true;
}
let pos = 0;
for (let i = 0; i < lookup.length; i++) {
const c = lookup[i];
if (c == " ") continue;
pos = str.indexOf(c, pos) + 1;
if (pos <= 0) {
return false;
}
}
return true;
}
/**
* @param {HTMLElement} item
*/
function hideItem(item) {
item.style.display = "none";
attrs(item, {
"aria-hidden": "true",
});
}
/**
* @param {HTMLElement} item
*/
function showItem(item) {
item.style.display = "list-item";
attrs(item, {
"aria-hidden": "false",
});
}
/**
* @param {HTMLElement} el
* @param {Object} attrs
*/
function attrs(el, attrs) {
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, v);
}
}
/**
* @param {HTMLElement} el
* @param {string} attr
*/
function rmAttr(el, attr) {
if (el.hasAttribute(attr)) {
el.removeAttribute(attr);
}
}
/**
* Allow 1/0, true/false as strings
* @param {any} value
* @returns {Boolean}
*/
function parseBool(value) {
return (
["true", "false", "1", "0", true, false].includes(value) &&
!!JSON.parse(value)
);
}
/**
* @template {keyof HTMLElementTagNameMap} K
* @param {K|String} tagName Name of the element
* @returns {*}
*/
function ce(tagName) {
return document.createElement(tagName);
}
/**
*
* @param {String} str
* @param {Array} tokens
* @returns {Array}
*/
function splitMulti(str, tokens) {
let tempChar = tokens[0];
for (let i = 1; i < tokens.length; i++) {
str = str.split(tokens[i]).join(tempChar);
}
return str.split(tempChar);
}
function nested(str, obj = "window") {
return str.split(".").reduce((r, p) => r[p], obj);
}
/**
* @param {HTMLElement} el
* @param {HTMLElement} newEl
* @returns {HTMLElement}
*/
// function insertAfter(el, newEl) {
// return el.parentNode.insertBefore(newEl, el.nextSibling);
// }
// #endregion
class Tags {
/**
* @param {HTMLSelectElement} el
* @param {Object|Config} config
*/
constructor(el, config = {}) {
if (!(el instanceof HTMLElement)) {
console.error("Invalid element", el);
return;
}
INSTANCE_MAP.set(el, this);
counter++;
this._selectElement = el;
this._configure(config);
// private vars
this._isMouse = false;
this._keyboardNavigation = false;
this._searchFunc = debounce(() => {
this._loadFromServer(true);
}, this._config.debounceTime);
this._fireEvents = true;
this._configureParent();
// Create elements
this._holderElement = ce("div"); // this is the one holding the fake input and the dropmenu
this._containerElement = ce("div"); // this is the one for the fake input (labels + input)
this._dropElement = ce("ul"); // this dropdown list
this._searchInput = ce("input"); // the input element
this._holderElement.appendChild(this._containerElement);
// insert before select, this helps having native validation tooltips positioned properly
this._selectElement.parentElement.insertBefore(
this._holderElement,
this._selectElement,
);
// insertAfter(this._selectElement, this._holderElement);
// Configure them
this._configureHolderElement();
this._configureContainerElement();
this._configureSelectElement();
this._configureSearchInput();
this._configureDropElement();
this.resetState();
// Rebind handleEvent to make sure the scope will not change
this.handleEvent = (ev) => {
this._handleEvent(ev);
};
if (this._config.fixed) {
document.addEventListener("scroll", this, true); // capture input for all scrollables elements
window.addEventListener("resize", this);
}
// Add listeners (remove then on dispose()). See handleEvent.
["focus", "blur", "input", "keydown", "paste"].forEach((type) => {
this._searchInput.addEventListener(type, this);
});
["mousemove", "mouseleave"].forEach((type) => {
this._dropElement.addEventListener(type, this);
});
this.loadData(true);
}
// #region Core
/**
* Attach to all elements matched by the selector
* @param {string} selector
* @param {Object} opts
* @param {Boolean} reset
*/
static init(selector = "select[multiple]", opts = {}, reset = false) {
/**
* @type {NodeListOf<HTMLSelectElement>}
*/
let list = document.querySelectorAll(selector);
for (let i = 0; i < list.length; i++) {
const inst = Tags.getInstance(list[i]);
if (inst && !reset) {
continue;
}
if (inst) {
inst.dispose();
}
new Tags(list[i], opts);
}
}
/**
* @param {HTMLSelectElement} el
*/
static getInstance(el) {
if (INSTANCE_MAP.has(el)) {
return INSTANCE_MAP.get(el);
}
}
dispose() {
["focus", "blur", "input", "keydown", "paste"].forEach((type) => {
this._searchInput.removeEventListener(type, this);
});
["mousemove", "mouseleave"].forEach((type) => {
this._dropElement.removeEventListener(type, this);
});
if (this._config.fixed) {
document.removeEventListener("scroll", this, true);
window.removeEventListener("resize", this);
}
// restore select, remove our custom stuff and unbind parent
this._selectElement.style.display = "block";
this._holderElement.parentElement.removeChild(this._holderElement);
if (this.parentForm) {
this.parentForm.removeEventListener("reset", this);
}
INSTANCE_MAP.delete(this._selectElement);
}
/**
* event-polyfill compat / handleEvent is expected on class
* @link https://github.com/lifaon74/events-polyfill/issues/10
* @param {Event} event
*/
handleEvent(event) {
this._handleEvent(event);
}
/**
* @link https://gist.github.com/WebReflection/ec9f6687842aa385477c4afca625bbf4#handling-events
* @param {Event} event
*/
_handleEvent(event) {
// debounce scroll and resize
const debounced = ["scroll", "resize"];
if (debounced.includes(event.type)) {
if (this._timer) window.cancelAnimationFrame(this._timer);
this._timer = window.requestAnimationFrame(() => {
this[`on${event.type}`](event);
});
} else {
this[`on${event.type}`](event);
}
}
/**
* @param {Config|Object} config
*/
_configure(config = {}) {
this._config = Object.assign({}, DEFAULTS, {
// Hide icon by default if no value
showDropIcon: this._findOption() ? true : false,
});
const json = this._selectElement.dataset.config
? JSON.parse(this._selectElement.dataset.config)
: {};
// Handle options, using arguments first, then json config and then data attr as override
const o = { ...config, ...json, ...this._selectElement.dataset };
// Typecast provided options based on defaults types
for (const [key, defaultValue] of Object.entries(DEFAULTS)) {
// Check for undefined keys
if (key == "config" || o[key] === void 0) {
continue;
}
const value = o[key];
switch (typeof defaultValue) {
case "number":
this._config[key] = parseInt(value);
break;
case "boolean":
this._config[key] = parseBool(value);
break;
case "string":
this._config[key] = value.toString();
break;
case "object":
this._config[key] = value;
if (typeof value === "string") {
if (["{", "["].includes(value[0])) {
// JSON like string
this._config[key] = JSON.parse(value);
} else {
// CSV or pipe separated string
this._config[key] = value.split(value.includes("|") ? "|" : ",");
}
}
break;
case "function":
// Find a global function with this name
this._config[key] =
typeof value === "string"
? value.split(".").reduce((r, p) => r[p], window)
: value;
if (!this._config[key]) {
console.error("Invalid function", value);
}
break;
default:
this._config[key] = value;
break;
}
}
// Dynamic default values
if (!this._config.placeholder) {
this._config.placeholder = this._getPlaceholder();
}
if (this._config.suggestionsThreshold == -1) {
// if we don't have ajax auto completion, behave like a select by default
this._config.suggestionsThreshold = this._config.liveServer ? 1 : 0;
}
}
/**
* @param {String} k
* @returns {*}
*/
config(k = null) {
return k ? this._config[k] : this._config;
}
/**
* @param {String} k
* @param {*} v
*/
setConfig(k, v) {
this._config[k] = v;
}
// #endregion
// #region Html
/**
* Find overflow parent for positioning
* and bind reset event of the parent form
*/
_configureParent() {
this.overflowParent = null;
this.parentForm = this._selectElement.parentElement;
while (this.parentForm) {
if (this.parentForm.style.overflow === "hidden") {
this.overflowParent = this.parentForm;
}
this.parentForm = this.parentForm.parentElement;
if (this.parentForm && this.parentForm.nodeName == "FORM") {
break;
}
}
if (this.parentForm) {
this.parentForm.addEventListener("reset", this);
}
}
/**
* @returns {string}
*/
_getPlaceholder() {
// Use placeholder and data-placeholder in priority
if (this._selectElement.hasAttribute("placeholder")) {
return this._selectElement.getAttribute("placeholder");
}
if (this._selectElement.dataset.placeholder) {
return this._selectElement.dataset.placeholder;
}
// Fallback to first option if no value
let firstOption = this._selectElement.querySelector("option");
if (!firstOption || !this._config.autoselectFirst) {
return "";
}
rmAttr(firstOption, "selected");
firstOption.selected = false;
return !firstOption.value ? firstOption.textContent : "";
}
_configureSelectElement() {
const selectEl = this._selectElement;
// Hiding the select should keep it focusable, otherwise we get this
// An invalid form control with name='...' is not focusable.
// If it's not focusable, we need to remove the native validation attributes
// If we use display none, we don't get the focus event
// selectEl.style.display = "none";
// If we position it like this, the html5 validation message will not display properly
if (this._config.hideNativeValidation) {
// This position dont break render within input-group and is focusable
selectEl.style.position = "absolute";
selectEl.style.left = "-9999px";
} else {
// Hide but keep it focusable. If 0 height, no native validation message will show
// It is placed below so that native tooltip is displayed properly
// Flex basis is required for input-group otherwise it breaks the layout
selectEl.style.cssText = `height:1px;width:1px;opacity:0;padding:0;margin:0;border:0;float:left;flex-basis:100%;min-height:unset;`;
}
// Make sure it's not usable using tab
selectEl.tabIndex = -1;
// No need for custom label click event if select is focusable
// const label = document.querySelector('label[for="' + selectEl.getAttribute("id") + '"]');
// if (label) {
// label.addEventListener("click", this);
// }
// It can be focused by clicking on the label
selectEl.addEventListener("focus", (event) => {
this.onclick(event);
});
// When using regular html5 validation, make sure our fake element get the proper class
selectEl.addEventListener("invalid", (event) => {
this._holderElement.classList.add(INVALID_CLASS);
});
}
/**
* Configure drop element
* Needs to be called after searchInput is created
*/
_configureDropElement() {
const dropEl = this._dropElement;
dropEl.classList.add(...["dropdown-menu", CLASS_PREFIX + "menu"]);
dropEl.id = CLASS_PREFIX + "menu-" + counter;
dropEl.setAttribute("role", "menu");
const dropStyles = dropEl.style;
dropStyles.padding = "0"; // avoid ugly space before option
dropStyles.maxHeight = "280px";
if (!this._config.fullWidth) {
dropStyles.maxWidth = "360px";
}
if (this._config.fixed) {
dropStyles.position = "fixed";
}
dropStyles.overflowY = "auto";
// Prevent scrolling the menu from scrolling the page
// @link https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior
dropStyles.overscrollBehavior = "contain";
dropStyles.textAlign = "unset"; // otherwise RTL is not good
// If the mouse was outside, entering remove keyboard nav mode
dropEl.addEventListener("mouseenter", (event) => {
this._keyboardNavigation = false;
});
this._holderElement.appendChild(dropEl);
// include aria-controls with the value of the id of the suggested list of values.
this._searchInput.setAttribute("aria-controls", dropEl.id);
}
_configureHolderElement() {
const holder = this._holderElement;
holder.classList.add(...["form-control", "dropdown"]);
// Reflect size (we must use form-select-xx because we may use form-select) and validation
["form-select-lg", "form-select-sm", "is-invalid", "is-valid"].forEach(
(className) => {
if (this._selectElement.classList.contains(className)) {
holder.classList.add(className);
}
},
);
// It is really more like a dropdown
if (this._config.suggestionsThreshold == 0 && this._config.showDropIcon) {
holder.classList.add("form-select");
}
// If we have an overflow parent, we can simply inherit styles
if (this.overflowParent) {
holder.style.position = "inherit";
}
// Prevent fixed height due to form-control in bs4
holder.style.height = "auto";
// Without this, clicking on a floating label won't always focus properly
holder.addEventListener("click", this);
}
_configureContainerElement() {
this._containerElement.addEventListener("click", (event) => {
if (this.isDisabled()) {
return;
}
if (this._searchInput.style.visibility != "hidden") {
this._searchInput.focus();
}
});
// Add some extra css to help positioning
const containerStyles = this._containerElement.style;
containerStyles.display = "flex";
containerStyles.alignItems = "center";
containerStyles.flexWrap = "wrap";
}
_configureSearchInput() {
const searchInput = this._searchInput;
searchInput.type = "text";
searchInput.autocomplete = "off";
searchInput.spellcheck = false;
// note: firefox doesn't support the properties so we use attributes
// @link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-autocomplete
// @link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded
// use the aria-expanded state on the element with role combobox to communicate that the list is displayed.
// https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaLabel
attrs(searchInput, {
"aria-autocomplete": "list",
"aria-haspopup": "menu",
"aria-expanded": "false",
"aria-label": this._config.searchLabel,
role: "combobox",
});
searchInput.style.cssText = `background-color:transparent;color:currentColor;border:0;padding:0;outline:0;max-width:100%`;
this.resetSearchInput(true);
this._containerElement.appendChild(searchInput);
this._rtl = (searchInput.dir === "" && document.dir === "rtl") || searchInput.dir === "rtl";
}
// #endregion
// #region Events
onfocus(event) {
if (this._holderElement.classList.contains(FOCUS_CLASS)) {
return; // don't trigger multiple focus
}
this._holderElement.classList.add(FOCUS_CLASS);
this.showOrSearch();
this._config.onFocus(event, this);
}
onblur(event) {
const related = event.relatedTarget;
// Clicking on the scroll in a modal blur the element incorrectly
// In chrome >= 127, the related target is the dropdown menu
if (
this._isMouse &&
related &&
(related.classList.contains("modal") ||
related.classList.contains(CLASS_PREFIX + "menu"))
) {
// Restore focus
this._searchInput.focus();
return;
}
this.afteronblur(event);
}
/**
* This is triggered externally by a document click handler
* Scrolling in the suggestion triggers the blur event and will close the suggestion
* so we cannot rely on the blur event of the input element
* We check for click and focus events (click when clicking outside, focus when tabbing...)
* @param {Event} event
*/
afteronblur(event) {
// Cancel any pending request
if (this._abortController) {
this._abortController.abort();
}
let clearValidation = true;
if (this._config.addOnBlur && this._searchInput.value) {
clearValidation = this._enterValue();
}
this._holderElement.classList.remove(FOCUS_CLASS);
this.hideSuggestions(clearValidation);
if (this._fireEvents) {
const sel = this.getSelection();
const data = {
selection: sel ? sel.dataset.value : null,
input: this._searchInput.value,
};
this._config.onBlur(event, this);
this._selectElement.dispatchEvent(
new CustomEvent("tags.blur", { bubbles: true, detail: data }),
);
}
}
onpaste(ev) {
//@ts-ignore
const clipboardData = ev.clipboardData || window.clipboardData;
const data = clipboardData.getData("text/plain").replace(/\r\n|\n/g, " ");
// Deal with copy paste including separators
if (data.length > 2 && this._config.separator.length) {
//@ts-ignore
const splitData = splitMulti(data, this._config.separator).filter(
(n) => n,
);
if (splitData.length > 1) {
ev.preventDefault();
splitData.forEach((value) => {
this._addPastedValue(value);
});
}
}
}
_addPastedValue(value) {
let label = value;
let addData = {};
if (!this._config.allowNew) {
const sel = this.getSelection();
if (!sel) {
return;
}
value = sel.getAttribute(VALUE_ATTRIBUTE);
label = sel.dataset.label;
} else {
addData.new = 1;
}
this._config
.confirmAdd(value, this)
.then(() => {
this._add(label, value, addData);
})
.catch(() => {});
return;
}
oninput(ev) {
const data = this._config.inputFilter(this._searchInput.value);
if (data != this._searchInput.value) {
this._searchInput.value = data;
}
// Add item if a separator is used
// On mobile or copy paste, it can pass multiple chars (eg: when pressing space and it formats the string)
if (data) {
const lastChar = data.slice(-1);
if (
this._config.separator.length &&
this._config.separator.includes(lastChar)
) {
// Remove separator even if adding is prevented
this._searchInput.value = this._searchInput.value.slice(0, -1);
let value = this._searchInput.value;
this._addPastedValue(value);
return;
}
}
// Adjust input width to current content
setTimeout(() => {
this._adjustWidth();
});
// Check if we should display suggestions
this.showOrSearch();
}
/**
* keypress doesn't send arrow keys, so we use keydown
* @param {KeyboardEvent} event
*/
onkeydown(event) {
// Keycode reference : https://css-tricks.com/snippets/javascript/javascript-keycodes/
let key = event.keyCode || event.key;
/**
* @type {HTMLInputElement}
*/
// @ts-ignore
const target = event.target;
// Android virtual keyboard might always return 229
if (event.keyCode == 229) {
key = target.value.charAt(target.selectionStart - 1).charCodeAt(0);
}
// Keyboard keys
switch (key) {
case 13:
case "Enter":
event.preventDefault();
this._enterValue();
break;
case 38:
case "ArrowUp":
event.preventDefault();
this._keyboardNavigation = true;
this._moveSelection(PREV);
break;
case 40:
case "ArrowDown":
event.preventDefault();
this._keyboardNavigation = true;
if (this.isDropdownVisible()) {
this._moveSelection(NEXT);
} else {
// show menu regardless of input length
this.showOrSearch(false);
}
break;
case 8:
case "Backspace":
// If the current item is empty, remove the last one
const lastItem = this.getLastItem();
if (this._searchInput.value.length == 0 && lastItem) {
this._config
.confirmClear(lastItem, this)
.then(() => {
this.removeLastItem();
this._adjustWidth();
this.showOrSearch();
})
.catch(() => {});
}
break;
case 27:
case "Escape":
this._searchInput.focus();
this.hideSuggestions();
break;
}
}
onmousemove(e) {
this._isMouse = true;
// Moving the mouse means no longer using keyboard
this._keyboardNavigation = false;
}
onmouseleave(e) {
this._isMouse = false;
// remove selection
this.removeSelection();
}
onscroll(e) {
this._positionMenu();
}
onresize(e) {
this._positionMenu();
}
onclick(e = null) {
if (!this.isSingle() && this.isMaxReached()) {
return;
}
// Focus on input when clicking on element or focusing select
this._searchInput.focus();
}
onreset(e) {
this.reset();
}
// #endregion
/**
* @param {Boolean} init called during init
*/
loadData(init = false) {
if (Object.keys(this._config.items).length > 0) {
this.setData(this._config.items, true);
} else {
// This will setData at the end
this.resetSuggestions(true);
}
if (this._config.server) {
if (this._config.liveServer) {
// No need to load anything since it will happen when typing
// Initial values are loaded from config items or from provided options
} else {
this._loadFromServer(!init);
}
}
}
/**
* Make sure we have valid selected attributes
*/
_setSelectedAttributes() {
// we use selectedOptions because single select can have a selected option without a selected attribute if it's the first value
const selectedOptions = this._selectElement.selectedOptions || [];
for (let j = 0; j < selectedOptions.length; j++) {
// Enforce selected attr for consistency
if (
selectedOptions[j].value &&
!selectedOptions[j].hasAttribute("selected")
) {
selectedOptions[j].setAttribute("selected", "selected");
}
}
}
resetState() {
if (this.isDisabled()) {
this._holderElement.setAttribute("readonly", "");
this._searchInput.setAttribute("disabled", "");
this._holderElement.classList.add(DISABLED_CLASS);
} else {
rmAttr(this._holderElement, "readonly");
rmAttr(this._searchInput, "disabled");
this._holderElement.classList.remove(DISABLED_CLASS);
}
}
/**
* Reset suggestions from select element
* Iterates over option children then calls setData
* @param {Boolean} init called during init
*/
resetSuggestions(init = false) {
this._setSelectedAttributes();
const convertOption = (option) => {
return {
value: option.getAttribute("value"),
label: option.textContent,
disabled: option.disabled,
//@ts-ignore
selected: option.selected,
title: option.title,
data: Object.assign(
{
disabled: option.disabled, // pass as data as well
},
option.dataset,
),
};
};
let suggestions = Array.from(this._selectElement.children)
.filter(
/**
* @param {HTMLOptionElement|HTMLOptGroupElement} option
*/
(option) => {
return (
option.hasAttribute("label") ||
!option.disabled ||
this._config.showDisabled
);
},
)
.map(
/**
* @param {HTMLOptionElement|HTMLOptGroupElement} option
*/
(option) => {
if (option.hasAttribute("label")) {
return {
group: option.getAttribute("label"),
items: Array.from(option.children).map((option) => {
return convertOption(option);
}),
};
}
return convertOption(option);
},
);
this.setData(suggestions, init);
}
/**
* Try to add the current value
* @returns {Boolean}
*/
_enterValue() {
let selection = this.getSelection();
if (selection) {
selection.click();
return true;
} else {
// We use what is typed if not selected and not empty
if (this._config.allowNew && this._searchInput.value) {
let text = this._searchInput.value;
this._config
.confirmAdd(text, this)
.then(() => {
this._add(text, text, { new: 1 });
})
.catch(() => {});
return true;
}
}
return false;
}
/**
* @param {Boolean} show Show menu after load. False during init
*/
_loadFromServer(show = false) {
if (this._abortController) {
this._abortController.abort();
}
this._abortController = new AbortController();
// Read data params dynamically as well (eg: for vue JS)
let extraParams = this._selectElement.dataset.serverParams || {};
if (typeof extraParams == "string") {
extraParams = JSON.parse(extraParams);
}
const params = Object.assign({}, this._config.serverParams, extraParams);
// Pass current value
params[this._config.queryParam] = this._searchInput.value;
// Prevent caching
if (this._config.noCache) {
params.t = Date.now();
}
// We have a related field
if (params.related) {
/**
* @type {HTMLInputElement}
*/
//@ts-ignore
const input = document.getElementById(params.related);
if (input) {
params.related = input.value;
const inputName = input.getAttribute("name");
if (inputName) {
params[inputName] = input.value;
}
}
}
const urlParams = new URLSearchParams(params);
let url = this._config.server;
let fetchOptions = Object.assign(this._config.fetchOptions, {
method: this._config.serverMethod || "GET",
signal: this._abortController.signal,
});
if (fetchOptions.method === "POST") {
fetchOptions.body = urlParams;
} else {
url += "?" + urlParams.toString();
}
this._holderElement.classList.add(LOADING_CLASS);
fetch(url, fetchOptions)
.then((r) => this._config.onServerResponse(r, this))
.then((suggestions) => {
const data =
nested(this._config.serverDataKey, suggestions) || suggestions;
this.setData(data, !show);
this._abortController = null;
if (show) {
this._showSuggestions();
}
})
.catch((e) => {
this._config.onServerError(e, this._abortController.signal, this);
})
.finally((e) => {
this._holderElement.classList.remove(LOADING_CLASS);
});
}
/**
* Wrapper for the public addItem method that check if the item
* can be added
*
* @param {string} text
* @param {string} value
* @param {object} data
* @returns {HTMLOptionElement|null}
*/
_add(text, value = null, data = {}) {
// Pass along value in data for canAdd()
if (!data.value && value) {
data.value = value;
}
if (!this.canAdd(text, data)) {
return null;
}
const el = this.addItem(text, value, data);
this._resetHtmlState();
if (this._config.keepOpen) {
this._showSuggestions();
} else {
this.resetSearchInput();
}
return el;
}
/**
* @param {HTMLElement} li
* @returns {Boolean}
*/
_isItemEnabled(li) {
if (li.style.display === "none") {
return false;
}
const fc = li.firstElementChild;
return fc.tagName === "A" && !fc.classList.contains("disabled");
}
/**
* @param {String} dir
* @param {*|HTMLElement} sel
* @returns {HTMLElement}
*/
_moveSelection(dir = NEXT, sel = null) {
const active = this.getSelection();
// select first li if visible
if (!active) {
// no active selection, cannot go back
if (dir === PREV) {
return sel;
}
// find first enabled item
if (!sel) {
sel = this._dropElement.firstChild;
while (sel && !this._isItemEnabled(sel)) {
sel = sel["nextSibling"];
}
}
} else {
const sibling = dir === NEXT ? "nextSibling" : "previousSibling";
// Iterate over visible li
sel = active.parentNode;
do {
sel = sel[sibling];
} while (sel && !this._isItemEnabled(sel));
// We have a new selection
if (sel) {
// Remove classes from current active
active.classList.remove(...this._activeClasses());
} else if (active) {
// Use active element as selection
sel = active.parentElement;
}
}
if (sel) {
// Scroll if necessary
const selHeight = sel.offsetHeight;
const selTop = sel.offsetTop;
const parent = sel.parentNode;
const parentHeight = parent.offsetHeight;
const parentScrollHeight = parent.scrollHeight;
const parentTop = parent.offsetTop;
// Reset scroll, this can happen if menu was scrolled then hidden
if (selHeight === 0) {
setTimeout(() => {
parent.scrollTop = 0;
});
}
if (dir === PREV) {
// Don't use scrollIntoView as it scrolls the whole window
// Avoid minor top scroll due to headers
const scrollTop = selTop - parentTop > 10 ? selTop - parentTop : 0;
parent.scrollTop = scrollTop;
} else {
// This is the equivalent of scrollIntoView(false) but only for parent node
// Only scroll if the element is not visible
const scrollNeeded =
selTop + selHeight - (parentHeight + parent.scrollTop);
if (scrollNeeded > 0 && selHeight > 0) {
parent.scrollTop = selTop + selHeight - parentHeight + 1;
// On last element, make sure we scroll the the bottom
if (parent.scrollTop + parentHeight >= parentScrollHeight - 10) {
parent.scrollTop = selTop - parentTop;
}
}
}
// Adjust link
const a = sel.querySelector("a");
a.classList.add(...this._activeClasses());
this._searchInput.setAttribute("aria-activedescendant", a.id);
if (this._config.updateOnSelect) {
this._searchInput.value = a.dataset.label;
this._adjustWidth();
}
} else {
this._searchInput.setAttribute("aria-activedescendant", "");
}
return sel;
}
/**
* Adjust the field to fit its content and show/hide placeholder if needed
*/
_adjustWidth() {
this._holderElement.classList.remove(PLACEHOLDER_CLASS);
if (this._searchInput.value) {
this._searchInput.size = this._searchInput.value.length;
} else {
// Show the placeholder only if empty
if (this.getSelectedValues().length) {
this._searchInput.placeholder = "";
this._searchInput.size = 1;
} else {
this._searchInput.size =
this._config.placeholder.length > 0
? this._config.placeholder.length
: 1;
this._searchInput.placeholder = this._config.placeholder;
this._holderElement.classList.add(PLACEHOLDER_CLASS);
}
}
// If the string contains ascii chars or strange font, input size may be wrong
// We cannot only rely on the size attribute
const v = this._searchInput.value || this._searchInput.placeholder;
if (v.length > 0) {
const computedFontSize = window.getComputedStyle(
this._holderElement,
).fontSize;
const w = calcTextWidth(v, computedFontSize) + 16;
this._searchInput.style.width = `${w}px`; // Don't use minWidth as it would prevent using maxWidth
}
}
/**
* Add suggestions to the drop element
* @param {Array<Suggestion|SuggestionGroup>} suggestions
*/
_buildSuggestions(suggestions) {
while (this._dropElement.lastChild) {
this._dropElement.removeChild(this._dropElement.lastChild);
}
let idx = 0;
let groupId = 1; // start at one, because data-id = "" + 0 doesn't do anything
for (let i = 0; i < suggestions.length; i++) {
const suggestion = suggestions[i];
if (!suggestion) {
continue;
}
// Handle optgroups
if (suggestion["group"] && suggestion["items"]) {
const newChild = ce("li");
newChild.setAttribute("role", "presentation");
newChild.dataset.id = "" + groupId;
const newChildSpan = ce("span");
newChild.append(newChildSpan);
newChildSpan.classList.add(...["dropdown-header", "text-truncate"]);
newChildSpan.innerHTML = this._config.sanitizer(suggestion["group"]);
this._dropElement.appendChild(newChild);
if (suggestion["items"]) {
for (let j = 0; j < suggestion["items"].length; j++) {
const groupSuggestion = suggestion["items"][j];
groupSuggestion.group_id = groupId;
this._buildSuggestionsItem(suggestion["items"][j], idx);
idx++;
}
}
groupId++;
}
//@ts-ignore
this._buildSuggestionsItem(suggestion, idx);
idx++;
}
// Create the not found message
if (this._config.notFoundMessage) {
const notFound = ce("li");
notFound.setAttribute("role", "presentation");
notFound.classList.add(CLASS_PREFIX + "not-found");
// Actual message is refreshed on typing, but we need item for consistency
notFound.innerHTML = `<span class="dropdown-item"></span>`;
this._dropElement.appendChild(notFound);
}
}
/**
* @param {Suggestion} suggestion
* @param {Number} i The global counter
*/
_buildSuggestionsItem(suggestion, i) {
if (!suggestion[this._config.valueField]) {
return;
}
const value = suggestion[this._config.valueField];
const label = suggestion[this._config.labelField];
let textContent = this._config.onRenderItem(suggestion, label, this);
const newChild = ce("li");
// role must be menuitem when used with menu
// see https://github.com/lekoala/bootstrap5-tags/issues/114
newChild.setAttribute("role", "menuitem");
if (suggestion.group_id) {
newChild.setAttribute("data-group-id", "" + suggestion.group_id);
}
if (suggestion.title) {
newChild.setAttribute("title", suggestion.title);
newChild.setAttribute("data-bs-placement", "left");
}
const newChildLink = ce("a");
newChild.append(newChildLink);
newChildLink.id = this._dropElement.id + "-" + i;
newChildLink.classList.add(...["dropdown-item", "text-truncate"]);
if (suggestion.disabled) {
newChildLink.classList.add(...["disabled"]);
}
newChildLink.setAttribute(VALUE_ATTRIBUTE, value);
newChildLink.dataset.label = label;
const searchData = {};
this._config.searchFields.forEach((sf) => {
searchData[sf] = suggestion[sf];
});
newChildLink.dataset.searchData = JSON.stringify(searchData);
newChildLink.setAttribute("href", "#");
// sanitized if needed by onRenderItem
newChildLink.innerHTML = textContent;
this._dropElement.appendChild(newChild);
// tooltips
const v5 = this._getBootstrapVersion() === 5;
if (suggestion.title && tooltip && v5) {
tooltip.getOrCreateInstance(newChild);
}
// Hover sets active item
newChildLink.addEventListener("mouseenter", (event) => {
// Don't trigger enter if using arrows
if (this._keyboardNavigation) {
return;
}
this.removeSelection();
newChild.querySelector("a").classList.add(...this._activeClasses());
});
newChildLink.addEventListener("mousedown", (event) => {
// Otherwise searchInput would lose focus and close the menu
event.preventDefault();
});
newChildLink.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this._config
.confirmAdd(value, this)
.then(() => {
this._add(label, value, suggestion.data);
this._config.onSelectItem(suggestion, this);
})
.catch(() => {});
});
}
/**
* @returns {NodeListOf<HTMLOptionElement>}
*/
initialOptions() {
return this._selectElement.querySelectorAll("option[data-init]");
}
/**
* Call this before looping in a list that calls addItem
* This will make sure addItem will not add incorrectly options to the select
*/
_removeSelectedAttrs() {
this._selectElement.querySelectorAll("option").forEach((opt) => {
rmAttr(opt, "selected");
});
}
reset() {
this.removeAll();
// Reset doesn't fire change event
this._fireEvents = false;
const opts = this.initialOptions();
this._removeSelectedAttrs();
for (let j = 0; j < opts.length; j++) {
const iv = opts[j];
const data = Object.assign(
{},
{
disabled: iv.hasAttribute("disabled"),
},
iv.dataset,
);
this.addItem(iv.textContent, iv.value, data);
}
this._resetHtmlState();
this._fireEvents = true;
}
/**
* @param {Boolean} init Pass true during init
*/
resetSearchInput(init = false) {
this._searchInput.value = "";
this._adjustWidth();
this._checkMax();
// Single select is a special case
if (this.isSingle() && !init) {
//@ts-ignore
document.activeElement.blur();
this.hideSuggestions();
return;
}
// Extra things to do when not during init
if (!init) {
if (!this._shouldShow()) {
this.hideSuggestions();
}
// Trigger input even to show suggestions if needed when focused
if (this._searchInput === document.activeElement) {
this._searchInput.dispatchEvent(new Event("input"));
}
}
}
/**
* We use visibility instead of display to keep layout intact
*/
_checkMax() {
if (this.isMaxReached()) {
this._holderElement.classList.add(MAX_REACHED_CLASS);
this._searchInput.style.visibility = "hidden";
} else {
if (this._searchInput.style.visibility == "hidden") {
this._searchInput.style.visibility = "visible";
}
}
}
/**
* @returns {Array}
*/
getSelectedValues() {
// option[selected] is used rather that selectedOptions as it works more consistently
/**
* @type {NodeListOf<HTMLOptionElement>}
*/
const selected = this._selectElement.querySelectorAll("option[selected]");
return Array.from(selected).map((el) => el.value);
}
/**
* @returns {Array}
*/
getAvailableValues() {
/**
* @type {NodeListOf<HTMLOptionElement>}
*/
const selected = this._selectElement.querySelectorAll("option");
return Array.from(selected).map((el) => el.value);
}
/**
* Show suggestions or search them depending on live server
* @param {Boolean} check
*/
showOrSearch(check = true) {
if (check && !this._shouldShow()) {
// focusing should not clear validation
this.hideSuggestions(false);
return;
}
if (this._config.liveServer) {
this._searchFunc();
} else {
this._showSuggestions();
}
}
/**
* The element create with buildSuggestions
* @param {Boolean} clearValidation
*/
hideSuggestions(clearValidation = true) {
this._dropElement.classList.remove(SHOW_CLASS);
attrs(this._searchInput, {
"aria-expanded": "false",
});
this.removeSelection();
if (clearValidation) {
this._holderElement.classList.remove(INVALID_CLASS);
}
}
/**
* Show or hide suggestions
* @param {Boolean} check Show suggestions regardless if shouldShow conditions
* @param {Boolean} clearValidation
*/
toggleSuggestions(check = true, clearValidation = true) {
if (this._dropElement.classList.contains(SHOW_CLASS)) {
this.hideSuggestions(clearValidation);
} else {
this.showOrSearch(check);
}
}
/**
* Do we have enough input to show suggestions ?
* @returns {Boolean}
*/
_shouldShow() {
if (this.isDisabled() || this.isMaxReached()) {
return false;
}
return this._searchInput.value.length >= this._config.suggestionsThreshold;
}
/**
* The element create with buildSuggestions
*/
_showSuggestions() {
// Never show suggestions if you cannot add new values
if (this._searchInput.style.visibility == "hidden") {
return;
}
const lookup = normalize(this._searchInput.value);
const valueCounter = {};
// Filter the list according to search string
const list = this._dropElement.querySelectorAll("li");
let count = 0;
let firstItem = null;
let hasPossibleValues = false;
let visibleGroups = {};
for (let i = 0; i < list.length; i++) {
/**
* @type {HTMLLIElement}
*/
let item = list[i];
/**
* @type {HTMLAnchorElement|HTMLSpanElement}
*/
//@ts-ignore
let link = item.firstElementChild;
// This is the empty result message or a header
if (link instanceof HTMLSpanElement) {
// We will show it later
if (item.dataset.id) {
visibleGroups[item.dataset.id] = false;
}
hideItem(item);
continue;
}
// Remove previous selection
link.classList.remove(...this._activeClasses());
// Hide selected values
if (!this._config.allowSame) {
const v = link.getAttribute(VALUE_ATTRIBUTE);
// Find if the matching option is already selected by index to deal with same values
valueCounter[v] = valueCounter[v] || 0;
const opt = this._findOption(
link.getAttribute(VALUE_ATTRIBUTE),
"[selected]",
valueCounter[v]++,
);
if (opt) {
hideItem(item);
cont