mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-11-24 17:04:14 +01:00
562a391579
Fix for when remote result doesn't get there before SlSelect renders, and the SlSelect removed the value because the option wasn't there
1574 lines
40 KiB
TypeScript
1574 lines
40 KiB
TypeScript
/**
|
|
* 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, CSSResultGroup, html, LitElement, nothing, TemplateResult} from "lit";
|
|
import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
|
|
import {Validator} from "@lion/form-core";
|
|
import {Et2Tag} from "./Tag/Et2Tag";
|
|
import {StaticOptions} from "./StaticOptions";
|
|
import {dedupeMixin} from "@open-wc/dedupe-mixin";
|
|
import {SlOption} from "@shoelace-style/shoelace";
|
|
import {Et2Textbox} from "../Et2Textbox/Et2Textbox";
|
|
import {until} from "lit/directives/until.js";
|
|
|
|
// Otherwise import gets stripped
|
|
let keep_import : Et2Tag;
|
|
|
|
// Export the Interface for TypeScript
|
|
type Constructor<T = {}> = 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;
|
|
|
|
/**
|
|
* Additional search options passed to the search functions
|
|
*
|
|
* @type {object}
|
|
*/
|
|
searchOptions : object;
|
|
|
|
/**
|
|
* Start the search process
|
|
*/
|
|
startSearch() : void
|
|
|
|
/**
|
|
* Search local options
|
|
*/
|
|
localSearch(search : string, options : object) : Promise<void>
|
|
|
|
/**
|
|
* Search remote options.
|
|
* If searchUrl is not set, it will return very quickly with no results
|
|
*/
|
|
remoteSearch(search : string, options : object) : Promise<void>
|
|
|
|
/**
|
|
* Check a [local] item to see if it matches
|
|
*/
|
|
searchMatch(search : string, options : object, item : LitElement) : boolean
|
|
|
|
/**
|
|
* Additional customisation location, where we stick the search elements
|
|
*
|
|
* @type {TemplateResult}
|
|
*/
|
|
}
|
|
|
|
/**
|
|
* 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(<T extends Constructor<LitElement>>(superclass : T) =>
|
|
{
|
|
class Et2WidgetWithSearch extends superclass
|
|
{
|
|
static get properties()
|
|
{
|
|
return {
|
|
...super.properties,
|
|
search: {type: Boolean, reflect: true},
|
|
|
|
searchUrl: {type: String},
|
|
|
|
/**
|
|
* Allow custom entries that are not in the options
|
|
*/
|
|
allowFreeEntries: {type: Boolean, reflect: true},
|
|
|
|
/**
|
|
* Additional search parameters that are passed to the server
|
|
* when we query searchUrl
|
|
*/
|
|
searchOptions: {type: Object},
|
|
|
|
/**
|
|
* Allow editing tags by clicking on them.
|
|
* allowFreeEntries must be true
|
|
*/
|
|
editModeEnabled: {type: Boolean}
|
|
}
|
|
}
|
|
|
|
static get styles() : CSSResultGroup
|
|
{
|
|
return [
|
|
// @ts-ignore
|
|
...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []),
|
|
css`
|
|
|
|
/* Full width search textbox covers loading spinner, lift it up */
|
|
::slotted(sl-spinner) {
|
|
z-index: 2;
|
|
}
|
|
|
|
/* Show edit textbox only when editing */
|
|
.search_input #edit {
|
|
display: none;
|
|
}
|
|
.search_input.editing #search {
|
|
display: none;
|
|
}
|
|
.search_input.editing #edit {
|
|
display: initial;
|
|
}
|
|
|
|
|
|
:host([search]) sl-select[open]::part(prefix), :host([allowfreeentries]) sl-select[open]::part(prefix) {
|
|
order: 9;
|
|
flex: 2 1 auto;
|
|
flex-wrap: wrap;
|
|
width: 100%;
|
|
}
|
|
|
|
:host([search]) sl-select[open]::part(display-input), :host([allowfreeentries]) sl-select[open]::part(display-input) {
|
|
display: none;
|
|
}
|
|
|
|
:host([search]) sl-select[open]::part(expand-icon) {
|
|
display: none;
|
|
}
|
|
|
|
sl-select[open][multiple]::part(tags) {
|
|
flex-basis: 100%;
|
|
}
|
|
|
|
sl-select[open][multiple]::part(combobox) {
|
|
flex-flow: wrap;
|
|
}
|
|
|
|
|
|
/* Search textbox general styling, starts hidden */
|
|
|
|
.search_input {
|
|
display: none;
|
|
/* See also etemplate2.css, searchbox border turned off in there */
|
|
border: none;
|
|
flex: 1 1 auto;
|
|
order: 2;
|
|
margin-left: 0px;
|
|
height: var(--sl-input-height-medium);
|
|
width: 100%;
|
|
background-color: white;
|
|
z-index: var(--sl-z-index-dropdown);
|
|
}
|
|
|
|
:host([search]) et2-textbox::part(base) {
|
|
border: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* Search UI active - show textbox & stuff */
|
|
|
|
.search_input.active,
|
|
.search_input.editing {
|
|
display: flex;
|
|
}
|
|
|
|
/* If multiple and no value, overlap search onto widget instead of below */
|
|
|
|
:host([multiple]) .search_input.active.novalue {
|
|
top: 0px;
|
|
}
|
|
|
|
/* Hide options that do not match current search text */
|
|
|
|
:host([search]) sl-option.no-match {
|
|
display: none;
|
|
}
|
|
/* Different cursor for editable tags */
|
|
:host([allowfreeentries]):not([readonly]) .search_tag::part(base) {
|
|
cursor: text;
|
|
}
|
|
|
|
/** Readonly **/
|
|
/* No border */
|
|
:host([readonly]) .form-control-input {
|
|
border: none;
|
|
}
|
|
/* disable focus border */
|
|
:host([readonly]) .form-control-input:focus-within {
|
|
box-shadow: none;
|
|
}
|
|
/* normal cursor */
|
|
:host([readonly]) .select__control {
|
|
cursor: initial;
|
|
}
|
|
`
|
|
]
|
|
}
|
|
|
|
// Borrowed from Lion ValidatorMixin, but we don't want the whole thing
|
|
protected defaultValidators : Validator[];
|
|
protected validators : Validator[];
|
|
|
|
private _searchTimeout : number;
|
|
|
|
/**
|
|
* When user is typing, we wait this long for them to be finished before we start the search
|
|
* @type {number}
|
|
* @protected
|
|
*/
|
|
protected static SEARCH_TIMEOUT = 500;
|
|
|
|
/**
|
|
* We need at least this many characters before we start the search
|
|
*
|
|
* @type {number}
|
|
* @protected
|
|
*/
|
|
protected static MIN_CHARS = 2;
|
|
|
|
/**
|
|
* Limit server searches to 100 results, matches Link::DEFAULT_NUM_ROWS
|
|
* @type {number}
|
|
*/
|
|
static RESULT_LIMIT : number = 100;
|
|
|
|
|
|
// Hold the original option data from earlier search results, since we discard on subsequent search
|
|
private _selected_remote = <SelectOption[]>[];
|
|
|
|
// Hold current search results, selected or otherwise
|
|
private _remote_options = <SelectOption[]>[];
|
|
|
|
private _total_result_count = 0;
|
|
|
|
/**
|
|
* These characters will end a free tag
|
|
* @type {string[]}
|
|
*/
|
|
static TAG_BREAK : string[] = ["Tab", "Enter", ","];
|
|
|
|
constructor(...args : any[])
|
|
{
|
|
super(...args);
|
|
|
|
this.search = false;
|
|
this.searchUrl = "";
|
|
this.searchOptions = {app: "addressbook"};
|
|
|
|
this.allowFreeEntries = false;
|
|
this.editModeEnabled = false;
|
|
|
|
// Hiding the selected options from the dropdown means we can't un-select the tags
|
|
// hidden by the max limit. Prefer no limit.
|
|
this.maxOptionsVisible = -1;
|
|
|
|
this.validators = [];
|
|
/**
|
|
* Used by Subclassers to add default Validators.
|
|
* A email input for instance, always needs the isEmail validator.
|
|
* @example
|
|
* ```js
|
|
* this.defaultValidators.push(new IsDate());
|
|
* ```
|
|
* @type {Validator[]}
|
|
*/
|
|
this.defaultValidators = [];
|
|
|
|
this.handleOptionClick = this.handleOptionClick.bind(this);
|
|
this._handleChange = this._handleChange.bind(this);
|
|
this.handleTagEdit = this.handleTagEdit.bind(this);
|
|
this._handleAfterShow = this._handleAfterShow.bind(this);
|
|
this._handleMenuHide = this._handleMenuHide.bind(this);
|
|
this._handleSearchBlur = this._handleSearchBlur.bind(this);
|
|
this._handleClear = this._handleClear.bind(this);
|
|
this._handleDoubleClick = this._handleDoubleClick.bind(this);
|
|
this._handleSearchAbort = this._handleSearchAbort.bind(this);
|
|
this._handleSearchClear = this._handleSearchClear.bind(this);
|
|
this._handleSearchChange = this._handleSearchChange.bind(this);
|
|
this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
|
|
this._handleSearchMouseDown = this._handleSearchMouseDown.bind(this);
|
|
this._handleEditKeyDown = this._handleEditKeyDown.bind(this);
|
|
this._handlePaste = this._handlePaste.bind(this);
|
|
}
|
|
|
|
connectedCallback()
|
|
{
|
|
super.connectedCallback();
|
|
|
|
this.classList.toggle("search", this.searchEnabled);
|
|
|
|
// Missing any of the required attributes? Don't change anything.
|
|
// If readonly, skip it
|
|
if(!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this._bindListeners();
|
|
}
|
|
|
|
disconnectedCallback()
|
|
{
|
|
super.disconnectedCallback();
|
|
this._unbindListeners();
|
|
}
|
|
|
|
async getUpdateComplete()
|
|
{
|
|
const result = super.getUpdateComplete();
|
|
if(this._searchInputNode)
|
|
{
|
|
await this._searchInputNode.updateComplete;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
willUpdate(changedProperties)
|
|
{
|
|
super.willUpdate(changedProperties);
|
|
|
|
// Turn on search if there's more than 20 options
|
|
if(changedProperties.has("select_options") && this.select_options.length > 20)
|
|
{
|
|
this.search = true;
|
|
}
|
|
|
|
// If searchURL is set, turn on search
|
|
if(changedProperties.has("searchUrl") && this.searchUrl)
|
|
{
|
|
this.search = true;
|
|
// Decode URL, possibly again. If set in template, it can wind up double-encoded.
|
|
this.searchUrl = this.egw().decodePath(this.searchUrl);
|
|
}
|
|
|
|
// Add missing options if search or free entries enabled
|
|
if(changedProperties.has("value") && this.value)
|
|
{
|
|
// Overridden to add options if allowFreeEntries=true
|
|
if(this.allowFreeEntries && typeof this.value == "string" && !this.select_options.find(o => o.value == this.value &&
|
|
(!o.class || o.class && !o.class.includes('remote'))))
|
|
{
|
|
this.createFreeEntry(this.value);
|
|
}
|
|
else if(this.allowFreeEntries && this.multiple)
|
|
{
|
|
this.getValueAsArray().forEach((e) =>
|
|
{
|
|
if(!this.select_options.find(o => o.value == e))
|
|
{
|
|
this.createFreeEntry(e);
|
|
}
|
|
});
|
|
}
|
|
if(this.searchEnabled)
|
|
{
|
|
// Check to see if value is for an option we do not have
|
|
for(const newValueElement of this.getValueAsArray())
|
|
{
|
|
if(this.select_options.some(o => o.value == newValueElement))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
this._missingOption(newValueElement);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
update(changedProperties)
|
|
{
|
|
super.update(changedProperties);
|
|
|
|
// One of the key properties has changed, need to add the needed nodes
|
|
if(changedProperties.has("search") || changedProperties.has("editModeEnabled") || changedProperties.has("allowFreeEntries"))
|
|
{
|
|
this._unbindListeners();
|
|
// Missing any of the required attributes? Now we need to take it out.
|
|
if(!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly)
|
|
{
|
|
this.querySelector(".search_input")?.remove();
|
|
return;
|
|
}
|
|
|
|
// Listeners may have been skipped from connectedCallback()
|
|
this._bindListeners();
|
|
}
|
|
// Update any tags if edit mode changes
|
|
if(changedProperties.has("editModeEnabled") || changedProperties.has("readonly"))
|
|
{
|
|
// Required because we explicitly create tags instead of doing it in render()
|
|
this.shadowRoot.querySelectorAll(".select__tags > *").forEach((tag : Et2Tag) =>
|
|
{
|
|
tag.editable = this.editModeEnabled && !this.readonly;
|
|
tag.removable = !this.readonly;
|
|
});
|
|
|
|
if(this.readonly)
|
|
{
|
|
this._unbindListeners();
|
|
}
|
|
}
|
|
}
|
|
|
|
protected _extraTemplate() : TemplateResult | typeof nothing
|
|
{
|
|
if(!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly)
|
|
{
|
|
return nothing;
|
|
}
|
|
|
|
return html`
|
|
${this._searchInputTemplate()}
|
|
${until(this._moreResultsTemplate(), nothing)}
|
|
${this._noResultsTemplate()}
|
|
`;
|
|
}
|
|
|
|
protected async _moreResultsTemplate()
|
|
{
|
|
await this.updateComplete;
|
|
const moreCount = this._total_result_count - this.select?.querySelectorAll("sl-option.match").length;
|
|
if(this._total_result_count == 0 || moreCount == 0 || !this.select)
|
|
{
|
|
return nothing;
|
|
}
|
|
const more = this.egw().lang("%1 more...", moreCount);
|
|
|
|
return html`<span class="more">${more}</span>`;
|
|
}
|
|
|
|
protected _searchInputTemplate()
|
|
{
|
|
let edit = nothing;
|
|
if(this.editModeEnabled)
|
|
{
|
|
edit = html`<input id="edit" type="text" part="input" autocomplete="off" style="width:100%"
|
|
@keydown=${this._handleEditKeyDown}
|
|
@click=${(e) => e.stopPropagation()}
|
|
@blur=${this.stopEdit.bind(this)}
|
|
/>`;
|
|
}
|
|
return html`
|
|
<div class="search_input" slot="prefix">
|
|
<et2-textbox id="search" type="text" part="input"
|
|
exportparts="base:search__base"
|
|
clearable
|
|
autocomplete="off"
|
|
tabindex="-1"
|
|
placeholder="${this.egw().lang("search")}"
|
|
style="flex: 1 1 auto;"
|
|
@keydown=${this._handleSearchKeyDown}
|
|
@blur=${this._handleSearchBlur}
|
|
@sl-clear=${this._handleSearchClear}
|
|
@sl-change=${this._handleSearchChange}
|
|
></et2-textbox>
|
|
${edit}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
protected _noResultsTemplate()
|
|
{
|
|
if(this._total_result_count !== 0 || !this._searchInputNode?.value)
|
|
{
|
|
return nothing;
|
|
}
|
|
|
|
return html`
|
|
<div class="no-results">${this.egw().lang("no suggestions")}</div>`;
|
|
}
|
|
|
|
/**
|
|
* Do we have the needed properties set, so we can actually do searching
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
public get searchEnabled() : boolean
|
|
{
|
|
return !this.readonly && (this.search || this.searchUrl.length > 0);
|
|
}
|
|
|
|
protected get _searchInputNode() : Et2Textbox
|
|
{
|
|
return this._activeControls?.querySelector("#search");
|
|
}
|
|
|
|
protected get _editInputNode() : HTMLInputElement
|
|
{
|
|
return this._activeControls?.querySelector("input#edit");
|
|
}
|
|
|
|
protected get _activeControls()
|
|
{
|
|
return this.shadowRoot?.querySelector(".search_input") ||
|
|
this.querySelector(".search_input");
|
|
}
|
|
|
|
protected get optionTag()
|
|
{
|
|
return 'sl-option';
|
|
}
|
|
|
|
/**
|
|
* Only local options, excludes server options
|
|
*
|
|
* @protected
|
|
*/
|
|
protected get localItems() : NodeList
|
|
{
|
|
return this.select.querySelectorAll(this.optionTag + ":not(.remote)");
|
|
}
|
|
|
|
/**
|
|
* Only remote options from search results
|
|
* @returns {NodeList}
|
|
* @protected
|
|
*/
|
|
protected get remoteItems() : NodeList
|
|
{
|
|
return this.select?.querySelectorAll(this.optionTag + ".remote") ?? [];
|
|
}
|
|
|
|
/**
|
|
* Only free entries
|
|
* @returns {NodeList}
|
|
* @protected
|
|
*/
|
|
protected get freeEntries() : NodeList
|
|
{
|
|
return this.select?.querySelectorAll(this.optionTag + ".freeEntry") ?? [];
|
|
}
|
|
|
|
get select_options() : SelectOption[]
|
|
{
|
|
let options = [];
|
|
|
|
// Any provided options
|
|
options = options.concat(this.__select_options ?? []);
|
|
|
|
// Any kept remote options
|
|
options = options.concat(this._selected_remote ?? []);
|
|
|
|
// Current search results
|
|
options = options.concat(this._remote_options ?? []);
|
|
|
|
if(this.allowFreeEntries)
|
|
{
|
|
this.freeEntries.forEach((item : SlOption) =>
|
|
{
|
|
if(!options.some(i => i.value == item.value.replaceAll("___", " ")))
|
|
{
|
|
options.push({value: item.value, label: item.textContent, class: item.classList.toString()});
|
|
}
|
|
})
|
|
}
|
|
return options;
|
|
}
|
|
|
|
set select_options(options : SelectOption[])
|
|
{
|
|
super.select_options = options;
|
|
|
|
// Remove any selected remote, they're real options now
|
|
for(let remote_index = this._selected_remote.length - 1; remote_index >= 0; remote_index--)
|
|
{
|
|
let remote = this._selected_remote[remote_index];
|
|
if(options.findIndex(o => o.value == remote.value) != -1)
|
|
{
|
|
this._selected_remote.splice(remote_index, 1);
|
|
this.querySelector('[value="' + remote.value + '"]')?.classList.remove("remote");
|
|
}
|
|
}
|
|
}
|
|
|
|
get value()
|
|
{
|
|
return super.value;
|
|
}
|
|
|
|
set value(new_value : string | string[])
|
|
{
|
|
super.value = new_value;
|
|
|
|
if(!new_value || !this.allowFreeEntries && !this.searchUrl)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// If widget is currently open, we may need to re-calculate search / dropdown positioning
|
|
if(this.isOpen)
|
|
{
|
|
this._handleMenuShow();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Some [part of a] value is missing from the available options, but should be there, so find and add it.
|
|
*
|
|
* This is used when not all options are sent to the client (search, link list). Ideally we want to send
|
|
* the options for the current value, but sometimes this is not the best option so here we search or create
|
|
* the option as needed. These are not free entries, but need to match some list somewhere.
|
|
*
|
|
* @param {string} newValueElement
|
|
* @protected
|
|
*/
|
|
protected _missingOption(newValueElement : string)
|
|
{
|
|
// Given a value we need to search for - this will add in all matches, including the one needed
|
|
this.remoteSearch(newValueElement, this.searchOptions).then((result : SelectOption[]) =>
|
|
{
|
|
// Re-set / update value since SlSelect probably removed it by now due to missing option
|
|
if(typeof this.select != "undefined")
|
|
{
|
|
this.select.value = this.shoelaceValue ?? this.value;
|
|
this.select.requestUpdate("value");
|
|
}
|
|
this.requestUpdate("value");
|
|
});
|
|
}
|
|
|
|
protected fix_bad_value()
|
|
{
|
|
if(!this.allowFreeEntries && !this.searchEnabled)
|
|
{
|
|
// Let regular select deal with it
|
|
return false;
|
|
}
|
|
const valueArray = Array.isArray(this.value) ? this.value : (!this.value ? [] : this.value.toString().split(','));
|
|
|
|
// Check any already found options
|
|
if(Object.values(this.getAllOptions()).filter((option) => valueArray.find(val => val == option.value)).length === 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
// TODO? Should we check the server, or just be OK with it? Passing the "current" value in sel_options makes sure the value is there
|
|
}
|
|
|
|
protected _bindListeners()
|
|
{
|
|
this.addEventListener("sl-clear", this._handleClear);
|
|
this.addEventListener("sl-show", this._handleMenuShow);
|
|
this.addEventListener("sl-after-show", this._handleAfterShow);
|
|
this.addEventListener("sl-hide", this._handleMenuHide);
|
|
|
|
// Need our own change to catch the change event from search input
|
|
this.addEventListener("change", this._handleChange);
|
|
|
|
if(this.allowFreeEntries)
|
|
{
|
|
this.addEventListener("paste", this._handlePaste);
|
|
}
|
|
|
|
this.updateComplete.then(() =>
|
|
{
|
|
// Search messes up event order. Since it throws its own bubbling change event,
|
|
// selecting an option fires 2 change events - 1 before the widget is finished adjusting, losing the value
|
|
// We catch all change events, then call this._oldChange only when value changes
|
|
this.removeEventListener("change", this._oldChange);
|
|
|
|
this._searchInputNode?.removeEventListener("change", this._searchInputNode.handleChange);
|
|
this._searchInputNode?.addEventListener("change", this._handleSearchChange);
|
|
|
|
// this.dropdown.querySelector('.select__label').addEventListener("change", this.handleTagEdit);
|
|
});
|
|
}
|
|
|
|
protected _unbindListeners()
|
|
{
|
|
this.removeEventListener("sl-select", this._handleSelect);
|
|
this.removeEventListener("sl-show", this._handleMenuShow);
|
|
this.removeEventListener("sl-after-show", this._handleAfterShow);
|
|
this.removeEventListener("sl-hide", this._handleMenuHide);
|
|
this.removeEventListener("sl-clear", this._handleClear)
|
|
this.removeEventListener("change", this._handleChange);
|
|
this.removeEventListener("paste", this._handlePaste);
|
|
|
|
this._searchInputNode?.removeEventListener("change", this._handleSearchChange);
|
|
}
|
|
|
|
_handleMenuShow()
|
|
{
|
|
if(this.readonly)
|
|
{
|
|
return;
|
|
}
|
|
this.setAttribute("open", "");
|
|
|
|
// Move search (& menu) if there's no value
|
|
this._activeControls?.classList.toggle("novalue", this.multiple && this.value == '' || !this.multiple);
|
|
|
|
// Reset for parent calculations, will be adjusted after if needed
|
|
//this.dropdown.setAttribute("distance", 0);
|
|
|
|
if(this.searchEnabled || this.allowFreeEntries)
|
|
{
|
|
this._activeControls?.classList.add("active");
|
|
// Hide edit explicitly since it's so hard via CSS
|
|
if(this._editInputNode)
|
|
{
|
|
this._editInputNode.style.display = "none";
|
|
}
|
|
}
|
|
|
|
if(this.editModeEnabled && this.allowFreeEntries && !this.multiple && this.value)
|
|
{
|
|
this.startEdit();
|
|
this._editInputNode.select();
|
|
// Hide search explicitly since it's so hard via CSS
|
|
this._searchInputNode.style.display = "none";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Focus the search input after showing the dropdown so user can just type.
|
|
*
|
|
* Timeout is needed for some systems to properly focus
|
|
*/
|
|
_handleAfterShow()
|
|
{
|
|
if(this.searchEnabled || this.allowFreeEntries)
|
|
{
|
|
window.setTimeout(() =>
|
|
{
|
|
this._searchInputNode.focus();
|
|
this._searchInputNode.select();
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
focus()
|
|
{
|
|
this.show().then(() =>
|
|
{
|
|
this._searchInputNode?.focus();
|
|
});
|
|
}
|
|
|
|
_handleMenuHide()
|
|
{
|
|
if(this.readonly)
|
|
{
|
|
return;
|
|
}
|
|
this.removeAttribute("open");
|
|
|
|
this.clearSearch();
|
|
|
|
// Reset display
|
|
if(this._searchInputNode)
|
|
{
|
|
this._searchInputNode.style.display = "";
|
|
}
|
|
if(this._editInputNode)
|
|
{
|
|
this._editInputNode.style.display = "";
|
|
}
|
|
|
|
this._activeControls?.classList.remove("active");
|
|
}
|
|
|
|
_triggerChange(event)
|
|
{
|
|
// Don't want searchbox events to trigger change event
|
|
if(event.target == this._searchInputNode)
|
|
{
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
return false;
|
|
}
|
|
|
|
// Find and keep any selected remote entries
|
|
// Doing it here catches keypress changes too
|
|
this._keepSelectedRemote();
|
|
return true;
|
|
}
|
|
|
|
_handleChange(event)
|
|
{
|
|
if(event.target == this._searchInputNode)
|
|
{
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
return false;
|
|
}
|
|
|
|
return this._oldChange(event);
|
|
}
|
|
|
|
_handleDoubleClick(event : MouseEvent)
|
|
{
|
|
// No edit (shouldn't happen...)
|
|
if(!this.editModeEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Find the tag
|
|
const path = event.composedPath();
|
|
const tag = <Et2Tag>path.find((el) => el instanceof Et2Tag);
|
|
this.hide();
|
|
this.updateComplete.then(() =>
|
|
{
|
|
tag.startEdit(event);
|
|
});
|
|
}
|
|
|
|
_keepSelectedRemote()
|
|
{
|
|
this.select.querySelectorAll("[aria-selected=true].remote").forEach((node) =>
|
|
{
|
|
const value = node.value.replaceAll("___", " ");
|
|
if(!node.selected || this._selected_remote.some(o => o.value == value))
|
|
{
|
|
return;
|
|
}
|
|
const filter = (options) =>
|
|
{
|
|
for(let i = options.length - 1; i >= 0; i--)
|
|
{
|
|
if(Array.isArray(options[i].value))
|
|
{
|
|
filter(options[i].value);
|
|
}
|
|
else if(options[i].value == value)
|
|
{
|
|
this._selected_remote.push(options[i]);
|
|
options.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
filter(this._remote_options)
|
|
});
|
|
}
|
|
/**
|
|
* An option was selected
|
|
*/
|
|
handleOptionClick(event)
|
|
{
|
|
// Only interested in option clicks, but handler is bound higher
|
|
if(event.target.tagName !== "SL-OPTION")
|
|
{
|
|
return;
|
|
}
|
|
|
|
if(typeof super.handleOptionClick == "function")
|
|
super.handleOptionClick(event);
|
|
|
|
this.updateComplete.then(() =>
|
|
{
|
|
// If they just chose one from the list, re-focus the search
|
|
if(this.multiple && this.searchEnabled)
|
|
{
|
|
this._searchInputNode.focus();
|
|
this._searchInputNode.select();
|
|
}
|
|
else if(!this.multiple && this.searchEnabled)
|
|
{
|
|
// Stop all the search stuff when they select an option
|
|
// this shows all non-matching options again
|
|
this._handleSearchAbort(event);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Value was cleared
|
|
*/
|
|
_handleClear(e)
|
|
{
|
|
// Only keep remote options that are still used
|
|
this._selected_remote = this._selected_remote.filter((option) => this.value.indexOf(option.value) !== -1);
|
|
|
|
if(!this.multiple && this.searchEnabled)
|
|
{
|
|
this._handleSearchAbort(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle blur from search field
|
|
*
|
|
* Either the user changed fields, or selected an option. For selecting don't interfere, but for
|
|
* changing fields we need to make sure the menu is hidden.
|
|
*
|
|
* @param event
|
|
*/
|
|
async _handleSearchBlur(event : FocusEvent)
|
|
{
|
|
clearTimeout(this._searchTimeout);
|
|
}
|
|
|
|
/**
|
|
* Handle keypresses inside the search input
|
|
* @param {KeyboardEvent} event
|
|
* @protected
|
|
*/
|
|
protected _handleSearchKeyDown(event : KeyboardEvent)
|
|
{
|
|
clearTimeout(this._searchTimeout);
|
|
this._activeControls?.classList.add("active");
|
|
|
|
// Pass off some keys to select
|
|
if(['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key))
|
|
{
|
|
|
|
// Strip out hidden non-matching selected & disabled items so key navigation works
|
|
// TODO
|
|
return;
|
|
}
|
|
else if(event.key == "Tab" && !this._searchInputNode.value)
|
|
{
|
|
// Mess with tabindexes to allow focus to easily go to next control
|
|
const input = this.select.shadowRoot.querySelector('[tabindex="0"]');
|
|
input.setAttribute("tabindex", "-1");
|
|
this.updateComplete.then(() =>
|
|
{
|
|
// Set it back so we can get focus again later
|
|
input.setAttribute("tabindex", "0");
|
|
})
|
|
// Allow to propagate
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
|
|
// Don't allow event to bubble or it will interact with select
|
|
event.stopImmediatePropagation();
|
|
if(Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value))
|
|
{
|
|
event.preventDefault();
|
|
this._searchInputNode.value = "";
|
|
this.updateComplete.then(async() =>
|
|
{
|
|
// update sizing / position before getting ready for another one
|
|
if(this.multiple)
|
|
{
|
|
// await this.show();
|
|
this._searchInputNode.focus();
|
|
}
|
|
});
|
|
}
|
|
else if(event.key == "Enter")
|
|
{
|
|
event.preventDefault();
|
|
this.startSearch();
|
|
return;
|
|
}
|
|
else if(event.key == "Escape")
|
|
{
|
|
this._handleSearchAbort(event);
|
|
this.hide();
|
|
return;
|
|
}
|
|
|
|
// Start the search automatically if they have enough letters
|
|
// -1 because we're in keyDown handler, and value is from _before_ this key was pressed
|
|
if(this._searchInputNode.value.length >= Et2WidgetWithSearch.MIN_CHARS - 1)
|
|
{
|
|
this._searchTimeout = window.setTimeout(() => {this.startSearch()}, Et2WidgetWithSearch.SEARCH_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Combobox listens for mousedown, which interferes with search clear button.
|
|
* Here we block it from bubbling
|
|
* @param {MouseEvent} event
|
|
* @protected
|
|
*/
|
|
protected _handleSearchMouseDown(event : MouseEvent)
|
|
{
|
|
event.stopPropagation();
|
|
}
|
|
|
|
protected _handleEditKeyDown(event : KeyboardEvent)
|
|
{
|
|
// Stop propagation, or parent key handler will add again
|
|
event.stopImmediatePropagation();
|
|
|
|
if(Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries)
|
|
{
|
|
// Prevent default, since that would try to submit
|
|
event.preventDefault();
|
|
this.stopEdit();
|
|
}
|
|
// Abort edit, put original value back
|
|
else if(event.key == "Escape")
|
|
{
|
|
this.stopEdit(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sometimes users paste multiple comma separated values at once. Split them then handle normally.
|
|
*
|
|
* @param {ClipboardEvent} event
|
|
* @protected
|
|
*/
|
|
protected _handlePaste(event : ClipboardEvent)
|
|
{
|
|
event.preventDefault();
|
|
|
|
let paste = event.clipboardData.getData('text');
|
|
if(!paste)
|
|
{
|
|
return;
|
|
}
|
|
const selection = window.getSelection();
|
|
if(selection.rangeCount)
|
|
{
|
|
selection.deleteFromDocument();
|
|
}
|
|
let values = paste.split(/,\t/);
|
|
|
|
values.forEach(v =>
|
|
{
|
|
this.createFreeEntry(v.trim());
|
|
});
|
|
this.dropdown.hide();
|
|
}
|
|
|
|
/**
|
|
* 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 async startSearch()
|
|
{
|
|
// Stop timeout timer
|
|
clearTimeout(this._searchTimeout);
|
|
|
|
this.setAttribute("searching", "");
|
|
|
|
// Show a spinner
|
|
let spinner = document.createElement("sl-spinner");
|
|
spinner.slot = "expand-icon";
|
|
this.select.appendChild(spinner);
|
|
|
|
// Hide clear button
|
|
let clear_button = <HTMLElement>this._searchInputNode?.shadowRoot?.querySelector(".input__clear");
|
|
if(clear_button)
|
|
{
|
|
clear_button.style.display = "none";
|
|
}
|
|
|
|
// Clear previous results
|
|
this._total_result_count = 0;
|
|
this._clearResults();
|
|
await this.updateComplete;
|
|
|
|
// Start the searches
|
|
return Promise.all([
|
|
this.localSearch(this._searchInputNode.value, this.searchOptions),
|
|
this.remoteSearch(this._searchInputNode.value, this.searchOptions)
|
|
]).then(async() =>
|
|
{
|
|
this.removeAttribute("searching");
|
|
// Remove spinner
|
|
spinner.remove();
|
|
|
|
// Restore clear button
|
|
if(clear_button)
|
|
{
|
|
clear_button.style.display = "";
|
|
}
|
|
await this.updateComplete;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear search term and any search results
|
|
*
|
|
* Local options are not removed, but remote options are
|
|
*/
|
|
public clearSearch()
|
|
{
|
|
// Stop timeout timer
|
|
clearTimeout(this._searchTimeout);
|
|
|
|
this._clearResults();
|
|
|
|
// Clear search term
|
|
if(this._searchInputNode)
|
|
{
|
|
this._searchInputNode.value = "";
|
|
}
|
|
}
|
|
|
|
protected _clearResults()
|
|
{
|
|
let target = this._optionTargetNode || this;
|
|
|
|
this._keepSelectedRemote();
|
|
|
|
this._remote_options = [];
|
|
|
|
this._total_result_count = 0;
|
|
|
|
// Not searching anymore, clear flag
|
|
const clear_flag = (option) =>
|
|
{
|
|
if(Array.isArray(option.value))
|
|
{
|
|
option.value.map(clear_flag)
|
|
}
|
|
else
|
|
{
|
|
option.isMatch = null
|
|
}
|
|
}
|
|
this.select_options.map(clear_flag);
|
|
this.requestUpdate("select_options");
|
|
|
|
// Rendering options using repeat() means we need to explicitly update the nodes since they
|
|
// don't always get re-rendered
|
|
for(const option of this.select.querySelectorAll(".no-match"))
|
|
{
|
|
option.classList.remove("no-match", "match");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter the local options
|
|
*
|
|
* @param {string} search
|
|
* @protected
|
|
*/
|
|
protected localSearch(search : string, options : object) : Promise<void>
|
|
{
|
|
return new Promise((resolve) =>
|
|
{
|
|
this.select_options.forEach((option) =>
|
|
{
|
|
option.isMatch = this.searchMatch(search, option);
|
|
})
|
|
this.requestUpdate("select_options");
|
|
resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ask for remote options and add them in unconditionally
|
|
* @param {string} search
|
|
* @protected
|
|
*/
|
|
protected remoteSearch(search : string, options : object) : Promise<SelectOption[]>
|
|
{
|
|
if(!this.searchUrl)
|
|
{
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
// Check our URL: JSON file or URL?
|
|
if(this.searchUrl.includes(".json"))
|
|
{
|
|
// Get the file, search it
|
|
return this.jsonQuery(search, options);
|
|
}
|
|
else
|
|
{
|
|
// Fire off the query to the server
|
|
return this.remoteQuery(search, options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search through a JSON file in the browser
|
|
*
|
|
* @param {string} search
|
|
* @param {object} options
|
|
* @protected
|
|
*/
|
|
protected jsonQuery(search : string, options : object) : Promise<SelectOption[]>
|
|
{
|
|
// Get the file
|
|
const controller = new AbortController();
|
|
const signal = controller.signal;
|
|
let response_ok = false;
|
|
return StaticOptions.cached_from_file(this, this.searchUrl)
|
|
.then(options =>
|
|
{
|
|
// Filter the options
|
|
const lower_search = search.toLowerCase();
|
|
const filtered = options.filter(option =>
|
|
{
|
|
return option.label.toLowerCase().includes(lower_search) || option.value.includes(search)
|
|
});
|
|
// Limit results
|
|
this._total_result_count += filtered.length;
|
|
if(filtered.length > Et2WidgetWithSearch.RESULT_LIMIT)
|
|
{
|
|
filtered.splice(Et2WidgetWithSearch.RESULT_LIMIT);
|
|
}
|
|
// Add the matches
|
|
this._total_result_count -= this.processRemoteResults(filtered);
|
|
return filtered;
|
|
})
|
|
.catch((_err) =>
|
|
{
|
|
this.egw().message(_err.statusText || this.searchUrl, "error");
|
|
return [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Actually query the server.
|
|
*
|
|
* This can be overridden to change request parameters or eg. send them as POST parameters.
|
|
*
|
|
* Default implementation here sends search string and options:
|
|
* - as two parameters to the AJAX function
|
|
* - and (additional) as GET parameters plus search string as "query"
|
|
*
|
|
* This is done to support as well the old taglist callbacks, as the regular select ones!
|
|
*
|
|
* @param {string} search
|
|
* @param {object} options
|
|
* @returns {any}
|
|
* @protected
|
|
*/
|
|
protected remoteQuery(search : string, options : object) : Promise<SelectOption[]>
|
|
{
|
|
// Include a limit, even if options don't, to avoid massive lists breaking the UI
|
|
let sendOptions = {
|
|
num_rows: Et2WidgetWithSearch.RESULT_LIMIT,
|
|
...options
|
|
}
|
|
return this.egw().request(this.egw().link(this.egw().ajaxUrl(this.egw().decodePath(this.searchUrl)),
|
|
{query: search, ...sendOptions}), [search, sendOptions]).then((results) =>
|
|
{
|
|
return this._processResultCount(results);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update total result count, checking results for a total attribute, then further processing the results
|
|
* into select options
|
|
*
|
|
* @param results
|
|
* @returns {SelectOption[]}
|
|
* @protected
|
|
*/
|
|
protected _processResultCount(results)
|
|
{
|
|
// If results have a total included, pull it out.
|
|
// It will cause errors if left in the results
|
|
if(typeof results.total !== "undefined")
|
|
{
|
|
this._total_result_count += results.total;
|
|
delete results.total;
|
|
// Make it an array, since it was probably an object, and cleanSelectOptions() treats objects differently
|
|
results = Object.values(results);
|
|
}
|
|
else
|
|
{
|
|
this._total_result_count += results.length;
|
|
}
|
|
let entries = cleanSelectOptions(results);
|
|
let entryCount = entries.length;
|
|
this._total_result_count -= this.processRemoteResults(entries);
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Add in remote results
|
|
*
|
|
* Any results that already exist will be removed to avoid duplicates
|
|
*
|
|
* @param results
|
|
* @return Duplicate count
|
|
* @protected
|
|
*/
|
|
protected processRemoteResults(entries)
|
|
{
|
|
if(!entries?.length)
|
|
{
|
|
return 0;
|
|
}
|
|
let duplicateCount = 0;
|
|
|
|
const process = (entries) =>
|
|
{
|
|
// Add a "remote" class so we can tell these apart from any local results
|
|
for(let i = entries.length - 1; i >= 0; i--)
|
|
{
|
|
const entry = entries[i];
|
|
entry.class = (entry.class || "") + " remote";
|
|
|
|
// Handle option groups
|
|
if(Array.isArray(entry.value))
|
|
{
|
|
process(entry.value);
|
|
continue;
|
|
}
|
|
|
|
// Server says it's a match
|
|
entry.isMatch = true;
|
|
|
|
// Avoid duplicates with existing options
|
|
if(this.select_options.some(o => o.value == entry.value))
|
|
{
|
|
duplicateCount++
|
|
entries.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
process(entries);
|
|
|
|
this._remote_options = entries;
|
|
this.requestUpdate("select_options");
|
|
|
|
return duplicateCount;
|
|
}
|
|
|
|
/**
|
|
* Check if one of our [local] items matches the search
|
|
*
|
|
* @param search
|
|
* @param item
|
|
* @returns {boolean}
|
|
* @protected
|
|
*/
|
|
protected searchMatch(search, option : SelectOption) : boolean
|
|
{
|
|
if(!option || !option.value)
|
|
{
|
|
return false;
|
|
}
|
|
if(option.label?.toLowerCase().includes(search.toLowerCase()))
|
|
{
|
|
return true;
|
|
}
|
|
if(typeof option.value == "string")
|
|
{
|
|
return option.value.includes(search.toLowerCase());
|
|
}
|
|
return option.value == search;
|
|
}
|
|
|
|
/**
|
|
* Create an entry that is not in the options and add it to the value
|
|
*
|
|
* @param {string} text Used as both value and label
|
|
*/
|
|
public createFreeEntry(text : string) : boolean
|
|
{
|
|
if(!text || !this.validateFreeEntry(text))
|
|
{
|
|
return false;
|
|
}
|
|
// Make sure not to double-add
|
|
if(!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.select_options.find(o => o.value == text))
|
|
{
|
|
this.__select_options.push(<SelectOption>{
|
|
value: text.trim(),
|
|
label: text.trim(),
|
|
class: "freeEntry",
|
|
isMatch: false
|
|
});
|
|
this.requestUpdate('select_options');
|
|
}
|
|
|
|
// Make sure not to double-add, but wait until the option is there
|
|
if(this.multiple && this.getValueAsArray().indexOf(text) == -1)
|
|
{
|
|
let value = this.getValueAsArray();
|
|
value.push(text);
|
|
this.value = value;
|
|
}
|
|
else if(!this.multiple && this.value !== text)
|
|
{
|
|
this.value = text;
|
|
}
|
|
this.dispatchEvent(new Event("change", {bubbles: true}));
|
|
|
|
// If we were overlapping edit inputbox with the value display, reset
|
|
if(!this.readonly && this._activeControls?.classList.contains("novalue"))
|
|
{
|
|
this._searchInputNode.style.display = "";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a free entry value is acceptable.
|
|
* We use validators directly using the proposed value
|
|
*
|
|
* @param text
|
|
* @returns {boolean}
|
|
*/
|
|
public validateFreeEntry(text) : boolean
|
|
{
|
|
let validators = [...this.validators, ...this.defaultValidators];
|
|
let result = validators.filter(v =>
|
|
v.execute(text, v.param, {node: this}),
|
|
);
|
|
return validators.length > 0 && result.length == 0 || validators.length == 0;
|
|
}
|
|
|
|
public handleTagEdit(event)
|
|
{
|
|
let value = event.target.value;
|
|
let original = event.target.dataset.original_value;
|
|
|
|
if(!value || !this.allowFreeEntries || !this.validateFreeEntry(value))
|
|
{
|
|
// Not a good value, reset it.
|
|
event.target.variant = "danger"
|
|
return false;
|
|
}
|
|
|
|
event.target.variant = "success";
|
|
|
|
// Add to internal list
|
|
this.createFreeEntry(value);
|
|
|
|
// Remove original from value & DOM
|
|
if(value != original)
|
|
{
|
|
if(this.multiple)
|
|
{
|
|
this.value = this.value.filter(v => v !== original);
|
|
}
|
|
else
|
|
{
|
|
this.value = value;
|
|
}
|
|
this.__select_options = this.__select_options.filter(v => v.value !== original);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start editing the current value if multiple=false
|
|
*
|
|
* @param {Et2Tag} tag
|
|
*/
|
|
public startEdit(tag? : Et2Tag)
|
|
{
|
|
const tag_value = tag ? tag.value : this.value;
|
|
|
|
// hide the menu
|
|
this.dropdown.hide()
|
|
|
|
waitForEvent(this, "sl-after-hide").then(() =>
|
|
{
|
|
// Turn on edit UI
|
|
this._activeControls.classList.add("editing", "active");
|
|
|
|
// Pre-set value to tag value
|
|
this._editInputNode.style.display = "";
|
|
this._editInputNode.value = tag_value
|
|
this._editInputNode.focus();
|
|
|
|
// If they abort the edit, they'll want the original back.
|
|
this._editInputNode.dataset.initial = tag_value;
|
|
})
|
|
}
|
|
|
|
protected stopEdit(abort = false)
|
|
{
|
|
// type to select will focus matching entries, but we don't want to stop the edit yet
|
|
if(typeof abort == "object" && abort.type == "blur")
|
|
{
|
|
if(abort.relatedTarget?.localName == this.optionTag)
|
|
{
|
|
return;
|
|
}
|
|
// Edit lost focus, accept changes
|
|
abort = false;
|
|
}
|
|
|
|
const original = this._editInputNode.dataset.initial;
|
|
delete this._editInputNode.dataset.initial;
|
|
|
|
let value = abort ? original : this._editInputNode.value;
|
|
this._editInputNode.value = "";
|
|
|
|
if(value && value != original)
|
|
{
|
|
this.createFreeEntry(value);
|
|
|
|
this.updateComplete.then(() =>
|
|
{
|
|
const item = this.querySelector("[value='" + value.replace(/'/g, "\\\'") + "']");
|
|
item.dispatchEvent(new CustomEvent("sl-select", {detail: {item}}));
|
|
})
|
|
}
|
|
|
|
// Remove original from value & DOM
|
|
if(value != original)
|
|
{
|
|
if(this.multiple)
|
|
{
|
|
this.value = this.value.filter(v => v !== original);
|
|
this.querySelector("[value='" + original.replace(/'/g, "\\\'") + "']")?.remove();
|
|
}
|
|
else
|
|
{
|
|
this.value = value;
|
|
}
|
|
this.select_options = this.select_options.filter(v => v.value !== original);
|
|
}
|
|
|
|
|
|
this._activeControls.classList.remove("editing", "active");
|
|
if(!this.multiple)
|
|
{
|
|
this.updateComplete.then(async() =>
|
|
{
|
|
// Don't know why, but this doesn't always work leaving the value hidden by prefix
|
|
await this.dropdown.hide();
|
|
this.dropdown.classList.remove("select--open");
|
|
this.dropdown.panel.setAttribute("hidden", "");
|
|
});
|
|
}
|
|
}
|
|
|
|
protected _handleSearchAbort(e)
|
|
{
|
|
this._activeControls.classList.remove("active");
|
|
this.clearSearch();
|
|
}
|
|
|
|
/**
|
|
* et2-searchbox (SlInput) sends out an event on change.
|
|
* We don't care, and if we let it bubble it'll get in the way.
|
|
* @param e
|
|
* @protected
|
|
*/
|
|
protected _handleSearchChange(e)
|
|
{
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
|
|
protected _handleSearchClear(e)
|
|
{
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
this.clearSearch();
|
|
}
|
|
}
|
|
|
|
return Et2WidgetWithSearch as unknown as Constructor<SearchMixinInterface> & T;
|
|
});
|