diff --git a/api/js/etemplate/Et2Select/Et2Select.ts b/api/js/etemplate/Et2Select/Et2Select.ts index 864838bee1..0e6337d80c 100644 --- a/api/js/etemplate/Et2Select/Et2Select.ts +++ b/api/js/etemplate/Et2Select/Et2Select.ts @@ -17,13 +17,14 @@ import {Et2InvokerMixin} from "../Et2Url/Et2InvokerMixin"; import {SlSelect} from "@shoelace-style/shoelace"; import {egw} from "../../jsapi/egw_global"; import shoelace from "../Styles/shoelace"; +import {Et2WithSearchMixin} from "./SearchMixin"; // export Et2WidgetWithSelect which is used as type in other modules export class Et2WidgetWithSelect extends Et2widgetWithSelectMixin(SlSelect) { }; -export class Et2Select extends Et2InvokerMixin(Et2WidgetWithSelect) +export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithSelect)) { static get styles() { @@ -266,7 +267,7 @@ export class Et2Select extends Et2InvokerMixin(Et2WidgetWithSelect) _emptyLabelTemplate() : TemplateResult { - if(!this.empty_label) + if(!this.empty_label || this.multiple) { return html``; } diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts new file mode 100644 index 0000000000..8b529a4981 --- /dev/null +++ b/api/js/etemplate/Et2Select/SearchMixin.ts @@ -0,0 +1,417 @@ +/** + * EGroupware eTemplate2 - SearchMixin + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ + + +import {css, dedupeMixin, html, LitElement, render, SlotMixin} from "@lion/core"; + + +// Export the Interface for TypeScript +type Constructor = new (...args : any[]) => T; + +export declare class SearchMixinInterface +{ + /** + * Enable searching on options + */ + search : boolean; + + /** + * Get [additional] options from the server when you search, instead of just searching existing options + */ + searchUrl : string; + + /** + * Allow adding new options that are not in the search results + */ + allowFreeEntries : boolean; + + + /** + * Start the search process + */ + startSearch() : void + + /** + * Search local options + */ + localSearch(search : string) : Promise + + /** + * Search remote options. + * If searchUrl is not set, it will return very quickly with no results + */ + remoteSearch(search : string) : Promise + + /** + * Check a [local] item to see if it matches + */ + searchMatch(search : string, item : LitElement) : boolean +} + +/** + * Base class for things that do search type behaviour + * Separated to keep things a little simpler. + * + * Currently I assume we're extending an Et2Select, so changes may need to be made for better abstraction + */ +export const Et2WithSearchMixin = dedupeMixin((superclass) => +{ + class Et2WidgetWithSearch extends SlotMixin(superclass) + { + static get properties() + { + return { + ...super.properties, + search: {type: Boolean, reflect: true}, + + searchUrl: {type: String}, + + allowFreeEntries: {type: Boolean} + } + } + + get slots() + { + return { + ...super.slots, + suffix: () => + { + const input = document.createElement("sl-icon"); + input.name = "search"; + return input; + } + } + } + + static get styles() + { + return [ + // @ts-ignore + ...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []), + css` + ::slotted([slot="suffix"]) { + display: none; + } + :host([search]) ::slotted([slot="suffix"]) { + display: initial; + } + ::slotted([name="search_input"]:focus ){ + width: 100%; + } + .select__prefix ::slotted(.search_input) { + display: none; + margin-left: 0px; + width: 100%; + } + ::slotted(.search_input.active) { + display: block; + } + ::slotted(.no-match) { + display: none; + } + ` + ] + } + + private _searchTimeout : number; + protected static SEARCH_DELAY = 200; + protected static MIN_CHARS = 2; + + constructor(...args : any[]) + { + super(...args); + + this._handleSearchButtonClick = this._handleSearchButtonClick.bind(this); + this._handleSearchAbort = this._handleSearchAbort.bind(this); + this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this); + } + + connectedCallback() + { + super.connectedCallback(); + + // Missing any of the required attributes? Don't change anything. + if(!this.search && !this.searchUrl && !this.allowFreeEntries) + { + return; + } + + this._addNodes(); + this._bindListeners(); + } + + disconnectedCallback() + { + super.disconnectedCallback(); + this._unbindListeners(); + } + + /** + * Add the nodes we need to search + * + * @protected + * + * NB: Not sure which is the best way yet, SlotMixin or using render() + */ + protected _addNodes() + { + const div = document.createElement("div"); + div.classList.add("search_input"); + div.slot = "prefix"; + render(this._searchInputTemplate(), div); + this.appendChild(div); + } + + protected _searchInputTemplate() + { + return html` + + `; + } + + + protected get _searchButtonNode() + { + return this.querySelector("sl-icon[slot='suffix']"); + } + + protected get _searchInputNode() + { + return this.querySelector(".search_input input"); + } + + protected get _activeControls() + { + return this.querySelector(".search_input"); + } + + protected get menuItems() + { + return this.querySelectorAll("sl-menu-item"); + } + + /** + * Only local options, excludes server options + * + * @protected + */ + protected get localItems() + { + return this.querySelectorAll("sl-menu-item:not(.remote)"); + } + + protected get remoteItems() + { + return this.querySelectorAll("sl-menu-item.remote"); + } + + getItems() + { + return [...this.querySelectorAll("sl-menu-item:not(.no-match)")]; + } + + protected _bindListeners() + { + this.addEventListener("sl-blur", this._handleSearchAbort); + this._searchButtonNode.addEventListener("click", this._handleSearchButtonClick); + } + + protected _unbindListeners() + { + this.removeEventListener("sl-blur", this._handleSearchAbort); + this._searchButtonNode.removeEventListener("click", this._handleSearchButtonClick); + } + + handleMenuShow() + { + super.handleMenuShow(); + + this._activeControls.classList.add("active"); + this._searchInputNode.focus(); + this._searchInputNode.select(); + } + + handleMenuHide() + { + super.handleMenuHide(); + this._activeControls.classList.remove("active"); + } + + /** + * Handle keypresses inside the search input + * @param {KeyboardEvent} event + * @protected + */ + protected _handleSearchKeyDown(event : KeyboardEvent) + { + this._activeControls.classList.add("active"); + this.dropdown.show(); + + // Pass off some keys to select + if(['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) + { + return this.handleKeyDown(event); + } + + // Don't allow event to bubble or it will interact with select + event.stopImmediatePropagation(); + if(event.key === "Enter") + { + // If there's only one option, select it + if(this.getItems().length === 1) + { + this.getItems()[0].click(); + this.hide(); + return; + } + event.preventDefault(); + this.startSearch(); + } + + // Start the search automatically if they have enough letters + clearTimeout(this._searchTimeout); + if(this._searchInputNode.value.length >= Et2WidgetWithSearch.MIN_CHARS) + { + this._searchTimeout = window.setTimeout(() => {this.startSearch()}, Et2WidgetWithSearch.SEARCH_DELAY); + } + } + + /** + * Start searching + * + * If we have local options, we'll search & display any matches. + * If serverUrl is set, we'll ask the server for results as well. + */ + public startSearch() + { + // Show a spinner instead of search button + this._searchButtonNode.style.display = "hidden"; + let spinner = document.createElement("sl-spinner"); + spinner.slot = this._searchButtonNode.slot; + this.appendChild(spinner); + + // Start the searches + Promise.all([ + this.localSearch(this._searchInputNode.value), + this.remoteSearch(this._searchInputNode.value) + ]).then(() => + { + spinner.remove(); + }); + } + + /** + * Filter the local options + * + * @param {string} search + * @protected + */ + protected localSearch(search : string) : Promise + { + return new Promise((resolve) => + { + this.localItems.forEach((item) => + { + let match = this.searchMatch(search, item); + item.classList.toggle("match", match); + // set disabled so arrow keys step over. Might be a better way to handle that + item.disabled = !match; + item.classList.toggle("no-match", !match); + }) + resolve(); + }); + } + + /** + * Ask for remote options and add them in unconditionally + * @param {string} search + * @protected + */ + protected remoteSearch(search : string) + { + // Remove existing remote items + this.remoteItems.forEach(i => i.remove()); + + if(!this.searchUrl) + { + return Promise.resolve(); + } + + // Fire off the query + let promise = this.remoteQuery(search); + + return promise; + } + + protected remoteQuery(search : string) + { + return this.egw().json(this.searchUrl, [search]).sendRequest().then((result) => + { + debugger; + this.processRemoteResults(result); + }); + } + + /** + * Add in remote results + * @param results + * @protected + */ + protected processRemoteResults(results) + { + // TODO: Add the results in + } + + /** + * Check if one of our [local] items matches the search + * + * @param search + * @param item + * @returns {boolean} + * @protected + */ + protected searchMatch(search, item) : boolean + { + if(!item || !item.value) + { + return false; + } + if(item.textContent?.toLowerCase().includes(search.toLowerCase())) + { + return true; + } + if(typeof item.value == "string") + { + return item.value.includes(search.toLowerCase()); + } + return item.value == search; + } + + protected _handleSearchButtonClick(e) + { + this.handleMenuShow(); + } + + protected _handleSearchAbort(e) + { + this._activeControls.classList.remove("active"); + this._searchInputNode.value = ""; + + // Reset options. It might be faster to re-create instead. + this.menuItems.forEach((item) => + { + item.disabled = false; + item.classList.remove("match"); + item.classList.remove("no-match"); + }) + } + } + + return Et2WidgetWithSearch as Constructor & T & LitElement; +}); diff --git a/api/js/etemplate/Styles/shoelace.ts b/api/js/etemplate/Styles/shoelace.ts index 15d453a5bb..0ab6ed7952 100644 --- a/api/js/etemplate/Styles/shoelace.ts +++ b/api/js/etemplate/Styles/shoelace.ts @@ -14,8 +14,11 @@ registerIconLibrary('default', { /** * Override some shoelace icons with EGroupware icons * In particular, the data: ones give errors with our CSP + * hacky hack to temporarily work around until CSP issue is fixed + * + * @see https://my.egroupware.org/egw/index.php?menuaction=tracker.tracker_ui.edit&tr_id=68774 */ -const egw_icons = {'chevron-down': 'arrow_down'} +const egw_icons = {'chevron-down': 'arrow_down', 'x': 'close'} registerIconLibrary("system", { resolver: (name) => {