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('
'); const control = getDom('
'); const dropdown = this._render('dropdown'); const dropdown_content = getDom(`
`); 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(''); 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 '
' + escape(data[field_optgroup]) + '
'; }, 'option': (data, escape) => { return '
' + escape(data[field_label]) + '
'; }, 'item': (data, escape) => { return '
' + escape(data[field_label]) + '
'; }, 'option_create': (data, escape) => { return '
Add ' + escape(data.input) + '
'; }, 'no_results': () => { return '
No results found
'; }, 'loading': () => { return '
'; }, 'not_loading': () => { }, 'dropdown': () => { return '
'; } }; 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 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 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 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 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 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 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. or * element to reflect the current state. * */ updateOriginalInput(opts = {}) { const self = this; var option, label; const empty_option = self.input.querySelector('option[value=""]'); if (self.is_select_tag) { const selected = []; const has_selected = self.input.querySelectorAll('option:checked').length; function AddSelected(option_el, value, label) { if (!option_el) { option_el = getDom(''); } // don't move empty option from top of list // fixes bug in firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1725293 if (option_el != empty_option) { self.input.append(option_el); } selected.push(option_el); // marking empty option as selected can break validation // fixes https://github.com/orchidjs/tom-select/issues/303 if (option_el != empty_option || has_selected > 0) { option_el.selected = true; } return option_el; } // unselect all selected options self.input.querySelectorAll('option:checked').forEach((option_el) => { option_el.selected = false; }); // nothing selected? if (self.items.length == 0 && self.settings.mode == 'single') { AddSelected(empty_option, "", ""); // order selected