/************************************************************************ * This file is part of EspoCRM. * * EspoCRM – Open Source CRM application. * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko * Website: https://www.espocrm.com * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * The interactive user interfaces in modified source and object code versions * of this program must display Appropriate Legal Notices, as required under * Section 5 of the GNU Affero General Public License version 3. * * In accordance with Section 7(b) of the GNU Affero General Public License version 3, * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ /** @module module:ui/select */ import Selectize from 'lib!selectize'; /** * @typedef module:ui/select~Options * @type {Object} * @property {boolean} [selectOnTab=false] To select on tab. * @property {boolean} [matchAnyWord=false] To match any word when searching. * @property {function(string, module:ui/select~OptionItemsCallback): void} [load] Loads additional items * when typing in search. * @property {function(string, module:ui/select~OptionItemFull): Number} [score] A score function scoring * searched items. * @property {'value'|'text'|'$order'|'$score'} [sortBy='$order'] Item sorting. * @property {'asc'|'desc'} [sortDirection='asc'] Sort direction. */ /** * @callback module:ui/select~OptionItemsCallback * @param {module:ui/select~OptionItem[]} list An option item list. */ /** * @typedef module:ui/select~OptionItem * @type {Object} * @property {string} value A value. * @property {string} text A label. */ /** * @typedef module:ui/select~OptionItemFull * @type {Object} * @property {string} value A value. * @property {string} text A label. * @property {Number} $order An order index. */ /** * @module ui/select * * Important. The Selectize library is heavily customized to fix multitude of UIX issues. * Upgrading is not advisable. Consider forking. */ const Select = { /** * @param {Element|JQuery} element An element. * @param {module:ui/select~Options} [options] Options. */ init: function (element, options = {}) { const score = options.score; const $el = $(element); options = Select.applyDefaultOptions(options || {}); const plugins = []; Select.loadEspoSelectPlugin(); plugins.push('espo_select'); const itemClasses = {}; const allowedValues = $el.children().toArray().map(item => { const value = item.getAttributeNode('value').value; if (item.classList) { itemClasses[value] = item.classList.toString(); } return value; }); let $relativeParent = null; const $modalBody = $el.closest('.modal-body'); if ($modalBody.length) { $relativeParent = $modalBody; } // noinspection JSUnusedGlobalSymbols const selectizeOptions = { sortField: [{field: options.sortBy, direction: options.sortDirection}], load: options.load, loadThrottle: 1, plugins: plugins, highlight: false, selectOnTab: options.selectOnTab, copyClassesToDropdown: false, allowEmptyOption: allowedValues.includes(''), showEmptyOptionInDropdown: true, $relativeParent: $relativeParent, render: { item: function (data) { return $('
') .addClass('item') .addClass(itemClasses[data.value] || '') .text(data.text) .get(0).outerHTML; }, option: function (data) { const $div = $('
') .addClass('option') .addClass(data.value === '' ? 'selectize-dropdown-emptyoptionlabel' : '') .addClass(itemClasses[data.value] || '') .val(data.value) .text(data.text); if (data.text === '') { $div.html(' '); } return $div.get(0).outerHTML; }, }, onDelete: function (values) { while (values.length) { this.removeItem(values.pop(), true); } // noinspection JSUnresolvedReference this.showInput(); this.positionDropdown(); this.refreshOptions(true); }, }; if (!options.matchAnyWord) { /** @this Selectize */ selectizeOptions.score = function (search) { // noinspection JSUnresolvedReference const score = this.getScoreFunction(search); search = search.toLowerCase(); return function (item) { if (item.text.toLowerCase().indexOf(search) === 0) { return score(item); } return 0; }; }; } if (options.matchAnyWord) { /** @this Selectize */ selectizeOptions.score = function (search) { // noinspection JSUnresolvedReference const score = this.getScoreFunction(search); search = search.toLowerCase(); return function (item) { const text = item.text.toLowerCase(); if ( !text.split(' ').find(item => item.startsWith(search)) && !text.startsWith(search) ) { return 0; } return score(item); }; }; } if (options.score) { selectizeOptions.score = function (search) { return function (item) { return score(search, item); }; }; } $el.selectize(selectizeOptions); }, /** * Focus. * * @param {Element|JQuery} element An element. * @param {{noTrigger?: boolean}} [options] Options. */ focus: function (element, options) { const $el = $(element); options = options || {}; if ( !$el[0] || !$el[0].selectize ) { return; } const selectize = $el[0].selectize; if (options.noTrigger) { selectize.focusNoTrigger = true; } selectize.focus(); if (options.noTrigger) { setTimeout(() => selectize.focusNoTrigger = false, 100); } }, /** * Set options. * @todo Side effects may occur if called multiple times. Workaround is to clone and re-initialize. To be fixed. * * @param {Element|JQuery} element An element. * @param {{value: string, text: string}[]} options Options. */ setOptions: function (element, options) { const $el = $(element); const selectize = $el.get(0).selectize; selectize.clearOptions(true); selectize.load(callback => { callback( options.map(item => { return { value: item.value, text: item.text || item.label, }; }) ); }); }, /** * Set value. * * @param {JQuery} $el An element. * @param {string} value A value. */ setValue: function ($el, value) { const selectize = $el.get(0).selectize; selectize.setValue(value, true); }, /** * Destroy. * * @param {JQuery} $el An element. */ destroy: function ($el) { if (!$el.length || !$el[0].selectize) { return; } $el[0].selectize.destroy(); }, /** * @private * @param {module:ui/select~Options} options * @return {module:ui/select~Options} */ applyDefaultOptions: function (options) { options = Espo.Utils.clone(options); const defaults = { selectOnTab: false, matchAnyWord: false, sortBy: '$order', sortDirection: 'asc', }; for (const key in defaults) { if (key in options) { continue; } options[key] = defaults[key]; } return options; }, /** * @private */ loadEspoSelectPlugin: function () { if ('espo_select' in Selectize.plugins) { return; } const IS_MAC = /Mac/.test(navigator.userAgent); const KEY_BACKSPACE = 8; Selectize.define('espo_select', function () { const self = this; this.setup = (function () { const original = self.setup; return function () { original.apply(this, arguments); self.selectedValue = self.items[0]; self.$dropdown .on('mouseup', '[data-selectable]', function () { $(document).off('mouseup.select'); return self.onOptionSelect.apply(self, arguments); }); self.$dropdown .on('mousedown', '[data-selectable]', function () { // Prevent issue when down inside, up outside. $(document).one('mouseup.select', function () { self.focusOnControlSilently(); }); }); self.$control_input.css({'width': '4px'}); }; })(); this.focusOnControlSilently = function () { self.preventReOpenOnFocus = true; self.$control_input[0].focus(); self.preventReOpenOnFocus = false; }; /*this.positionDropdown = (function () { let original = self.positionDropdown; return function () { original.apply(this, arguments); this.$dropdown.css({margin: 'unset'}); }; })();*/ this.refreshOptions = (function () { const original = self.refreshOptions; return function () { if (self.focusNoTrigger) { original.apply(this, [false]); return; } original.apply(this, arguments); }; })(); this.blur = (function () { const original = self.blur; return function () { // Prevent closing on mouse down. if (self.preventClose) { return; } original.apply(this, arguments); }; })(); this.close = (function () { const original = self.close; return function () { if (self.preventClose) { return; } original.apply(this, arguments); }; })(); this.onOptionSelect = (function () { const original = self.onOptionSelect; return function (e) { if (e.type === 'mousedown' || e.type === 'click') { self.preventClose = true; setTimeout(() => self.preventClose = false, 100); return; } self.preventClose = false; if (e.type === 'mouseup') { setTimeout(() => self.focusOnControlSilently(), 50); } original.apply(this, arguments); self.selectedValue = $(e.currentTarget).attr('data-value'); }; })(); this.open = (function() { const original = self.open; return function () { const toProcess = !(self.isLocked || self.isOpen); original.apply(this, arguments); if (!toProcess) { return; } const $dropdownContent = self.$dropdown.children().first(); const $selected = $dropdownContent.find('.selected'); if (!$selected.length) { return; } let scrollTo = $selected.get(0).offsetTop - $dropdownContent.get(0).clientHeight; scrollTo = scrollTo >= 0 ? scrollTo : 0; $dropdownContent .find('.selectize-dropdown-content') .scrollTop(scrollTo); }; })(); this.onMouseDown = (function() { const original = self.onMouseDown; return function (e) { // Prevent flicking when clicking on input. if (!self.isOpen && !self.isInputHidden && self.$control_input.val()) { return; } if (self.isOpen) { self.closedByMouseDown = true; } return original.apply(this, arguments); }; })(); this.onFocus = (function() { const original = self.onFocus; return function (e) { if (self.preventReOpenOnFocus) { return; } if (self.closedByMouseDown) { self.closedByMouseDown = false; return; } self.closedByMouseDown = false; return original.apply(this, arguments); }; })(); this.restoreSelectedValue = function () { if (this.preventRevertLoop) { return; } this.preventRevertLoop = true; setTimeout(() => this.preventRevertLoop = false, 10); this.setValue(this.selectedValue, true); }; this.onBlur = (function() { const original = self.onBlur; return function () { // Prevent closing on mouse down. if (self.preventClose) { return; } self.restoreSelectedValue(); self.$control_input.css({width: '4px'}); return original.apply(this, arguments); }; })(); this.onKeyDown = (function() { const original = self.onKeyDown; return function (e) { if (IS_MAC ? e.metaKey : e.ctrlKey) { if (!self.items.length) { self.restoreSelectedValue(); self.focus(); } return; } if (e.code === 'Escape') { if (self.isOpen || !self.isInputHidden) { e.stopPropagation(); } if (self.isOpen) { self.close(); } if (!self.isInputHidden) { self.hideInput(); } self.addItem(this.selectedValue, true); } if (self.isFull() || self.isInputHidden) { if ( e.key.length === 1 && ( e.code.match(/Key[A-Z]/i) || e.key.match(/[0-9]/) || RegExp(/^\p{L}/, 'u').test(e.key) // is letter ) ) { const keyCode = e.keyCode; e.keyCode = KEY_BACKSPACE; self.deleteSelection(e); e.keyCode = keyCode; self.$control_input.width(15); } } return original.apply(this, arguments); }; })(); this.positionDropdown = (function() { const POSITION = { top: 'top', bottom: 'bottom', }; return function() { const $control = self.$control; const offset = this.settings.dropdownParent === 'body' ? $control.offset() : $control.position(); offset.top += $control.outerHeight(true); const dropdownHeight = self.$dropdown.prop('scrollHeight') + 5; const controlPosTop = self.$control.get(0).getBoundingClientRect().top; const wrapperHeight = self.$wrapper.height(); const controlPosBottom = self.$control.get(0).getBoundingClientRect().bottom; const boundaryTop = !this.settings.$relativeParent ? 0 : this.settings.$relativeParent.get(0).getBoundingClientRect().top; const position = controlPosTop + dropdownHeight + wrapperHeight > window.innerHeight && controlPosBottom - dropdownHeight - wrapperHeight >= boundaryTop ? POSITION.top : POSITION.bottom; const styles = { width: $control.outerWidth(), left: offset.left, }; if (position === POSITION.top) { Object.assign(styles, { bottom: offset.top, top: 'unset', margin: '0 0 0 0', }); self.$dropdown.addClass('selectize-position-top'); } else { Object.assign(styles, { top: offset.top, bottom: 'unset', margin: '0 0 0 0', }); self.$dropdown.removeClass('selectize-position-top'); } self.$dropdown.css(styles); } })(); }); }, }; export default Select;