2022-05-31 01:05:00 +02:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
import {css, html, LitElement, render, repeat, SlotMixin} from "@lion/core";
|
2022-06-01 17:23:07 +02:00
|
|
|
import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
|
2022-06-10 18:11:34 +02:00
|
|
|
import {Validator} from "@lion/form-core";
|
|
|
|
import {Et2Tag} from "./Tag/Et2Tag";
|
2022-05-31 01:05:00 +02:00
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
// Otherwise import gets stripped
|
|
|
|
let keep_import : Et2Tag;
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
2022-05-31 21:40:31 +02:00
|
|
|
/**
|
|
|
|
* Additional search options passed to the search functions
|
|
|
|
*
|
|
|
|
* @type {object}
|
|
|
|
*/
|
|
|
|
searchOptions : object;
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Start the search process
|
|
|
|
*/
|
|
|
|
startSearch() : void
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Search local options
|
|
|
|
*/
|
2022-05-31 21:40:31 +02:00
|
|
|
localSearch(search : string, options : object) : Promise<void>
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Search remote options.
|
|
|
|
* If searchUrl is not set, it will return very quickly with no results
|
|
|
|
*/
|
2022-05-31 21:40:31 +02:00
|
|
|
remoteSearch(search : string, options : object) : Promise<void>
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check a [local] item to see if it matches
|
|
|
|
*/
|
2022-05-31 21:40:31 +02:00
|
|
|
searchMatch(search : string, options : object, item : LitElement) : boolean
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2022-06-10 18:11:34 +02:00
|
|
|
export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass : T) =>
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
|
|
|
class Et2WidgetWithSearch extends SlotMixin(superclass)
|
|
|
|
{
|
|
|
|
static get properties()
|
|
|
|
{
|
|
|
|
return {
|
|
|
|
...super.properties,
|
|
|
|
search: {type: Boolean, reflect: true},
|
|
|
|
|
|
|
|
searchUrl: {type: String},
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
/**
|
|
|
|
* Allow custom entries that are not in the options
|
|
|
|
*/
|
|
|
|
allowFreeEntries: {type: Boolean, reflect: true},
|
2022-05-31 21:40:31 +02:00
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
searchOptions: {type: Object},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Allow editing tags by clicking on them.
|
|
|
|
* allowFreeEntries must be true
|
|
|
|
*/
|
|
|
|
editModeEnabled: {type: Boolean}
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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`
|
2022-06-10 18:11:34 +02:00
|
|
|
/* Show / hide SlSelect icons - dropdown arrow, etc */
|
2022-05-31 01:05:00 +02:00
|
|
|
::slotted([slot="suffix"]) {
|
|
|
|
display: none;
|
|
|
|
}
|
|
|
|
:host([search]) ::slotted([slot="suffix"]) {
|
|
|
|
display: initial;
|
|
|
|
}
|
2022-06-14 01:21:35 +02:00
|
|
|
/* Move the widget border */
|
|
|
|
.form-control-input {
|
|
|
|
border: solid var(--sl-input-border-width) var(--sl-input-border-color);
|
|
|
|
border-radius: var(--sl-input-border-radius-medium);
|
|
|
|
}
|
|
|
|
.select--standard .select__control {
|
|
|
|
border-style: none;
|
|
|
|
}
|
|
|
|
/* Move focus highlight */
|
|
|
|
.form-control-input:focus-within {
|
|
|
|
box-shadow: var(--sl-focus-ring);
|
|
|
|
}
|
|
|
|
.select--standard.select--focused:not(.select--disabled) .select__control {
|
|
|
|
box-shadow: initial;
|
|
|
|
}
|
2022-06-10 18:11:34 +02:00
|
|
|
:host([allowFreeEntries]) ::slotted([slot="suffix"]) {
|
|
|
|
display: none;
|
|
|
|
}
|
|
|
|
|
2022-06-13 17:58:46 +02:00
|
|
|
/* Make search textbox take full width */
|
2022-06-14 01:21:35 +02:00
|
|
|
::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input {
|
|
|
|
width: 100%;
|
|
|
|
}
|
|
|
|
.search_input input {
|
|
|
|
flex: 1 1 auto;
|
2022-05-31 01:05:00 +02:00
|
|
|
width: 100%;
|
|
|
|
}
|
2022-06-14 17:09:11 +02:00
|
|
|
/* Don't show the current value while searching for single, we want the space
|
|
|
|
This lets the current value shrink to nothing so the input can expand
|
|
|
|
*/
|
|
|
|
.select__label {
|
|
|
|
flex: 1 15 auto;
|
|
|
|
}
|
2022-06-14 01:21:35 +02:00
|
|
|
/* 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]:not([multiple])) .select--open .select__prefix {
|
2022-06-02 23:44:13 +02:00
|
|
|
flex: 2 1 auto;
|
|
|
|
width: 100%;
|
|
|
|
}
|
2022-06-10 18:11:34 +02:00
|
|
|
|
|
|
|
/* Search textbox general styling, starts hidden */
|
2022-06-14 01:21:35 +02:00
|
|
|
.select__prefix ::slotted(.search_input),.search_input {
|
2022-05-31 01:05:00 +02:00
|
|
|
display: none;
|
2022-06-14 01:21:35 +02:00
|
|
|
flex: 1 1 auto;
|
2022-05-31 01:05:00 +02:00
|
|
|
margin-left: 0px;
|
|
|
|
width: 100%;
|
2022-06-14 01:21:35 +02:00
|
|
|
height: var(--sl-input-height-medium);
|
|
|
|
position: relative;
|
|
|
|
background-color: white;
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
2022-06-10 18:11:34 +02:00
|
|
|
/* Search UI active - show textbox & stuff */
|
2022-06-14 01:21:35 +02:00
|
|
|
::slotted(.search_input.active),.search_input.active,
|
|
|
|
.search_input.editing{
|
2022-05-31 21:40:31 +02:00
|
|
|
display: flex;
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
2022-06-10 18:11:34 +02:00
|
|
|
|
|
|
|
/* Hide options that do not match current search text */
|
2022-05-31 01:05:00 +02:00
|
|
|
::slotted(.no-match) {
|
|
|
|
display: none;
|
|
|
|
}
|
2022-06-13 17:58:46 +02:00
|
|
|
/* Hide selected options from the dropdown */
|
|
|
|
::slotted([checked])
|
|
|
|
{
|
|
|
|
display: none;
|
|
|
|
}
|
2022-06-10 22:11:57 +02:00
|
|
|
/* Different cursor for editable tags */
|
|
|
|
:host([allowfreeentries]) .search_tag::part(base) {
|
|
|
|
cursor: text;
|
|
|
|
}
|
|
|
|
/* styling for icon inside tag (not option) */
|
|
|
|
.tag_image {
|
|
|
|
margin-right: var(--sl-spacing-x-small);
|
|
|
|
}
|
2022-06-14 17:37:21 +02:00
|
|
|
/* Maximum height + scrollbar on tags */
|
|
|
|
.select__tags {
|
|
|
|
max-height: 5em;
|
|
|
|
overflow-y: auto;
|
|
|
|
}
|
2022-06-10 18:11:34 +02:00
|
|
|
/* Keep overflow tag right-aligned. It's the only sl-tag. */
|
|
|
|
.select__tags sl-tag {
|
|
|
|
margin-left: auto;
|
|
|
|
}
|
2022-05-31 01:05:00 +02:00
|
|
|
`
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
// Borrowed from Lion ValidatorMixin, but we don't want the whole thing
|
|
|
|
protected defaultValidators : Validator[];
|
|
|
|
protected validators : Validator[];
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
private _searchTimeout : number;
|
2022-05-31 21:40:31 +02:00
|
|
|
protected static SEARCH_TIMEOUT = 500;
|
2022-05-31 01:05:00 +02:00
|
|
|
protected static MIN_CHARS = 2;
|
2022-06-14 01:21:35 +02:00
|
|
|
/**
|
|
|
|
* These characters will end a free tag
|
|
|
|
* @type {string[]}
|
|
|
|
*/
|
|
|
|
static TAG_BREAK : string[] = ["Tab", "Enter", ","];
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
constructor(...args : any[])
|
|
|
|
{
|
|
|
|
super(...args);
|
|
|
|
|
2022-05-31 21:40:31 +02:00
|
|
|
this.search = false;
|
|
|
|
this.searchUrl = "";
|
|
|
|
this.searchOptions = {};
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
this.allowFreeEntries = false;
|
|
|
|
this.editModeEnabled = false;
|
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
// Hiding the selected options from the dropdown means we can't un-select the tags
|
|
|
|
// hidden by the max limit. Prefer no limit.
|
|
|
|
this.maxTagsVisible = -1;
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
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 = [];
|
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
this._handleSelect = this._handleSelect.bind(this);
|
2022-06-14 18:06:27 +02:00
|
|
|
this._handleClear = this._handleClear.bind(this);
|
2022-05-31 01:05:00 +02:00
|
|
|
this._handleSearchButtonClick = this._handleSearchButtonClick.bind(this);
|
2022-06-14 01:21:35 +02:00
|
|
|
this._handleDoubleClick = this._handleDoubleClick.bind(this);
|
2022-05-31 01:05:00 +02:00
|
|
|
this._handleSearchAbort = this._handleSearchAbort.bind(this);
|
|
|
|
this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
|
2022-06-14 01:21:35 +02:00
|
|
|
this._handleEditKeyDown = this._handleEditKeyDown.bind(this);
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
connectedCallback()
|
|
|
|
{
|
|
|
|
super.connectedCallback();
|
|
|
|
|
2022-06-02 23:44:13 +02:00
|
|
|
this.classList.toggle("search", this.searchEnabled);
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
// Missing any of the required attributes? Don't change anything.
|
2022-06-02 23:44:13 +02:00
|
|
|
if(!this.searchEnabled)
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this._addNodes();
|
|
|
|
this._bindListeners();
|
|
|
|
}
|
|
|
|
|
|
|
|
disconnectedCallback()
|
|
|
|
{
|
|
|
|
super.disconnectedCallback();
|
|
|
|
this._unbindListeners();
|
|
|
|
}
|
|
|
|
|
2022-06-10 22:11:57 +02:00
|
|
|
willUpdate(changedProperties)
|
|
|
|
{
|
|
|
|
super.willUpdate(changedProperties);
|
|
|
|
|
|
|
|
// If searchURL is set, turn on search
|
|
|
|
if(changedProperties.has("searchUrl") && this.searchUrl)
|
|
|
|
{
|
|
|
|
this.search = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
/**
|
2022-06-14 01:21:35 +02:00
|
|
|
* Add the nodes we need to search - adjust parent shadowDOM
|
2022-05-31 01:05:00 +02:00
|
|
|
*
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected _addNodes()
|
|
|
|
{
|
|
|
|
const div = document.createElement("div");
|
|
|
|
div.classList.add("search_input");
|
|
|
|
render(this._searchInputTemplate(), div);
|
2022-06-14 01:21:35 +02:00
|
|
|
if(!super.multiple)
|
|
|
|
{
|
|
|
|
div.slot = "prefix";
|
|
|
|
this.appendChild(div);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
super.updateComplete.then(() =>
|
|
|
|
{
|
|
|
|
let control = this.shadowRoot.querySelector(".form-control-input");
|
|
|
|
control.append(div);
|
|
|
|
|
|
|
|
// Move menu down to make space for search
|
|
|
|
this.dropdown.setAttribute("distance", parseInt(getComputedStyle(div).getPropertyValue("--sl-input-height-medium")));
|
|
|
|
});
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
protected _searchInputTemplate()
|
|
|
|
{
|
2022-06-14 01:21:35 +02:00
|
|
|
let edit = '';
|
|
|
|
if(this.editModeEnabled)
|
|
|
|
{
|
|
|
|
edit = html`<input id="edit" type="text" part="input"
|
|
|
|
@keydown=${this._handleEditKeyDown}
|
|
|
|
@blur=${this.stopEdit.bind(this)}
|
|
|
|
/>`;
|
|
|
|
}
|
2022-06-02 23:44:13 +02:00
|
|
|
// I can't figure out how to get this full width via CSS
|
2022-05-31 01:05:00 +02:00
|
|
|
return html`
|
2022-06-14 17:09:11 +02:00
|
|
|
<input id="search" type="text" part="input" style="width:100%" @keydown=${this._handleSearchKeyDown}/>
|
2022-06-14 01:21:35 +02:00
|
|
|
${edit}
|
2022-05-31 01:05:00 +02:00
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2022-05-31 21:40:31 +02:00
|
|
|
/**
|
|
|
|
* Do we have the needed properties set, so we can actually do searching
|
|
|
|
*
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
public get searchEnabled() : boolean
|
|
|
|
{
|
|
|
|
return this.search || this.searchUrl.length > 0;
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
protected get _searchButtonNode()
|
|
|
|
{
|
|
|
|
return this.querySelector("sl-icon[slot='suffix']");
|
|
|
|
}
|
|
|
|
|
|
|
|
protected get _searchInputNode()
|
|
|
|
{
|
2022-06-14 01:21:35 +02:00
|
|
|
return this._activeControls.querySelector("input#search");
|
|
|
|
}
|
|
|
|
|
|
|
|
protected get _editInputNode() : HTMLInputElement
|
|
|
|
{
|
|
|
|
return this._activeControls.querySelector("input#edit");
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
protected get _activeControls()
|
|
|
|
{
|
2022-06-14 01:21:35 +02:00
|
|
|
return this.multiple ?
|
|
|
|
this.shadowRoot.querySelector(".search_input") :
|
|
|
|
this.querySelector(".search_input");
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
2022-06-10 22:11:57 +02:00
|
|
|
/**
|
|
|
|
* Tag used for rendering options
|
|
|
|
* Used for finding & filtering options, they're created by the mixed-in class
|
|
|
|
* @returns {string}
|
|
|
|
*/
|
|
|
|
public get optionTag()
|
|
|
|
{
|
|
|
|
return "sl-menu-item";
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
protected get menuItems()
|
|
|
|
{
|
2022-06-10 22:11:57 +02:00
|
|
|
return this.querySelectorAll(this.optionTag);
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Only local options, excludes server options
|
|
|
|
*
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected get localItems()
|
|
|
|
{
|
2022-06-10 22:11:57 +02:00
|
|
|
return this.querySelectorAll(this.optionTag + ":not(.remote)");
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
protected get remoteItems()
|
|
|
|
{
|
2022-06-10 22:11:57 +02:00
|
|
|
return this.querySelectorAll(this.optionTag + ".remote");
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
get value()
|
|
|
|
{
|
|
|
|
return super.value;
|
|
|
|
}
|
|
|
|
|
|
|
|
set value(new_value : string | string[])
|
|
|
|
{
|
|
|
|
super.value = new_value;
|
|
|
|
|
|
|
|
// Overridden to add options if allowFreeEntries=true
|
|
|
|
if(this.allowFreeEntries)
|
|
|
|
{
|
2022-06-13 12:34:05 +02:00
|
|
|
if(typeof this.value == "string" && !this.select_options.find(o => o.value == this.value))
|
2022-06-10 18:11:34 +02:00
|
|
|
{
|
2022-06-13 12:34:05 +02:00
|
|
|
this.createFreeEntry(this.value);
|
2022-06-10 18:11:34 +02:00
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
this.value.forEach((e) =>
|
|
|
|
{
|
|
|
|
if(!this.select_options.find(o => o.value == e))
|
|
|
|
{
|
|
|
|
this.createFreeEntry(e);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
protected _bindListeners()
|
|
|
|
{
|
|
|
|
this.addEventListener("sl-blur", this._handleSearchAbort);
|
2022-06-14 01:21:35 +02:00
|
|
|
this.addEventListener("sl-select", this._handleSelect);
|
2022-06-14 18:06:27 +02:00
|
|
|
this.addEventListener("sl-clear", this._handleClear)
|
2022-06-02 23:44:13 +02:00
|
|
|
if(this._oldChange)
|
|
|
|
{
|
|
|
|
// Search messes up event order somehow, selecting an option fires the change event before
|
|
|
|
// the widget is finished adjusting, losing the value
|
|
|
|
// This is not optimal, but we need to get that change event
|
|
|
|
this.removeEventListener("change", this._oldChange);
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
this._searchButtonNode.addEventListener("click", this._handleSearchButtonClick);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected _unbindListeners()
|
|
|
|
{
|
|
|
|
this.removeEventListener("sl-blur", this._handleSearchAbort);
|
2022-06-14 18:06:27 +02:00
|
|
|
this.removeEventListener("sl-select", this._handleSelect);
|
|
|
|
this.removeEventListener("sl-clear", this._handleClear)
|
2022-06-02 23:44:13 +02:00
|
|
|
this.removeEventListener("change", this._handleChange);
|
2022-05-31 01:05:00 +02:00
|
|
|
this._searchButtonNode.removeEventListener("click", this._handleSearchButtonClick);
|
|
|
|
}
|
|
|
|
|
|
|
|
handleMenuShow()
|
|
|
|
{
|
|
|
|
super.handleMenuShow();
|
|
|
|
|
2022-05-31 21:40:31 +02:00
|
|
|
if(this.searchEnabled)
|
|
|
|
{
|
|
|
|
this._activeControls?.classList.add("active");
|
|
|
|
this._searchInputNode.focus();
|
|
|
|
this._searchInputNode.select();
|
|
|
|
}
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
handleMenuHide()
|
|
|
|
{
|
|
|
|
super.handleMenuHide();
|
2022-05-31 21:40:31 +02:00
|
|
|
if(this.searchEnabled)
|
|
|
|
{
|
|
|
|
this._activeControls?.classList.remove("active");
|
|
|
|
}
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
_handleDoubleClick(event : MouseEvent)
|
2022-06-10 18:11:34 +02:00
|
|
|
{
|
2022-06-14 01:21:35 +02:00
|
|
|
// No edit (shouldn't happen...)
|
|
|
|
if(!this.editModeEnabled)
|
2022-06-10 18:11:34 +02:00
|
|
|
{
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find the tag
|
2022-06-14 01:21:35 +02:00
|
|
|
const path = event.composedPath();
|
2022-06-10 18:11:34 +02:00
|
|
|
const tag = <Et2Tag>path.find((el) => el instanceof Et2Tag);
|
|
|
|
this.startEdit(tag);
|
|
|
|
}
|
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
/**
|
|
|
|
* An option was selected
|
|
|
|
*/
|
|
|
|
_handleSelect(event)
|
|
|
|
{
|
|
|
|
// If they just chose one from the list, re-focus the search
|
|
|
|
if(this.multiple && this.searchEnabled)
|
|
|
|
{
|
|
|
|
this._searchInputNode.focus();
|
2022-06-14 17:37:21 +02:00
|
|
|
|
|
|
|
// Scroll the new tag into view
|
|
|
|
if(event.detail && event.detail.item)
|
|
|
|
{
|
|
|
|
this.updateComplete.then(() =>
|
|
|
|
{
|
|
|
|
this.shadowRoot.querySelector("et2-tag[value='" + event.detail.item.value + "']").scrollIntoView();
|
|
|
|
});
|
|
|
|
}
|
2022-06-14 01:21:35 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-02 19:52:27 +02:00
|
|
|
/**
|
|
|
|
* Value was cleared
|
|
|
|
*/
|
|
|
|
_handleClear()
|
|
|
|
{
|
2022-06-14 18:06:27 +02:00
|
|
|
if(!this.multiple && this.searchEnabled)
|
|
|
|
{
|
|
|
|
// Restore label styling
|
|
|
|
this.shadowRoot.querySelector("[part='display-label']").style.display = "";
|
|
|
|
|
|
|
|
// Start searching again
|
|
|
|
this.updateComplete.then(() => this.handleMenuShow())
|
|
|
|
}
|
2022-06-02 19:52:27 +02:00
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
/**
|
|
|
|
* Handle keypresses inside the search input
|
|
|
|
* @param {KeyboardEvent} event
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected _handleSearchKeyDown(event : KeyboardEvent)
|
|
|
|
{
|
2022-05-31 21:40:31 +02:00
|
|
|
this._activeControls?.classList.add("active");
|
2022-05-31 01:05:00 +02:00
|
|
|
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();
|
2022-06-14 01:21:35 +02:00
|
|
|
if(Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value))
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
|
|
|
event.preventDefault();
|
2022-06-13 17:58:46 +02:00
|
|
|
this._searchInputNode.value = "";
|
|
|
|
if(!this.multiple)
|
2022-06-10 18:11:34 +02:00
|
|
|
{
|
2022-06-13 17:58:46 +02:00
|
|
|
this.dropdown.hide();
|
2022-06-10 18:11:34 +02:00
|
|
|
}
|
2022-06-13 17:58:46 +02:00
|
|
|
|
|
|
|
}
|
|
|
|
else if(event.key == "Enter")
|
|
|
|
{
|
|
|
|
event.preventDefault();
|
|
|
|
this.startSearch();
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
2022-06-14 01:21:35 +02:00
|
|
|
else if(event.key == "Escape")
|
|
|
|
{
|
|
|
|
this._handleSearchAbort(event);
|
|
|
|
}
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
// Start the search automatically if they have enough letters
|
|
|
|
clearTimeout(this._searchTimeout);
|
|
|
|
if(this._searchInputNode.value.length >= Et2WidgetWithSearch.MIN_CHARS)
|
|
|
|
{
|
2022-05-31 21:40:31 +02:00
|
|
|
this._searchTimeout = window.setTimeout(() => {this.startSearch()}, Et2WidgetWithSearch.SEARCH_TIMEOUT);
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
protected _handleEditKeyDown(event : KeyboardEvent)
|
|
|
|
{
|
|
|
|
if(event.key == "Enter" && this.allowFreeEntries)
|
|
|
|
{
|
|
|
|
event.preventDefault();
|
|
|
|
// Stop propagation, or parent key handler will add again
|
|
|
|
event.stopImmediatePropagation();
|
|
|
|
this.stopEdit();
|
|
|
|
}
|
|
|
|
// Abort edit, put original value back
|
|
|
|
else if(event.key == "Escape")
|
|
|
|
{
|
|
|
|
this.stopEdit(true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
/**
|
|
|
|
* 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([
|
2022-05-31 21:40:31 +02:00
|
|
|
this.localSearch(this._searchInputNode.value, this.searchOptions),
|
|
|
|
this.remoteSearch(this._searchInputNode.value, this.searchOptions)
|
2022-05-31 01:05:00 +02:00
|
|
|
]).then(() =>
|
|
|
|
{
|
|
|
|
spinner.remove();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Filter the local options
|
|
|
|
*
|
|
|
|
* @param {string} search
|
|
|
|
* @protected
|
|
|
|
*/
|
2022-05-31 21:40:31 +02:00
|
|
|
protected localSearch(search : string, options : object) : Promise<void>
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
|
|
|
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
|
|
|
|
*/
|
2022-05-31 21:40:31 +02:00
|
|
|
protected remoteSearch(search : string, options : object)
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
|
|
|
// Remove existing remote items
|
|
|
|
this.remoteItems.forEach(i => i.remove());
|
|
|
|
|
|
|
|
if(!this.searchUrl)
|
|
|
|
{
|
|
|
|
return Promise.resolve();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fire off the query
|
2022-05-31 21:40:31 +02:00
|
|
|
let promise = this.remoteQuery(search, options);
|
2022-05-31 01:05:00 +02:00
|
|
|
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
/**
|
|
|
|
* Actually query the server.
|
|
|
|
*
|
|
|
|
* This can be overridden to change request parameters
|
|
|
|
*
|
|
|
|
* @param {string} search
|
|
|
|
* @param {object} options
|
|
|
|
* @returns {any}
|
|
|
|
* @protected
|
|
|
|
*/
|
2022-05-31 21:40:31 +02:00
|
|
|
protected remoteQuery(search : string, options : object)
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
2022-06-10 18:11:34 +02:00
|
|
|
return this.egw().request(this.searchUrl, [search]).then((result) =>
|
2022-05-31 01:05:00 +02:00
|
|
|
{
|
|
|
|
this.processRemoteResults(result);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add in remote results
|
|
|
|
* @param results
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected processRemoteResults(results)
|
|
|
|
{
|
2022-06-01 17:23:07 +02:00
|
|
|
let entries = cleanSelectOptions(results);
|
|
|
|
|
|
|
|
// Add a "remote" class so we can tell these apart from any local results
|
2022-06-01 17:41:53 +02:00
|
|
|
entries.forEach((entry) => entry.class = (entry.class || "") + " remote");
|
2022-06-01 17:23:07 +02:00
|
|
|
|
|
|
|
let target = this._optionTargetNode || this;
|
|
|
|
if(target)
|
|
|
|
{
|
2022-06-02 19:52:27 +02:00
|
|
|
// Keep local options first, add in remote options
|
|
|
|
entries = this.select_options.concat(entries);
|
|
|
|
|
2022-06-01 17:23:07 +02:00
|
|
|
render(html`${repeat(<SelectOption[]>entries, (option : SelectOption) => option.value, this._optionTemplate.bind(this))}`,
|
|
|
|
target
|
|
|
|
);
|
|
|
|
}
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
/**
|
|
|
|
* 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(!this.validateFreeEntry(text))
|
|
|
|
{
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
// Make sure not to double-add
|
|
|
|
if(!this.select_options.find(o => o.value == text))
|
|
|
|
{
|
|
|
|
this.select_options.push(<SelectOption>{
|
|
|
|
value: text,
|
|
|
|
label: text
|
|
|
|
});
|
2022-06-14 01:21:35 +02:00
|
|
|
this.requestUpdate('select_options');
|
2022-06-10 18:11:34 +02:00
|
|
|
}
|
2022-06-14 01:21:35 +02:00
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
// Make sure not to double-add
|
|
|
|
if(this.multiple && this.value.indexOf(text) == -1)
|
|
|
|
{
|
|
|
|
this.value.push(text);
|
|
|
|
}
|
|
|
|
else if(!this.multiple)
|
|
|
|
{
|
|
|
|
this.value = text;
|
2022-06-14 01:21:35 +02:00
|
|
|
return;
|
2022-06-10 18:11:34 +02:00
|
|
|
}
|
2022-06-14 01:21:35 +02:00
|
|
|
|
|
|
|
// Once added to options, add to value / tags
|
|
|
|
this.updateComplete.then(() =>
|
|
|
|
{
|
|
|
|
this.menuItems.forEach(o =>
|
|
|
|
{
|
|
|
|
if(o.value == text)
|
|
|
|
{
|
|
|
|
o.dispatchEvent(new Event("click"));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
this.syncItemsFromValue();
|
|
|
|
});
|
2022-06-10 18:11:34 +02:00
|
|
|
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 result.length == 0;
|
|
|
|
}
|
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
/**
|
|
|
|
* Start editing an existing (free) tag
|
|
|
|
*
|
|
|
|
* @param {Et2Tag} tag
|
|
|
|
*/
|
2022-06-10 18:11:34 +02:00
|
|
|
public startEdit(tag : Et2Tag)
|
|
|
|
{
|
2022-06-14 01:21:35 +02:00
|
|
|
const tag_value = tag.textContent.trim();
|
|
|
|
|
|
|
|
// hide the menu
|
|
|
|
//this.dropdown.hide()
|
2022-06-10 18:11:34 +02:00
|
|
|
|
2022-06-14 01:21:35 +02:00
|
|
|
// Turn on edit UI
|
|
|
|
this._activeControls.classList.add("editing", "active");
|
2022-06-10 18:11:34 +02:00
|
|
|
|
|
|
|
// Pre-set value to tag value
|
2022-06-14 01:21:35 +02:00
|
|
|
this._editInputNode.value = tag_value
|
|
|
|
this._editInputNode.focus();
|
2022-06-10 18:11:34 +02:00
|
|
|
|
|
|
|
// Remove from value & DOM. If they finish the edit, the new one will be added.
|
2022-06-14 01:21:35 +02:00
|
|
|
this.value = this.value.filter(v => v !== tag_value);
|
|
|
|
this.select_options = this.select_options.filter(v => v.value !== tag_value);
|
|
|
|
this.querySelector("[value='" + tag_value + "']").remove();
|
2022-06-10 18:11:34 +02:00
|
|
|
tag.remove();
|
2022-06-14 01:21:35 +02:00
|
|
|
|
|
|
|
// If they abort the edit, they'll want the original back.
|
|
|
|
this._editInputNode.dataset.initial = tag_value;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected stopEdit(abort = false)
|
|
|
|
{
|
|
|
|
let value = abort ? this._editInputNode.dataset.initial : this._editInputNode.value;
|
|
|
|
|
|
|
|
this.createFreeEntry(value);
|
|
|
|
delete this._editInputNode.dataset.initial;
|
|
|
|
|
|
|
|
this._activeControls.classList.remove("editing", "active");
|
2022-06-10 18:11:34 +02:00
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
protected _handleSearchButtonClick(e)
|
|
|
|
{
|
|
|
|
this.handleMenuShow();
|
|
|
|
}
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
/**
|
|
|
|
* Override this method from SlSelect to stick our own tags in there
|
|
|
|
*/
|
|
|
|
syncItemsFromValue()
|
|
|
|
{
|
|
|
|
if(typeof super.syncItemsFromValue === "function")
|
|
|
|
{
|
|
|
|
super.syncItemsFromValue();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Only applies to multiple
|
|
|
|
if(typeof this.displayTags !== "object" || !this.multiple)
|
|
|
|
{
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let overflow = null;
|
|
|
|
if(this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible)
|
|
|
|
{
|
|
|
|
overflow = this.displayTags.pop();
|
|
|
|
}
|
|
|
|
const checkedItems = Object.values(this.menuItems).filter(item => this.value.includes(item.value));
|
|
|
|
this.displayTags = checkedItems.map(item => this._tagTemplate(item));
|
|
|
|
|
|
|
|
// Re-slice & add overflow tag
|
|
|
|
if(overflow)
|
|
|
|
{
|
|
|
|
this.displayTags = this.displayTags.slice(0, this.maxTagsVisible);
|
|
|
|
this.displayTags.push(overflow);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-10 22:11:57 +02:00
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
/**
|
|
|
|
* Customise how tags are rendered. This overrides what SlSelect
|
|
|
|
* does in syncItemsFromValue().
|
|
|
|
* This is a copy+paste from SlSelect.syncItemsFromValue().
|
|
|
|
*
|
|
|
|
* @param item
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected _tagTemplate(item)
|
|
|
|
{
|
2022-06-10 22:11:57 +02:00
|
|
|
let image = item.querySelector("et2-image");
|
|
|
|
if(image)
|
|
|
|
{
|
|
|
|
image = image.clone();
|
|
|
|
image.slot = "prefix";
|
|
|
|
image.class = "tag_image";
|
|
|
|
}
|
2022-06-10 18:11:34 +02:00
|
|
|
return html`
|
2022-06-10 22:11:57 +02:00
|
|
|
<et2-tag class="search_tag"
|
|
|
|
removable
|
2022-06-14 17:37:21 +02:00
|
|
|
value="${item.value}"
|
2022-06-14 01:21:35 +02:00
|
|
|
@dblclick=${this._handleDoubleClick}
|
2022-06-10 22:11:57 +02:00
|
|
|
@click=${this.handleTagInteraction}
|
|
|
|
@keydown=${this.handleTagInteraction}
|
|
|
|
@sl-remove=${(event) =>
|
|
|
|
{
|
|
|
|
event.stopPropagation();
|
|
|
|
if(!this.disabled)
|
|
|
|
{
|
|
|
|
item.checked = false;
|
|
|
|
this.syncValueFromItems();
|
|
|
|
}
|
|
|
|
}}
|
2022-06-10 18:11:34 +02:00
|
|
|
>
|
2022-06-10 22:11:57 +02:00
|
|
|
${image}
|
2022-06-10 18:11:34 +02:00
|
|
|
${this.getItemLabel(item)}
|
|
|
|
</et2-tag>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
2022-05-31 01:05:00 +02:00
|
|
|
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");
|
|
|
|
})
|
2022-06-02 23:44:13 +02:00
|
|
|
this.syncItemsFromValue();
|
2022-05-31 01:05:00 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-10 18:11:34 +02:00
|
|
|
return Et2WidgetWithSearch as unknown as Constructor<SearchMixinInterface> & T;
|
|
|
|
}
|