tom-select
Version:
Tom Select is a versatile and dynamic <select> UI control. Forked from Selectize.js to provide a framework agnostic autocomplete widget with native-feeling keyboard navigation, it's useful for tagging, contact lists, country selectors, etc.
1,435 lines • 77.8 kB
JavaScript
import MicroEvent from "./contrib/microevent.js";
import MicroPlugin from "./contrib/microplugin.js";
import { Sifter } from '@orchidjs/sifter';
import { escape_regex } from '@orchidjs/unicode-variants';
import { highlight, removeHighlight } from "./contrib/highlight.js";
import * as constants from "./constants.js";
import getSettings from "./getSettings.js";
import { hash_key, get_hash, escape_html, debounce_events, getSelection, preventDefault, addEvent, loadDebounce, timeout, isKeyDown, getId, addSlashes, append, iterate } from "./utils.js";
import { getDom, isHtmlString, escapeQuery, triggerEvent, applyCSS, addClasses, removeClasses, parentMatch, getTail, isEmptyObject, nodeIndex, setAttr, replaceNode } from "./vanilla.js";
var instance_i = 0;
export default class TomSelect extends MicroPlugin(MicroEvent) {
constructor(input_arg, user_settings) {
super();
this.order = 0;
this.isOpen = false;
this.isDisabled = false;
this.isReadOnly = false;
this.isInvalid = false; // @deprecated 1.8
this.isValid = true;
this.isLocked = false;
this.isFocused = false;
this.isInputHidden = false;
this.isSetup = false;
this.ignoreFocus = false;
this.ignoreHover = false;
this.hasOptions = false;
this.lastValue = '';
this.caretPos = 0;
this.loading = 0;
this.loadedSearches = {};
this.activeOption = null;
this.activeItems = [];
this.optgroups = {};
this.options = {};
this.userOptions = {};
this.items = [];
this.refreshTimeout = null;
instance_i++;
var dir;
var input = getDom(input_arg);
if (input.tomselect) {
throw new Error('Tom Select already initialized on this element');
}
input.tomselect = this;
// detect rtl environment
var computedStyle = window.getComputedStyle && window.getComputedStyle(input, null);
dir = computedStyle.getPropertyValue('direction');
// setup default state
const settings = getSettings(input, user_settings);
this.settings = settings;
this.input = input;
this.tabIndex = input.tabIndex || 0;
this.is_select_tag = input.tagName.toLowerCase() === 'select';
this.rtl = /rtl/i.test(dir);
this.inputId = getId(input, 'tomselect-' + instance_i);
this.isRequired = input.required;
// search system
this.sifter = new Sifter(this.options, { diacritics: settings.diacritics });
// option-dependent defaults
settings.mode = settings.mode || (settings.maxItems === 1 ? 'single' : 'multi');
if (typeof settings.hideSelected !== 'boolean') {
settings.hideSelected = settings.mode === 'multi';
}
if (typeof settings.hidePlaceholder !== 'boolean') {
settings.hidePlaceholder = settings.mode !== 'multi';
}
// set up createFilter callback
var filter = settings.createFilter;
if (typeof filter !== 'function') {
if (typeof filter === 'string') {
filter = new RegExp(filter);
}
if (filter instanceof RegExp) {
settings.createFilter = (input) => filter.test(input);
}
else {
settings.createFilter = (value) => {
return this.settings.duplicates || !this.options[value];
};
}
}
this.initializePlugins(settings.plugins);
this.setupCallbacks();
this.setupTemplates();
// Create all elements
const wrapper = getDom('<div>');
const control = getDom('<div>');
const dropdown = this._render('dropdown');
const dropdown_content = getDom(`<div role="listbox" tabindex="-1">`);
const classes = this.input.getAttribute('class') || '';
const inputMode = settings.mode;
var control_input;
addClasses(wrapper, settings.wrapperClass, classes, inputMode);
addClasses(control, settings.controlClass);
append(wrapper, control);
addClasses(dropdown, settings.dropdownClass, inputMode);
if (settings.copyClassesToDropdown) {
addClasses(dropdown, classes);
}
addClasses(dropdown_content, settings.dropdownContentClass);
append(dropdown, dropdown_content);
getDom(settings.dropdownParent || wrapper).appendChild(dropdown);
// default controlInput
if (isHtmlString(settings.controlInput)) {
control_input = getDom(settings.controlInput);
// set attributes
var attrs = ['autocorrect', 'autocapitalize', 'autocomplete', 'spellcheck'];
iterate(attrs, (attr) => {
if (input.getAttribute(attr)) {
setAttr(control_input, { [attr]: input.getAttribute(attr) });
}
});
control_input.tabIndex = -1;
control.appendChild(control_input);
this.focus_node = control_input;
// dom element
}
else if (settings.controlInput) {
control_input = getDom(settings.controlInput);
this.focus_node = control_input;
}
else {
control_input = getDom('<input/>');
this.focus_node = control;
}
this.wrapper = wrapper;
this.dropdown = dropdown;
this.dropdown_content = dropdown_content;
this.control = control;
this.control_input = control_input;
this.setup();
}
/**
* set up event bindings.
*
*/
setup() {
const self = this;
const settings = self.settings;
const control_input = self.control_input;
const dropdown = self.dropdown;
const dropdown_content = self.dropdown_content;
const wrapper = self.wrapper;
const control = self.control;
const input = self.input;
const focus_node = self.focus_node;
const passive_event = { passive: true };
const listboxId = self.inputId + '-ts-dropdown';
setAttr(dropdown_content, {
id: listboxId
});
setAttr(focus_node, {
role: 'combobox',
'aria-haspopup': 'listbox',
'aria-expanded': 'false',
'aria-controls': listboxId
});
const control_id = getId(focus_node, self.inputId + '-ts-control');
const query = "label[for='" + escapeQuery(self.inputId) + "']";
const label = document.querySelector(query);
const label_click = self.focus.bind(self);
if (label) {
addEvent(label, 'click', label_click);
setAttr(label, { for: control_id });
const label_id = getId(label, self.inputId + '-ts-label');
setAttr(focus_node, { 'aria-labelledby': label_id });
setAttr(dropdown_content, { 'aria-labelledby': label_id });
}
wrapper.style.width = input.style.width;
if (self.plugins.names.length) {
const classes_plugins = 'plugin-' + self.plugins.names.join(' plugin-');
addClasses([wrapper, dropdown], classes_plugins);
}
if ((settings.maxItems === null || settings.maxItems > 1) && self.is_select_tag) {
setAttr(input, { multiple: 'multiple' });
}
if (settings.placeholder) {
setAttr(control_input, { placeholder: settings.placeholder });
}
// if splitOn was not passed in, construct it from the delimiter to allow pasting universally
if (!settings.splitOn && settings.delimiter) {
settings.splitOn = new RegExp('\\s*' + escape_regex(settings.delimiter) + '+\\s*');
}
// debounce user defined load() if loadThrottle > 0
// after initializePlugins() so plugins can create/modify user defined loaders
if (settings.load && settings.loadThrottle) {
settings.load = loadDebounce(settings.load, settings.loadThrottle);
}
addEvent(dropdown, 'mousemove', () => {
self.ignoreHover = false;
});
addEvent(dropdown, 'mouseenter', (e) => {
var target_match = parentMatch(e.target, '[data-selectable]', dropdown);
if (target_match)
self.onOptionHover(e, target_match);
}, { capture: true });
// clicking on an option should select it
addEvent(dropdown, 'click', (evt) => {
const option = parentMatch(evt.target, '[data-selectable]');
if (option) {
self.onOptionSelect(evt, option);
preventDefault(evt, true);
}
});
addEvent(control, 'click', (evt) => {
var target_match = parentMatch(evt.target, '[data-ts-item]', control);
if (target_match && self.onItemSelect(evt, target_match)) {
preventDefault(evt, true);
return;
}
// retain focus (see control_input mousedown)
if (control_input.value != '') {
return;
}
self.onClick();
preventDefault(evt, true);
});
// keydown on focus_node for arrow_down/arrow_up
addEvent(focus_node, 'keydown', (e) => self.onKeyDown(e));
// keypress and input/keyup
addEvent(control_input, 'keypress', (e) => self.onKeyPress(e));
addEvent(control_input, 'input', (e) => self.onInput(e));
addEvent(focus_node, 'blur', (e) => self.onBlur(e));
addEvent(focus_node, 'focus', (e) => self.onFocus(e));
addEvent(control_input, 'paste', (e) => self.onPaste(e));
const doc_mousedown = (evt) => {
// blur if target is outside of this instance
// dropdown is not always inside wrapper
const target = evt.composedPath()[0];
if (!wrapper.contains(target) && !dropdown.contains(target)) {
if (self.isFocused) {
self.blur();
}
self.inputState();
return;
}
// retain focus by preventing native handling. if the
// event target is the input it should not be modified.
// otherwise, text selection within the input won't work.
// Fixes bug #212 which is no covered by tests
if (target == control_input && self.isOpen) {
evt.stopPropagation();
// clicking anywhere in the control should not blur the control_input (which would close the dropdown)
}
else {
preventDefault(evt, true);
}
};
const win_scroll = () => {
if (self.isOpen) {
self.positionDropdown();
}
};
addEvent(document, 'mousedown', doc_mousedown);
addEvent(window, 'scroll', win_scroll, passive_event);
addEvent(window, 'resize', win_scroll, passive_event);
this._destroy = () => {
document.removeEventListener('mousedown', doc_mousedown);
window.removeEventListener('scroll', win_scroll);
window.removeEventListener('resize', win_scroll);
if (label)
label.removeEventListener('click', label_click);
};
// store original html and tab index so that they can be
// restored when the destroy() method is called.
this.revertSettings = {
innerHTML: input.innerHTML,
tabIndex: input.tabIndex
};
input.tabIndex = -1;
input.insertAdjacentElement('afterend', self.wrapper);
self.sync(false);
settings.items = [];
delete settings.optgroups;
delete settings.options;
addEvent(input, 'invalid', () => {
if (self.isValid) {
self.isValid = false;
self.isInvalid = true;
self.refreshState();
}
});
self.updateOriginalInput();
self.refreshItems();
self.close(false);
self.inputState();
self.isSetup = true;
if (input.disabled) {
self.disable();
}
else if (input.readOnly) {
self.setReadOnly(true);
}
else {
self.enable(); //sets tabIndex
}
self.on('change', this.onChange);
addClasses(input, 'tomselected', 'ts-hidden-accessible');
self.trigger('initialize');
// preload options
if (settings.preload === true) {
self.preload();
}
}
/**
* Register options and optgroups
*
*/
setupOptions(options = [], optgroups = []) {
// build options table
this.addOptions(options);
// build optgroup table
iterate(optgroups, (optgroup) => {
this.registerOptionGroup(optgroup);
});
}
/**
* Sets up default rendering functions.
*/
setupTemplates() {
var self = this;
var field_label = self.settings.labelField;
var field_optgroup = self.settings.optgroupLabelField;
var templates = {
'optgroup': (data) => {
let optgroup = document.createElement('div');
optgroup.className = 'optgroup';
optgroup.appendChild(data.options);
return optgroup;
},
'optgroup_header': (data, escape) => {
return '<div class="optgroup-header">' + escape(data[field_optgroup]) + '</div>';
},
'option': (data, escape) => {
return '<div>' + escape(data[field_label]) + '</div>';
},
'item': (data, escape) => {
return '<div>' + escape(data[field_label]) + '</div>';
},
'option_create': (data, escape) => {
return '<div class="create">Add <strong>' + escape(data.input) + '</strong>…</div>';
},
'no_results': () => {
return '<div class="no-results">No results found</div>';
},
'loading': () => {
return '<div class="spinner"></div>';
},
'not_loading': () => { },
'dropdown': () => {
return '<div></div>';
}
};
self.settings.render = Object.assign({}, templates, self.settings.render);
}
/**
* Maps fired events to callbacks provided
* in the settings used when creating the control.
*/
setupCallbacks() {
var key, fn;
var callbacks = {
'initialize': 'onInitialize',
'change': 'onChange',
'item_add': 'onItemAdd',
'item_remove': 'onItemRemove',
'item_select': 'onItemSelect',
'clear': 'onClear',
'option_add': 'onOptionAdd',
'option_remove': 'onOptionRemove',
'option_clear': 'onOptionClear',
'optgroup_add': 'onOptionGroupAdd',
'optgroup_remove': 'onOptionGroupRemove',
'optgroup_clear': 'onOptionGroupClear',
'dropdown_open': 'onDropdownOpen',
'dropdown_close': 'onDropdownClose',
'type': 'onType',
'load': 'onLoad',
'focus': 'onFocus',
'blur': 'onBlur'
};
for (key in callbacks) {
fn = this.settings[callbacks[key]];
if (fn)
this.on(key, fn);
}
}
/**
* Sync the Tom Select instance with the original input or select
*
*/
sync(get_settings = true) {
const self = this;
const settings = get_settings ? getSettings(self.input, { delimiter: self.settings.delimiter }) : self.settings;
self.setupOptions(settings.options, settings.optgroups);
self.setValue(settings.items || [], true); // silent prevents recursion
self.lastQuery = null; // so updated options will be displayed in dropdown
}
/**
* Triggered when the main control element
* has a click event.
*
*/
onClick() {
var self = this;
if (self.activeItems.length > 0) {
self.clearActiveItems();
self.focus();
return;
}
if (self.isFocused && self.isOpen) {
self.blur();
}
else {
self.focus();
}
}
/**
* @deprecated v1.7
*
*/
onMouseDown() { }
/**
* Triggered when the value of the control has been changed.
* This should propagate the event to the original DOM
* input / select element.
*/
onChange() {
triggerEvent(this.input, 'input');
triggerEvent(this.input, 'change');
}
/**
* Triggered on <input> paste.
*
*/
onPaste(e) {
var self = this;
if (self.isInputHidden || self.isLocked) {
preventDefault(e);
return;
}
// If a regex or string is included, this will split the pasted
// input and create Items for each separate value
if (!self.settings.splitOn) {
return;
}
// Wait for pasted text to be recognized in value
setTimeout(() => {
var pastedText = self.inputValue();
if (!pastedText.match(self.settings.splitOn)) {
return;
}
var splitInput = pastedText.trim().split(self.settings.splitOn);
iterate(splitInput, (piece) => {
const hash = hash_key(piece);
if (hash) {
if (this.options[piece]) {
self.addItem(piece);
}
else {
self.createItem(piece);
}
}
});
}, 0);
}
/**
* Triggered on <input> keypress.
*
*/
onKeyPress(e) {
var self = this;
if (self.isLocked) {
preventDefault(e);
return;
}
var character = String.fromCharCode(e.keyCode || e.which);
if (self.settings.create && self.settings.mode === 'multi' && character === self.settings.delimiter) {
self.createItem();
preventDefault(e);
return;
}
}
/**
* Triggered on <input> keydown.
*
*/
onKeyDown(e) {
var self = this;
self.ignoreHover = true;
if (self.isLocked) {
if (e.keyCode !== constants.KEY_TAB) {
preventDefault(e);
}
return;
}
switch (e.keyCode) {
// ctrl+A: select all
case constants.KEY_A:
if (isKeyDown(constants.KEY_SHORTCUT, e)) {
if (self.control_input.value == '') {
preventDefault(e);
self.selectAll();
return;
}
}
break;
// esc: close dropdown
case constants.KEY_ESC:
if (self.isOpen) {
preventDefault(e, true);
self.close();
}
self.clearActiveItems();
return;
// down: open dropdown or move selection down
case constants.KEY_DOWN:
if (!self.isOpen && self.hasOptions) {
self.open();
}
else if (self.activeOption) {
let next = self.getAdjacent(self.activeOption, 1);
if (next)
self.setActiveOption(next);
}
preventDefault(e);
return;
// up: move selection up
case constants.KEY_UP:
if (self.activeOption) {
let prev = self.getAdjacent(self.activeOption, -1);
if (prev)
self.setActiveOption(prev);
}
preventDefault(e);
return;
// return: select active option
case constants.KEY_RETURN:
if (self.canSelect(self.activeOption)) {
self.onOptionSelect(e, self.activeOption);
preventDefault(e);
// if the option_create=null, the dropdown might be closed
}
else if (self.settings.create && self.createItem()) {
preventDefault(e);
// don't submit form when searching for a value
}
else if (document.activeElement == self.control_input && self.isOpen) {
preventDefault(e);
}
return;
// left: modifiy item selection to the left
case constants.KEY_LEFT:
self.advanceSelection(-1, e);
return;
// right: modifiy item selection to the right
case constants.KEY_RIGHT:
self.advanceSelection(1, e);
return;
// tab: select active option and/or create item
case constants.KEY_TAB:
if (self.settings.selectOnTab) {
if (self.canSelect(self.activeOption)) {
self.onOptionSelect(e, self.activeOption);
// prevent default [tab] behaviour of jump to the next field
// if select isFull, then the dropdown won't be open and [tab] will work normally
preventDefault(e);
}
if (self.settings.create && self.createItem()) {
preventDefault(e);
}
}
return;
// delete|backspace: delete items
case constants.KEY_BACKSPACE:
case constants.KEY_DELETE:
self.deleteSelection(e);
return;
}
// don't enter text in the control_input when active items are selected
if (self.isInputHidden && !isKeyDown(constants.KEY_SHORTCUT, e)) {
preventDefault(e);
}
}
/**
* Triggered on <input> keyup.
*
*/
onInput(e) {
if (this.isLocked) {
return;
}
const value = this.inputValue();
if (this.lastValue === value)
return;
this.lastValue = value;
if (value == '') {
this._onInput();
return;
}
if (this.refreshTimeout) {
window.clearTimeout(this.refreshTimeout);
}
this.refreshTimeout = timeout(() => {
this.refreshTimeout = null;
this._onInput();
}, this.settings.refreshThrottle);
}
_onInput() {
const value = this.lastValue;
if (this.settings.shouldLoad.call(this, value)) {
this.load(value);
}
this.refreshOptions();
this.trigger('type', value);
}
/**
* Triggered when the user rolls over
* an option in the autocomplete dropdown menu.
*
*/
onOptionHover(evt, option) {
if (this.ignoreHover)
return;
this.setActiveOption(option, false);
}
/**
* Triggered on <input> focus.
*
*/
onFocus(e) {
var self = this;
var wasFocused = self.isFocused;
if (self.isDisabled || self.isReadOnly) {
self.blur();
preventDefault(e);
return;
}
if (self.ignoreFocus)
return;
self.isFocused = true;
if (self.settings.preload === 'focus')
self.preload();
if (!wasFocused)
self.trigger('focus');
if (!self.activeItems.length) {
self.inputState();
self.refreshOptions(!!self.settings.openOnFocus);
}
self.refreshState();
}
/**
* Triggered on <input> blur.
*
*/
onBlur(e) {
if (document.hasFocus() === false)
return;
var self = this;
if (!self.isFocused)
return;
self.isFocused = false;
self.ignoreFocus = false;
var deactivate = () => {
self.close();
self.setActiveItem();
self.setCaret(self.items.length);
self.trigger('blur');
};
if (self.settings.create && self.settings.createOnBlur) {
self.createItem(null, deactivate);
}
else {
deactivate();
}
}
/**
* Triggered when the user clicks on an option
* in the autocomplete dropdown menu.
*
*/
onOptionSelect(evt, option) {
var value, self = this;
// should not be possible to trigger a option under a disabled optgroup
if (option.parentElement && option.parentElement.matches('[data-disabled]')) {
return;
}
if (option.classList.contains('create')) {
self.createItem(null, () => {
if (self.settings.closeAfterSelect) {
self.close();
}
});
}
else {
value = option.dataset.value;
if (typeof value !== 'undefined') {
self.lastQuery = null;
self.addItem(value);
if (self.settings.closeAfterSelect) {
self.close();
}
if (!self.settings.hideSelected && evt.type && /click/.test(evt.type)) {
self.setActiveOption(option);
}
}
}
}
/**
* Return true if the given option can be selected
*
*/
canSelect(option) {
if (this.isOpen && option && this.dropdown_content.contains(option)) {
return true;
}
return false;
}
/**
* Triggered when the user clicks on an item
* that has been selected.
*
*/
onItemSelect(evt, item) {
var self = this;
if (!self.isLocked && self.settings.mode === 'multi') {
preventDefault(evt);
self.setActiveItem(item, evt);
return true;
}
return false;
}
/**
* Determines whether or not to invoke
* the user-provided option provider / loader
*
* Note, there is a subtle difference between
* this.canLoad() and this.settings.shouldLoad();
*
* - settings.shouldLoad() is a user-input validator.
* When false is returned, the not_loading template
* will be added to the dropdown
*
* - canLoad() is lower level validator that checks
* the Tom Select instance. There is no inherent user
* feedback when canLoad returns false
*
*/
canLoad(value) {
if (!this.settings.load)
return false;
if (this.loadedSearches.hasOwnProperty(value))
return false;
return true;
}
/**
* Invokes the user-provided option provider / loader.
*
*/
load(value) {
const self = this;
if (!self.canLoad(value))
return;
addClasses(self.wrapper, self.settings.loadingClass);
self.loading++;
const callback = self.loadCallback.bind(self);
self.settings.load.call(self, value, callback);
}
/**
* Invoked by the user-provided option provider
*
*/
loadCallback(options, optgroups) {
const self = this;
self.loading = Math.max(self.loading - 1, 0);
self.lastQuery = null;
self.clearActiveOption(); // when new results load, focus should be on first option
self.setupOptions(options, optgroups);
self.refreshOptions(self.isFocused && !self.isInputHidden);
if (!self.loading) {
removeClasses(self.wrapper, self.settings.loadingClass);
}
self.trigger('load', options, optgroups);
}
preload() {
var classList = this.wrapper.classList;
if (classList.contains('preloaded'))
return;
classList.add('preloaded');
this.load('');
}
/**
* Sets the input field of the control to the specified value.
*
*/
setTextboxValue(value = '') {
var input = this.control_input;
var changed = input.value !== value;
if (changed) {
input.value = value;
triggerEvent(input, 'update');
this.lastValue = value;
}
}
/**
* Returns the value of the control. If multiple items
* can be selected (e.g. <select multiple>), this returns
* an array. If only one item can be selected, this
* returns a string.
*
*/
getValue() {
if (this.is_select_tag && this.input.hasAttribute('multiple')) {
return this.items;
}
return this.items.join(this.settings.delimiter);
}
/**
* Resets the selected items to the given value.
*
*/
setValue(value, silent) {
var events = silent ? [] : ['change'];
debounce_events(this, events, () => {
this.clear(silent);
this.addItems(value, silent);
});
}
/**
* Resets the number of max items to the given value
*
*/
setMaxItems(value) {
if (value === 0)
value = null; //reset to unlimited items.
this.settings.maxItems = value;
this.refreshState();
}
/**
* Sets the selected item.
*
*/
setActiveItem(item, e) {
var self = this;
var eventName;
var i, begin, end, swap;
var last;
if (self.settings.mode === 'single')
return;
// clear the active selection
if (!item) {
self.clearActiveItems();
if (self.isFocused) {
self.inputState();
}
return;
}
// modify selection
eventName = e && e.type.toLowerCase();
if (eventName === 'click' && isKeyDown('shiftKey', e) && self.activeItems.length) {
last = self.getLastActive();
begin = Array.prototype.indexOf.call(self.control.children, last);
end = Array.prototype.indexOf.call(self.control.children, item);
if (begin > end) {
swap = begin;
begin = end;
end = swap;
}
for (i = begin; i <= end; i++) {
item = self.control.children[i];
if (self.activeItems.indexOf(item) === -1) {
self.setActiveItemClass(item);
}
}
preventDefault(e);
}
else if ((eventName === 'click' && isKeyDown(constants.KEY_SHORTCUT, e)) || (eventName === 'keydown' && isKeyDown('shiftKey', e))) {
if (item.classList.contains('active')) {
self.removeActiveItem(item);
}
else {
self.setActiveItemClass(item);
}
}
else {
self.clearActiveItems();
self.setActiveItemClass(item);
}
// ensure control has focus
self.inputState();
if (!self.isFocused) {
self.focus();
}
}
/**
* Set the active and last-active classes
*
*/
setActiveItemClass(item) {
const self = this;
const last_active = self.control.querySelector('.last-active');
if (last_active)
removeClasses(last_active, 'last-active');
addClasses(item, 'active last-active');
self.trigger('item_select', item);
if (self.activeItems.indexOf(item) == -1) {
self.activeItems.push(item);
}
}
/**
* Remove active item
*
*/
removeActiveItem(item) {
var idx = this.activeItems.indexOf(item);
this.activeItems.splice(idx, 1);
removeClasses(item, 'active');
}
/**
* Clears all the active items
*
*/
clearActiveItems() {
removeClasses(this.activeItems, 'active');
this.activeItems = [];
}
/**
* Sets the selected item in the dropdown menu
* of available options.
*
*/
setActiveOption(option, scroll = true) {
if (option === this.activeOption) {
return;
}
this.clearActiveOption();
if (!option)
return;
this.activeOption = option;
setAttr(this.focus_node, { 'aria-activedescendant': option.getAttribute('id') });
setAttr(option, { 'aria-selected': 'true' });
addClasses(option, 'active');
if (scroll)
this.scrollToOption(option);
}
/**
* Sets the dropdown_content scrollTop to display the option
*
*/
scrollToOption(option, behavior) {
if (!option)
return;
const content = this.dropdown_content;
const height_menu = content.clientHeight;
const scrollTop = content.scrollTop || 0;
const height_item = option.offsetHeight;
const y = option.getBoundingClientRect().top - content.getBoundingClientRect().top + scrollTop;
if (y + height_item > height_menu + scrollTop) {
this.scroll(y - height_menu + height_item, behavior);
}
else if (y < scrollTop) {
this.scroll(y, behavior);
}
}
/**
* Scroll the dropdown to the given position
*
*/
scroll(scrollTop, behavior) {
const content = this.dropdown_content;
if (behavior) {
content.style.scrollBehavior = behavior;
}
content.scrollTop = scrollTop;
content.style.scrollBehavior = '';
}
/**
* Clears the active option
*
*/
clearActiveOption() {
if (this.activeOption) {
removeClasses(this.activeOption, 'active');
setAttr(this.activeOption, { 'aria-selected': null });
}
this.activeOption = null;
setAttr(this.focus_node, { 'aria-activedescendant': null });
}
/**
* Selects all items (CTRL + A).
*/
selectAll() {
const self = this;
if (self.settings.mode === 'single')
return;
const activeItems = self.controlChildren();
if (!activeItems.length)
return;
self.inputState();
self.close();
self.activeItems = activeItems;
iterate(activeItems, (item) => {
self.setActiveItemClass(item);
});
}
/**
* Determines if the control_input should be in a hidden or visible state
*
*/
inputState() {
var self = this;
if (!self.control.contains(self.control_input))
return;
setAttr(self.control_input, { placeholder: self.settings.placeholder });
if (self.activeItems.length > 0 || (!self.isFocused && self.settings.hidePlaceholder && self.items.length > 0)) {
self.setTextboxValue();
self.isInputHidden = true;
}
else {
if (self.settings.hidePlaceholder && self.items.length > 0) {
setAttr(self.control_input, { placeholder: '' });
}
self.isInputHidden = false;
}
self.wrapper.classList.toggle('input-hidden', self.isInputHidden);
}
/**
* Get the input value
*/
inputValue() {
return this.control_input.value.trim();
}
/**
* Gives the control focus.
*/
focus() {
var self = this;
if (self.isDisabled || self.isReadOnly)
return;
self.ignoreFocus = true;
if (self.control_input.offsetWidth) {
self.control_input.focus();
}
else {
self.focus_node.focus();
}
setTimeout(() => {
self.ignoreFocus = false;
self.onFocus();
}, 0);
}
/**
* Forces the control out of focus.
*
*/
blur() {
this.focus_node.blur();
this.onBlur();
}
/**
* Returns a function that scores an object
* to show how good of a match it is to the
* provided query.
*
* @return {function}
*/
getScoreFunction(query) {
return this.sifter.getScoreFunction(query, this.getSearchOptions());
}
/**
* Returns search options for sifter (the system
* for scoring and sorting results).
*
* @see https://github.com/orchidjs/sifter.js
* @return {object}
*/
getSearchOptions() {
var settings = this.settings;
var sort = settings.sortField;
if (typeof settings.sortField === 'string') {
sort = [{ field: settings.sortField }];
}
return {
fields: settings.searchField,
conjunction: settings.searchConjunction,
sort: sort,
nesting: settings.nesting
};
}
/**
* Searches through available options and returns
* a sorted array of matches.
*
*/
search(query) {
var result, calculateScore;
var self = this;
var options = this.getSearchOptions();
// validate user-provided result scoring function
if (self.settings.score) {
calculateScore = self.settings.score.call(self, query);
if (typeof calculateScore !== 'function') {
throw new Error('Tom Select "score" setting must be a function that returns a function');
}
}
// perform search
if (query !== self.lastQuery) {
self.lastQuery = query;
result = self.sifter.search(query, Object.assign(options, { score: calculateScore }));
self.currentResults = result;
}
else {
result = Object.assign({}, self.currentResults);
}
// filter out selected items
if (self.settings.hideSelected) {
result.items = result.items.filter((item) => {
let hashed = hash_key(item.id);
return !(hashed && self.items.indexOf(hashed) !== -1);
});
}
return result;
}
/**
* Refreshes the list of available options shown
* in the autocomplete dropdown menu.
*
*/
refreshOptions(triggerDropdown = true) {
var i, j, k, n, optgroup, optgroups, html, has_create_option, active_group;
var create;
const groups = {};
const groups_order = [];
var self = this;
var query = self.inputValue();
const same_query = query === self.lastQuery || (query == '' && self.lastQuery == null);
var results = self.search(query);
var active_option = null;
var show_dropdown = self.settings.shouldOpen || false;
var dropdown_content = self.dropdown_content;
if (same_query) {
active_option = self.activeOption;
if (active_option) {
active_group = active_option.closest('[data-group]');
}
}
// build markup
n = results.items.length;
if (typeof self.settings.maxOptions === 'number') {
n = Math.min(n, self.settings.maxOptions);
}
if (n > 0) {
show_dropdown = true;
}
// get fragment for group and the position of the group in group_order
const getGroupFragment = (optgroup, order) => {
let group_order_i = groups[optgroup];
if (group_order_i !== undefined) {
let order_group = groups_order[group_order_i];
if (order_group !== undefined) {
return [group_order_i, order_group.fragment];
}
}
let group_fragment = document.createDocumentFragment();
group_order_i = groups_order.length;
groups_order.push({ fragment: group_fragment, order, optgroup });
return [group_order_i, group_fragment];
};
// render and group available options individually
for (i = 0; i < n; i++) {
// get option dom element
let item = results.items[i];
if (!item)
continue;
let opt_value = item.id;
let option = self.options[opt_value];
if (option === undefined)
continue;
let opt_hash = get_hash(opt_value);
let option_el = self.getOption(opt_hash, true);
// toggle 'selected' class
if (!self.settings.hideSelected) {
option_el.classList.toggle('selected', self.items.includes(opt_hash));
}
optgroup = option[self.settings.optgroupField] || '';
optgroups = Array.isArray(optgroup) ? optgroup : [optgroup];
for (j = 0, k = optgroups && optgroups.length; j < k; j++) {
optgroup = optgroups[j];
let order = option.$order;
let self_optgroup = self.optgroups[optgroup];
if (self_optgroup === undefined) {
optgroup = '';
}
else {
order = self_optgroup.$order;
}
const [group_order_i, group_fragment] = getGroupFragment(optgroup, order);
// nodes can only have one parent, so if the option is in mutple groups, we need a clone
if (j > 0) {
option_el = option_el.cloneNode(true);
setAttr(option_el, { id: option.$id + '-clone-' + j, 'aria-selected': null });
option_el.classList.add('ts-cloned');
removeClasses(option_el, 'active');
// make sure we keep the activeOption in the same group
if (self.activeOption && self.activeOption.dataset.value == opt_value) {
if (active_group && active_group.dataset.group === optgroup.toString()) {
active_option = option_el;
}
}
}
group_fragment.appendChild(option_el);
if (optgroup != '') {
groups[optgroup] = group_order_i;
}
}
}
// sort optgroups
if (self.settings.lockOptgroupOrder) {
groups_order.sort((a, b) => {
return a.order - b.order;
});
}
// render optgroup headers & join groups
html = document.createDocumentFragment();
iterate(groups_order, (group_order) => {
let group_fragment = group_order.fragment;
let optgroup = group_order.optgroup;
if (!group_fragment || !group_fragment.children.length)
return;
let group_heading = self.optgroups[optgroup];
if (group_heading !== undefined) {
let group_options = document.createDocumentFragment();
let header = self.render('optgroup_header', group_heading);
append(group_options, header);
append(group_options, group_fragment);
let group_html = self.render('optgroup', { group: group_heading, options: group_options });
append(html, group_html);
}
else {
append(html, group_fragment);
}
});
dropdown_content.innerHTML = '';
append(dropdown_content, html);
// highlight matching terms inline
if (self.settings.highlight) {
removeHighlight(dropdown_content);
if (results.query.length && results.tokens.length) {
iterate(results.tokens, (tok) => {
highlight(dropdown_content, tok.regex);
});
}
}
// helper method for adding templates to dropdown
var add_template = (template) => {
let content = self.render(template, { input: query });
if (content) {
show_dropdown = true;
dropdown_content.insertBefore(content, dropdown_content.firstChild);
}
return content;
};
// add loading message
if (self.loading) {
add_template('loading');
// invalid query
}
else if (!self.settings.shouldLoad.call(self, query)) {
add_template('not_loading');
// add no_results message
}
else if (results.items.length === 0) {
add_template('no_results');
}
// add create option
has_create_option = self.canCreate(query);
if (has_create_option) {
create = add_template('option_create');
}
// activate
self.hasOptions = results.items.length > 0 || has_create_option;
if (show_dropdown) {
if (results.items.length > 0) {
if (!active_option && self.settings.mode === 'single' && self.items[0] != undefined) {
active_option = self.getOption(self.items[0]);
}
if (!dropdown_content.contains(active_option)) {
let active_index = 0;
if (create && !self.settings.addPrecedence) {
active_index = 1;
}
active_option = self.selectable()[active_index];
}
}
else if (create) {
active_option = create;
}
if (triggerDropdown && !self.isOpen) {
self.open();
self.scrollToOption(active_option, 'auto');
}
self.setActiveOption(active_option);
}
else {
self.clearActiveOption();
if (triggerDropdown && self.isOpen) {
self.close(false); // if create_option=null, we want the dropdown to close but not reset the textbox value
}
}
}
/**
* Return list of selectable options
*
*/
selectable() {
return this.dropdown_content.querySelectorAll('[data-selectable]');
}
/**
* Adds an available option. If it already exists,
* nothing will happen. Note: this does not refresh
* the options list dropdown (use `refreshOptions`
* for that).
*
* Usage:
*
* this.addOption(data)
*
*/
addOption(data, user_created = false) {
const self = this;
// @deprecated 1.7.7
// use addOptions( array, user_created ) for adding multiple options
if (Array.isArray(data)) {
self.addOptions(data, user_created);
return false;
}
const key = hash_key(data[self.settings.valueField]);
if (key === null || self.options.hasOwnProperty(key)) {
return false;
}
data.$order = data.$order || ++self.order;
data.$id = self.inputId + '-opt-' + data.$order;
self.options[key] = data;
self.lastQuery = null;
if (user_created) {
self.userOptions[key] = user_created;
self.trigger('option_add', key, data);
}
return key;
}
/**
* Add multiple options
*
*/
addOptions(data, user_created = false) {
iterate(data, (dat) => {
this.addOption(dat, user_created);
});
}
/**
* @deprecated 1.7.7
*/
registerOption(data) {
return this.addOption(data);
}
/**
* Registers an option group to the pool of option groups.
*
* @return {boolean|string}
*/
registerOptionGroup(data) {
var key = hash_key(data[this.settings.optgroupValueField]);
if (key === null)
return false;
data.$order = data.$order || ++this.order;
this.optgroups[key] = data;
return key;
}
/**
* Registers a new optgroup for options
* to be bucketed into.
*
*/
addOptionGroup(id, data) {
var hashed_id;
data[this.settings.optgroupValueField] = id;
if (hashed_id = this.registerOptionGroup(data)) {
this.trigger('optgroup_add', hashed_id, data);
}
}
/**
* Removes an existing option group.
*
*/
removeOptionGroup(id) {
if (this.optgroups.hasOwnProperty(id)) {
delete this.optgroups[id];
this.clearCache();
this.trigger('optgroup_remove', id);
}
}
/**
* Clears all existing option groups.
*/
clearOptionGroups() {
this.optgroups = {};
this.clearCache();
this.trigger('optgroup_clear');
}
/**
* Updates an option available for selection. If
* it is visible in the selected items or options
* dropdown, it will be re-rendered automatically.
*
*/
updateOption(value, data) {
const self = this;
var item_new;
var index_item;
const value_old = hash_key(value);
const value_new = hash_key(data[self.settings.valueField]);
// sanity checks
if (value_old === null)
return;
const data_old = self.options[value_old];
if (data_old == undefined)
return;
if (typeof value_new !== 'string')
throw new Error('Value must be set in option data');
const option = self.getOption(value_old);
const item = self.getItem(value_old);
data.$order = data.$order || data_old.$order;
delete self.options[value_old];
// invalidate render cache
// don't remove existin