diff --git a/api/js/etemplate/Et2Email/Et2Email.styles.ts b/api/js/etemplate/Et2Email/Et2Email.styles.ts index 243d96c84e..feef20612e 100644 --- a/api/js/etemplate/Et2Email/Et2Email.styles.ts +++ b/api/js/etemplate/Et2Email/Et2Email.styles.ts @@ -27,7 +27,7 @@ export default css` border-radius: var(--sl-input-border-radius-medium); font-size: var(--sl-input-font-size-medium); min-height: var(--sl-input-height-medium); - max-height: calc(var(--height, 2.5) * var(--sl-input-height-medium)); + max-height: calc(var(--height, 5) * var(--sl-input-height-medium)); overflow-y: auto; padding-block: 0; padding-inline: var(--sl-input-spacing-medium); @@ -58,7 +58,8 @@ export default css` order: 1; } /* Tags */ - .email et2-email-tag { + + et2-email-tag { order: 2; flex-grow: 0; margin: auto 0px; @@ -137,4 +138,70 @@ export default css` padding-inline: var(--sl-spacing-x-large); } + /** + * Readonly + */ + + :host([readonly]) .email .email__combobox { + border: none; + box-shadow: none; + max-height: calc(var(--height, 5) * (var(--sl-input-height-medium) * 0.8)) + } + + :host([readonly])::part(expand-icon) { + display: none; + } + + :host([readonly]) .email__search { + display: none; + } + + /** + * Style for tag count if readonly and rows=1 + */ + + :host([readonly][rows="1"]) .email__combobox { + overflow: hidden; + min-height: auto; + max-height: calc(var(--sl-input-height-medium) * 0.8); + } + + .tag_limit { + position: absolute; + right: 0px; + top: 0px; + bottom: 0px; + box-shadow: rgb(0 0 0/50%) -1.5ex 0px 1ex -1ex, rgb(0 0 0 / 0%) 0px 0px 0px 0px; + z-index: 1; + } + + .tag_limit::part(base) { + height: 100%; + background-color: var(--sl-input-background-color); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + font-weight: bold; + min-width: 3em; + justify-content: center; + } + + /* Show all rows on hover if readonly rows=1 */ + + :host([ readonly ][ rows ]) .hover__popup { + width: -webkit-fill-available; + width: -moz-fill-available; + width: fill-available; + } + + :host([readonly][rows]) .hover__popup::part(popup) { + z-index: var(--sl-z-index-dropdown); + background-color: white; + display: flex; + flex-wrap: wrap; + + /* Same as .email__combobox */ + gap: 0.1rem 0.5rem; + } + + /* End styles for [readonly][rows=1] */ `; \ No newline at end of file diff --git a/api/js/etemplate/Et2Email/Et2Email.ts b/api/js/etemplate/Et2Email/Et2Email.ts index 347cda6f8c..43bba4eedf 100644 --- a/api/js/etemplate/Et2Email/Et2Email.ts +++ b/api/js/etemplate/Et2Email/Et2Email.ts @@ -11,6 +11,7 @@ import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit"; import {property} from "lit/decorators/property.js"; import {state} from "lit/decorators/state.js"; import {classMap} from "lit/directives/class-map.js"; +import {styleMap} from "lit/directives/style-map.js"; import {keyed} from "lit/directives/keyed.js"; import {live} from "lit/directives/live.js"; import {map} from "lit/directives/map.js"; @@ -63,7 +64,7 @@ import Sortable from "sortablejs/modular/sortable.complete.esm.js"; * @csspart option - Each matching email address suggestion * @csspart tag - The individual tags that represent each email address. * - * @cssproperty [--height=2.5] - The maximum height of the widget, to limit size when you have a lot of addresses. + * @cssproperty [--height=5] - The maximum height of the widget, to limit size when you have a lot of addresses. Set by rows property, when set. */ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinInterface { @@ -144,11 +145,21 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI */ @property({type: String}) searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email"; + /** + * Limit the maximum height of the widget, for when you have a lot of addresses. + * Set it to 1 for special single-line styling, 0 to disable + * @type {number} + */ + @property({type: Number, reflect: true}) rows; + @state() searching = false; @state() hasFocus = false; @state() currentOption : SlOption; @state() currentTag : Et2EmailTag; + /** If the select is limited to 1 row, we show the number of tags not visible */ + @state() _tagsHidden = 0; + get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup");} @@ -187,6 +198,9 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI protected _searchPromise : Promise = Promise.resolve([]); protected _selectOptions : SelectOption[] = []; + // Overflow Observer for +# display + protected tagOverflowObserver : IntersectionObserver = null; + // Drag / drop / sort protected _sortable : Sortable; @@ -208,8 +222,10 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI this.handleOpenChange = this.handleOpenChange.bind(this); this.handleLostFocus = this.handleLostFocus.bind(this); - this.handleSortEnd = this.handleSortEnd.bind(this); + this.handleTagOverflow = this.handleTagOverflow.bind(this); + this.handleMouseEnter = this.handleMouseEnter.bind(this); + this.handleMouseLeave = this.handleMouseLeave.bind(this); } connectedCallback() @@ -271,6 +287,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI { this.makeSortable(); } + this.checkTagOverflow(); } private _getEmailDisplayPreference() @@ -374,6 +391,35 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI } } + protected checkTagOverflow() + { + // Create / destroy intersection observer + if(this.readonly && this.rows == "1" && this.tagOverflowObserver == null) + { + this.tagOverflowObserver = new IntersectionObserver(this.handleTagOverflow, { + root: this.shadowRoot.querySelector(".email__combobox"), + threshold: 0.1 + }); + } + else if((!this.readonly || this.rows !== 1) && this.tagOverflowObserver !== null) + { + this.tagOverflowObserver.disconnect(); + this.tagOverflowObserver = null; + } + + if(this.tagOverflowObserver) + { + this.updateComplete.then(() => + { + for(const tag of Array.from(this.shadowRoot.querySelectorAll(".email__combobox et2-email-tag"))) + { + this.tagOverflowObserver.observe(tag); + } + }); + } + } + + /** * Create an entry that is not in the suggestions and add it to the value * @@ -667,6 +713,56 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI } + /** + * Callback for the intersection observer so we know when tags don't fit + * + * Here we set the flag to show how many more tags are hidden, but this only happens + * when there are more tags than space. + * + * @param entries + * @protected + */ + protected handleTagOverflow(entries : IntersectionObserverEntry[]) + { + const oldCount = this._tagsHidden; + let visibleTagCount = this.value.length - this._tagsHidden; + let update = false; + // If we have all tags, start from 0, otherwise it's just a change + if(entries.length == this.value.length) + { + visibleTagCount = 0; + } + else + { + update = true; + } + for(const tag of entries) + { + if(tag.isIntersecting) + { + visibleTagCount++; + } + else if(update && !tag.isIntersecting) + { + visibleTagCount--; + } + else + { + break; + } + } + if(visibleTagCount && visibleTagCount < this.value.length) + { + this._tagsHidden = this.value.length - visibleTagCount; + } + else + { + this._tagsHidden = 0; + } + this.requestUpdate("_tagsHidden", oldCount); + } + + /** * Sometimes users paste multiple comma separated values at once. Split them then handle normally. * Overridden here to handle email addresses that may have commas using the regex from the validator. @@ -895,6 +991,36 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI } } + /** + * If rows=1 and multiple=true, when they put the mouse over the widget show all tags + * @param {MouseEvent} e + * @private + */ + protected handleMouseEnter(e : MouseEvent) + { + if(this.rows == "1" && this.value.length > 1) + { + e.stopPropagation(); + + // Bind to turn this all off + this.addEventListener("mouseleave", this.handleMouseLeave); + + this.classList.add("hover"); + this.requestUpdate(); + } + } + + /** + * If we're showing all rows because of _handleMouseEnter, reset when mouse leaves + * @param {MouseEvent} e + * @private + */ + protected handleMouseLeave(e : MouseEvent) + { + this.classList.remove("hover"); + this.requestUpdate(); + } + /** * Keyboard events from the suggestion list * @@ -1013,6 +1139,31 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI this.dispatchEvent(new Event("change", {bubbles: true})); } + /* Sub-template when [readonly][rows=1] to show all tags in current value in popup */ + readonlyHoverTemplate() + { + if(!this.classList.contains("hover")) + { + return nothing; + } + + // Offset distance to open _over_ the rest + let distance = (-1 * parseInt(getComputedStyle(this).height)); + return html` + + ${this.tagsTemplate()} + + `; + } + tagsTemplate() { return html`${keyed(this._valueUID, map(this.value, (value, index) => this.tagTemplate(value)))}`; @@ -1045,6 +1196,21 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI `; } + protected tagLimitTemplate() : TemplateResult | typeof nothing + { + if(this._tagsHidden == 0) + { + return nothing; + } + return html` + +${this._tagsHidden} + `; + } + inputTemplate() { return html` @@ -1110,6 +1276,13 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; const isPlaceholderVisible = this.placeholder && this.value.length === 0 && !this.disabled && !this.readonly; + let styles = {}; + + if(this.rows !== 0) + { + styles["--height"] = this.rows; + } + // TODO Don't forget required & disabled return html` @@ -1121,7 +1294,9 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI 'form-control--has-label': hasLabel, 'form-control--has-help-text': hasHelpText })} + style=${styleMap(styles)} @click=${this.handleLabelClick} + @mouseenter=${this.handleMouseEnter} @mousedown=${() => { if(!this.hasFocus) @@ -1141,6 +1316,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI ${this.label}
+ ${this.readonlyHoverTemplate()} ${this.tagsTemplate()} ${this.inputTemplate()} + ${this.tagLimitTemplate()} ${this.searching ? html` ` : nothing} diff --git a/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts b/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts index bc398c489d..5850892a00 100644 --- a/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts +++ b/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts @@ -90,7 +90,7 @@ export function inputBasicTests(before : Function, test_value : string, value_se // Shows as empty / no value let value = (element).querySelector(value_selector) || (element).shadowRoot.querySelector(value_selector); assert.isDefined(value, "Bad value selector '" + value_selector + "'"); - debugger; + assert.equal(value.textContent.trim(), "", "Displaying something when there is no value"); if(element.multiple) { @@ -104,7 +104,7 @@ export function inputBasicTests(before : Function, test_value : string, value_se it("value out matches value in", async() => { element.set_value(test_value); - debugger; + // wait for asychronous changes to the DOM await elementUpdated(element); diff --git a/mail/templates/default/index.xet b/mail/templates/default/index.xet index 8d5f8a2b60..f224c832c1 100644 --- a/mail/templates/default/index.xet +++ b/mail/templates/default/index.xet @@ -30,28 +30,24 @@ - + - + - + - +