diff --git a/api/js/etemplate/Et2Tree/Et2Tree.ts b/api/js/etemplate/Et2Tree/Et2Tree.ts index 7386c6ec23..edff6576d4 100644 --- a/api/js/etemplate/Et2Tree/Et2Tree.ts +++ b/api/js/etemplate/Et2Tree/Et2Tree.ts @@ -1043,7 +1043,7 @@ export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement) implements Fin > - ${repeat(this._selectOptions, this._optionTemplate)} + ${repeat(this._selectOptions, (o) => o.value, this._optionTemplate)} `; } diff --git a/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts b/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts index db0d9000ee..46ef2c45da 100644 --- a/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts +++ b/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts @@ -9,6 +9,10 @@ export default css` display: none; } + .form-control-input { + display: flex; + } + /* Label */ .form-control--has-label .form-control__label { @@ -37,14 +41,26 @@ export default css` margin-top: var(--sl-spacing-3x-small); } + .tree-dropdown__value-input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + opacity: 0; + z-index: -1; + } .tree-dropdown__combobox { min-height: calc(var(--sl-input-height-medium) - 2 * var(--sl-input-border-width)); display: flex; flex-direction: row; flex-wrap: nowrap; - align-items: flex-start; + align-items: center; justify-content: space-between; + vertical-align: middle; background-color: var(--sl-input-background-color); border: solid var(--sl-input-border-width) var(--sl-input-border-color); @@ -58,6 +74,12 @@ export default css` transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow, var(--sl-transition-fast) background-color; + + cursor: pointer; + } + + :host([multiple]) .tree-dropdown__combobox { + align-items: flex-start } .tree-dropdown--disabled { @@ -91,6 +113,7 @@ export default css` transition: var(--sl-transition-medium) rotate ease; rotate: 0; margin-inline-start: var(--sl-spacing-small); + order: 99; } .tree-dropdown--open .tree-dropdown__expand-icon { @@ -103,9 +126,18 @@ export default css` order: 1; } + /* Single */ + + + /* End single */ + /* Tags */ .tree-dropdown__tags { + display: none; + } + + .tree-dropdown--multiple.tree-dropdown--has-value:not(.tree-dropdown--placeholder-visible) .tree-dropdown__tags { display: flex; flex: 2 1 auto; flex-wrap: wrap; @@ -116,6 +148,7 @@ export default css` min-width: 0px; } + /* Limit tag size */ .tree_tag { @@ -132,10 +165,6 @@ export default css` /* Search box */ - :host([readonly]) .tree-dropdown__search { - display: none; - } - .tree-dropdown__search { flex: 1 1 7em; order: 10; @@ -143,9 +172,10 @@ export default css` border: none; outline: none; + color: var(--sl-input-color); font-size: var(--sl-input-font-size-medium); padding-block: 0; - padding-inline: var(--sl-input-spacing-medium); + cursor: inherit; } .form-control--medium .tree-dropdown__search { @@ -153,6 +183,14 @@ export default css` height: calc(var(--sl-input-height-medium) * 0.8); } + :host([open]) .tree-dropdown__search { + cursor: text; + } + + :host(:not([open])) .tree-dropdown--has-value.tree-dropdown--multiple .tree-dropdown__search { + visibility: hidden; + } + .tree-dropdown--disabled .tree-dropdown__search { cursor: not-allowed; } @@ -161,17 +199,6 @@ export default css` cursor: default; } - /* tag takes full width when widget is not multiple and has value and does not have focus */ - - :host(:not([multiple])) .tree-dropdown--has-value .tree-dropdown__search { - display: none; - } - - :host(:not([multiple])) .tree-dropdown--focused .tree-dropdown__search, - :host(:not([multiple])) .tree-dropdown--open .tree-dropdown__search { - display: initial; - } - .tree-dropdown__suffix { order: 20; } diff --git a/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts b/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts index c83c8e8d49..ae8ea69ded 100644 --- a/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts +++ b/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts @@ -11,9 +11,10 @@ import {SlPopup, SlRemoveEvent, SlTreeItem} from "@shoelace-style/shoelace"; import shoelace from "../Styles/shoelace"; import styles from "./Et2TreeDropdown.styles"; import {Et2Tag} from "../Et2Select/Tag/Et2Tag"; -import {SearchMixin, SearchResult, SearchResultsInterface} from "../Et2Widget/SearchMixin"; +import {SearchMixin, SearchResult, SearchResultElement, SearchResultsInterface} from "../Et2Widget/SearchMixin"; import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget"; import {Required} from "../Validators/Required"; +import {SelectOption} from "../Et2Select/FindSelectOptions"; interface TreeSearchResults extends SearchResultsInterface @@ -56,11 +57,26 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg ]; } + /** + * List of properties that get translated + * @returns object + */ + static get translate() + { + return { + ...super.translate, + placeholder: true, + } + } + /** Placeholder text to show as a hint when the select is empty. */ @property() placeholder = ""; @property({type: Boolean, reflect: true}) multiple = false; + /** Adds a clear button when the dropdown is not empty. */ + @property({type: Boolean}) clearable = false; + /** The component's help text. If you need to display HTML, use the `help-text` slot instead. */ @property({attribute: 'help-text'}) helpText = ""; @@ -122,6 +138,8 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg protected readonly hasSlotController = new HasSlotController(this, "help-text", "label"); private __value : string[]; + protected displayLabel = ''; + constructor() { super(); @@ -133,11 +151,30 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg connectedCallback() { super.connectedCallback(); + document.addEventListener("click", this.handleDocumentClick); } disconnectedCallback() { super.disconnectedCallback(); + document.removeEventListener("click", this.handleDocumentClick); + } + + willUpdate(changedProperties) + { + super.willUpdate(changedProperties); + + // Child tree not updating when our emptyLabel changes + if(this._tree && (changedProperties.has("select_options") || changedProperties.has("emptyLabel"))) + { + let options = this.multiple || !this.emptyLabel ? this.select_options : [{ + value: "", + label: this.emptyLabel + }, ...this.select_options]; + + this._tree._selectOptions = options; + this._tree.requestUpdate("_selectOptions"); + } } updated(changedProperties : PropertyValues) @@ -188,6 +225,17 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg const oldValue = this.__value; // Filter to make sure there are no trailing commas or duplicates this.__value = Array.from(new Set(new_value.filter(v => v))); + + this.displayLabel = ""; + if(!this.multiple) + { + const option = this.optionSearch(this.__value[0], this.select_options, 'value', 'children'); + if(option) + { + this.displayLabel = option.label; + } + } + this.requestUpdate("value", oldValue); } @@ -199,47 +247,49 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg } + get select_options() : SelectOption[] + { + return super.select_options; + } + + set select_options(new_options : SelectOption[]) + { + super.select_options = new_options; + + // Overridden so we can update displayLabel in the case where value got set before selectOptions + if(this.value && !this.multiple) + { + const option = this.optionSearch(typeof this.value == "string" ? this.value : this.value[0], this.select_options, 'value', 'children'); + if(option) + { + this.displayLabel = option.label; + } + } + } /** Sets focus on the control. */ focus(options? : FocusOptions) { - this.hasFocus = true; - // Should not be needed, but not firing the update - this.requestUpdate("hasFocus"); - - if(this._searchNode) - { - this._searchNode.focus(options); - } + this.handleFocus(); } /** Removes focus from the control. */ blur() { - this.open = false; - this.treeOrSearch = "tree"; - this.hasFocus = false; - this._popup.active = false; - // Should not be needed, but not firing the update - this.requestUpdate("open"); - this.requestUpdate("hasFocus"); - this._searchNode.blur(); - - clearTimeout(this._searchTimeout); + this.handleBlur(); } /** Shows the tree. */ async show() { - if(this.open || this.disabled) + if(this.readonly || this.disabled) { this.open = false; this.requestUpdate("open", true); - return undefined; + return this.updateComplete; } - document.addEventListener("click", this.handleDocumentClick); this.open = true; this.requestUpdate("open", false) return this.updateComplete @@ -253,8 +303,6 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg return undefined; } - document.removeEventListener("click", this.handleDocumentClick); - this.open = false; this._popup.active = false; this._searchNode.value = ""; @@ -311,10 +359,25 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg return super.localSearch(search, searchOptions, this.select_options); } + /** + * Toggles a search result's selected state + * Overridden to handle multiple attribute so only 1 result is selected + */ + protected toggleResultSelection(result : HTMLElement & SearchResultElement, force? : boolean) + { + if(!this.multiple) + { + this._resultNodes.forEach(t => t.selected = false); + } + + super.toggleResultSelection(result, force); + } + protected searchResultSelected() { super.searchResultSelected(); + const oldValue = [...this.value]; if(this.multiple && typeof this.value !== "undefined") { // Add in the new result(s), no duplicates @@ -328,9 +391,50 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg // Done with search, show the tree this.treeOrSearch = "tree"; - // Close the dropdown - this.hide(); - this.requestUpdate("value"); + + // Close the dropdown, move on + if(!this.multiple || this.egw().preference("select_multiple_close") == "close") + { + this.blur(); + } + else + { + this._tree.value = this.value; + } + + // Update values + this.updateComplete.then(() => + { + this.dispatchEvent(new Event("change", {bubbles: true})); + }); + this._tree.requestUpdate("value", oldValue); + this.requestUpdate("value", oldValue); + } + + private handleClearClick(event : MouseEvent) + { + event.stopPropagation(); + + if(this.value.length > 0) + { + this.value = []; + this.displayInput.focus({preventScroll: true}); + + // Emit after update + this.updateComplete.then(() => + { + this.emit('sl-clear'); + this.emit('sl-input'); + this.emit('sl-change'); + }); + } + } + + private handleClearMouseDown(event : MouseEvent) + { + // Don't lose focus or propagate events when clicking the clear button + event.stopPropagation(); + event.preventDefault(); } /** @@ -403,11 +507,60 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg event.preventDefault(); this.hide() } - else - { - this.blur(); - } + this.blur(); + } + private handleFocus() + { + this.hasFocus = true; + // Should not be needed, but not firing the update + this.requestUpdate("hasFocus"); + + this.updateComplete.then(() => + { + if(this._searchNode) + { + this._searchNode.focus(); + } + else + { + this._tree.focus(); + } + + this.dispatchEvent(new Event("sl-focus")); + }) + } + + private handleBlur() + { + this.open = false; + this.treeOrSearch = "tree"; + this.hasFocus = false; + this.resultsOpen = false; + this._popup.active = false; + // Should not be needed, but not firing the update + this.requestUpdate("resultsOpen"); + this.requestUpdate("open"); + this.requestUpdate("hasFocus"); + this._searchNode?.blur(); + + clearTimeout(this._searchTimeout); + + this.updateComplete.then(() => + { + this.dispatchEvent(new Event("sl-blur")); + }) + } + + protected handleClick(event) + { + // Open if clicking somewhere in the widget + if(event.target.classList.contains("tree-dropdown__combobox")) + { + event.stopPropagation(); + this.show(); + this.handleFocus(); + } } private handleSearchFocus() @@ -419,12 +572,28 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg // Reset tags to not take focus this.setCurrentTag(null); + this.show(); + } + + private handleSearchBlur(event) + { + // Focus lost to some other internal component - ignore it + if(event.composedPath().includes(this.shadowRoot)) + { + return; + } + this.handleBlur(); } handleSearchKeyDown(event) { super.handleSearchKeyDown(event); + if(event.key == "ArrowDown" && !this.open && !this.resultsOpen) + { + this.show(); + } + // Left at beginning goes to tags if(this._searchNode.selectionStart == 0 && event.key == "ArrowLeft") { @@ -492,30 +661,31 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg this.value = [...this.value, id]; } } + this.requestUpdate("value", oldValue); this.updateComplete.then(() => { this.dispatchEvent(new Event("change", {bubbles: true})); }); - if(!this.multiple) + if(!this.multiple || this.egw().preference("select_multiple_close") == "close") { - this.hide(); + this.blur(); } } - handleTriggerClick() + handleTriggerClick(event) { + event.stopPropagation(); + this.hasFocus = true; if(this.open) { this._popup.active = false; this._searchNode.value = ""; - document.removeEventListener("click", this.handleDocumentClick); } else { this._popup.active = true; - document.addEventListener("click", this.handleDocumentClick); } this.open = this._popup.active; this.treeOrSearch = "tree"; @@ -523,7 +693,7 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg this.updateComplete.then(() => { this._tree.style.minWidth = getComputedStyle(this).width; - this._tree.focus(); + this.focus(); }) } @@ -546,18 +716,49 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg inputTemplate() { + let placeholder = this.egw().lang("search"); + if(this.disabled || this.readonly || (this.open && this.value)) + { + placeholder = ""; + } + else + { + placeholder = this.emptyLabel || this.placeholder; + } return html` {this.hasFocus = false;}} + @blur=${this.handleSearchBlur} @focus=${this.handleSearchFocus} @paste=${this.handlePaste} + /> + `; } @@ -578,15 +779,23 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg return literal`et2-tag`; } + /** + * Shows the currently selected values as tags when multiple=true + * + * @returns {TemplateResult} + */ tagsTemplate() { const value = this.getValueAsArray(); - return html`${map(value, (value, index) => - { - // Deal with value that is not in options - const option = this.optionSearch(value, this.select_options, 'value', 'children'); - return option ? this.tagTemplate(option) : nothing; - })}`; + return html` +
+ ${map(value, (value, index) => + { + // Deal with value that is not in options + const option = this.optionSearch(value, this.select_options, 'value', 'children'); + return option ? this.tagTemplate(option) : nothing; + })} +
`; } tagTemplate(option : TreeItemData) @@ -633,8 +842,12 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg const hasLabel = this.label ? true : !!hasLabelSlot; const hasValue = this.value && this.value.length > 0; const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0; const isPlaceholderVisible = (this.placeholder || this.emptyLabel) && this.value.length === 0 && !this.disabled && !this.readonly; - + let options = this.multiple || !this.emptyLabel ? this.select_options : [{ + value: "", + label: this.emptyLabel + }, ...this.select_options]; return html`
& Et2InputWidg 'tree-dropdown--disabled': this.disabled, 'tree-dropdown--readonly': this.readonly, 'tree-dropdown--focused': this.hasFocus, + 'tree-dropdown--multiple': this.multiple, 'tree-dropdown--placeholder-visible': isPlaceholderVisible, 'tree-dropdown--searching': this.treeOrSearch == "search", 'tree-dropdown--has-value': hasValue @@ -672,7 +886,6 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg auto-size-padding="10" ?active=${this.open} placement=${this.placement || "bottom"} - stay-open-on-select strategy="fixed" ?disabled=${this.disabled} > @@ -681,12 +894,28 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg class="tree-dropdown__combobox" slot="anchor" @keydown=${this.handleComboboxKeyDown} + @click=${this.handleClick} > -
- ${this.tagsTemplate()} - ${this.inputTemplate()} -
+ ${this.multiple ? this.tagsTemplate() : nothing} + ${this.inputTemplate()} + ${hasClearIcon + ? html` + + ` + : ''} @@ -702,7 +931,7 @@ export class Et2TreeDropdown extends SearchMixin & Et2InputWidg ?readonly=${this.readonly} ?disabled=${this.disabled} value=${this.multiple ? nothing : this.value} - ._selectOptions=${this.select_options} + ._selectOptions=${options} .actions=${this.actions} .styleTemplate=${() => this.styleTemplate()} .autoloading="${this.autoloading}" diff --git a/api/js/etemplate/Et2Widget/SearchMixin.ts b/api/js/etemplate/Et2Widget/SearchMixin.ts index 975c43aa78..6d99027323 100644 --- a/api/js/etemplate/Et2Widget/SearchMixin.ts +++ b/api/js/etemplate/Et2Widget/SearchMixin.ts @@ -505,6 +505,11 @@ export const SearchMixin = + { + this.dispatchEvent(new Event("change", {bubbles: true})); + }); */ this.updateComplete.then(() => diff --git a/api/js/etemplate/et2_extension_nextmatch.ts b/api/js/etemplate/et2_extension_nextmatch.ts index cdcc64a30a..ac9c7b0341 100644 --- a/api/js/etemplate/et2_extension_nextmatch.ts +++ b/api/js/etemplate/et2_extension_nextmatch.ts @@ -3797,6 +3797,8 @@ export class et2_nextmatch_header_bar extends et2_DOMWidget implements et2_INext !select.select_options.filter(option => option.value === '').length) { select.emptyLabel = this.egw().lang('All categories'); + // requestUpdate because widget is not firing update itself + select.requestUpdate("emptyLabel"); } select.requestUpdate("value"); })