diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts index 39e5bfc9b2..c1e82879d3 100644 --- a/api/js/etemplate/Et2Select/SearchMixin.ts +++ b/api/js/etemplate/Et2Select/SearchMixin.ts @@ -124,27 +124,61 @@ export const Et2WithSearchMixin = >(superclass :host([search]) ::slotted([slot="suffix"]) { display: initial; } + /* 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; + } :host([allowFreeEntries]) ::slotted([slot="suffix"]) { display: none; } /* Make search textbox take full width */ - ::slotted([name="search_input"]:focus ){ + ::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input { width: 100%; } - :host([search]) .select--open .select__prefix { + .search_input input { + flex: 1 1 auto; + width: 100%; + } + /* 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 { flex: 2 1 auto; width: 100%; } /* Search textbox general styling, starts hidden */ - .select__prefix ::slotted(.search_input) { + .select__prefix ::slotted(.search_input),.search_input { display: none; + flex: 1 1 auto; margin-left: 0px; width: 100%; + height: var(--sl-input-height-medium); + position: relative; + background-color: white; } /* Search UI active - show textbox & stuff */ - ::slotted(.search_input.active) { + ::slotted(.search_input.active),.search_input.active, + .search_input.editing{ display: flex; } @@ -180,6 +214,11 @@ export const Et2WithSearchMixin = >(superclass private _searchTimeout : number; protected static SEARCH_TIMEOUT = 500; protected static MIN_CHARS = 2; + /** + * These characters will end a free tag + * @type {string[]} + */ + static TAG_BREAK : string[] = ["Tab", "Enter", ","]; constructor(...args : any[]) { @@ -192,6 +231,10 @@ export const Et2WithSearchMixin = >(superclass 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.maxTagsVisible = -1; + this.validators = []; /** * Used by Subclassers to add default Validators. @@ -204,10 +247,12 @@ export const Et2WithSearchMixin = >(superclass */ this.defaultValidators = []; + this._handleSelect = this._handleSelect.bind(this); this._handleSearchButtonClick = this._handleSearchButtonClick.bind(this); + this._handleDoubleClick = this._handleDoubleClick.bind(this); this._handleSearchAbort = this._handleSearchAbort.bind(this); this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this); - this.handleTagInteraction = this.handleTagInteraction.bind(this); + this._handleEditKeyDown = this._handleEditKeyDown.bind(this); } connectedCallback() @@ -244,26 +289,46 @@ export const Et2WithSearchMixin = >(superclass } /** - * Add the nodes we need to search + * Add the nodes we need to search - adjust parent shadowDOM * * @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); + 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"))); + }); } protected _searchInputTemplate() { + let edit = ''; + if(this.editModeEnabled) + { + edit = html``; + } // I can't figure out how to get this full width via CSS return html` - + + ${edit} `; } @@ -285,12 +350,19 @@ export const Et2WithSearchMixin = >(superclass protected get _searchInputNode() { - return this.querySelector(".search_input input"); + return this._activeControls.querySelector("input#search"); + } + + protected get _editInputNode() : HTMLInputElement + { + return this._activeControls.querySelector("input#edit"); } protected get _activeControls() { - return this.querySelector(".search_input"); + return this.multiple ? + this.shadowRoot.querySelector(".search_input") : + this.querySelector(".search_input"); } /** @@ -355,6 +427,7 @@ export const Et2WithSearchMixin = >(superclass protected _bindListeners() { this.addEventListener("sl-blur", this._handleSearchAbort); + this.addEventListener("sl-select", this._handleSelect); if(this._oldChange) { // Search messes up event order somehow, selecting an option fires the change event before @@ -382,10 +455,6 @@ export const Et2WithSearchMixin = >(superclass this._activeControls?.classList.add("active"); this._searchInputNode.focus(); this._searchInputNode.select(); - - // Hide the label for the currently selected value - it shows as checked in list - // and we want the space - this.shadowRoot.querySelector("[part='display-label']").style.display = "none"; } } @@ -395,39 +464,35 @@ export const Et2WithSearchMixin = >(superclass if(this.searchEnabled) { this._activeControls?.classList.remove("active"); - - // Restore selected value visibility - this.shadowRoot.querySelector("[part='display-label']").style.display = ""; } } - handleTagInteraction(event : KeyboardEvent | MouseEvent) + _handleDoubleClick(event : MouseEvent) { - let result = super.handleTagInteraction(event); - - // Check if remove button was clicked - const path = event.composedPath(); - const clearButton = path.find((el) => - { - if(el instanceof HTMLElement) - { - const element = el as HTMLElement; - return element.classList.contains('tag__remove'); - } - return false; - }); - - // No edit, or removed tag - if(!this.editModeEnabled || clearButton) + // No edit (shouldn't happen...) + if(!this.editModeEnabled) { return; } // Find the tag + const path = event.composedPath(); const tag = path.find((el) => el instanceof Et2Tag); this.startEdit(tag); } + /** + * 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(); + } + } + /** * Value was cleared */ @@ -455,7 +520,7 @@ export const Et2WithSearchMixin = >(superclass // Don't allow event to bubble or it will interact with select event.stopImmediatePropagation(); - if(["Tab", "Enter", ","].indexOf(event.key) && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value)) + if(Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value)) { event.preventDefault(); this._searchInputNode.value = ""; @@ -470,6 +535,10 @@ export const Et2WithSearchMixin = >(superclass event.preventDefault(); this.startSearch(); } + else if(event.key == "Escape") + { + this._handleSearchAbort(event); + } // Start the search automatically if they have enough letters clearTimeout(this._searchTimeout); @@ -479,6 +548,22 @@ export const Et2WithSearchMixin = >(superclass } } + 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); + } + } + /** * Start searching * @@ -631,7 +716,9 @@ export const Et2WithSearchMixin = >(superclass value: text, label: text }); + this.requestUpdate('select_options'); } + // Make sure not to double-add if(this.multiple && this.value.indexOf(text) == -1) { @@ -640,8 +727,21 @@ export const Et2WithSearchMixin = >(superclass else if(!this.multiple) { this.value = text; + return; } - this.requestUpdate('select_options'); + + // 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(); + }); return true; } @@ -661,20 +761,43 @@ export const Et2WithSearchMixin = >(superclass return result.length == 0; } + /** + * Start editing an existing (free) tag + * + * @param {Et2Tag} tag + */ public startEdit(tag : Et2Tag) { - // Turn on edit UI - this.handleMenuShow(); + const tag_value = tag.textContent.trim(); - // but hide the menu - this.updateComplete.then(() => this.dropdown.hide()); + // hide the menu + //this.dropdown.hide() + + // Turn on edit UI + this._activeControls.classList.add("editing", "active"); // Pre-set value to tag value - this._searchInputNode.value = tag.textContent.trim(); + this._editInputNode.value = tag_value + this._editInputNode.focus(); // Remove from value & DOM. If they finish the edit, the new one will be added. - this.value = this.value.filter(v => v !== this._searchInputNode.value); + 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(); tag.remove(); + + // 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"); } protected _handleSearchButtonClick(e) @@ -735,6 +858,7 @@ export const Et2WithSearchMixin = >(superclass return html`