From 580466f9b85cf98eba90ca0736ad2d6f24660d81 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 8 Dec 2023 16:22:01 -0700 Subject: [PATCH] Et2Email WIP --- api/js/etemplate/Et2Email/Et2Email.styles.ts | 96 +++ api/js/etemplate/Et2Email/Et2Email.ts | 737 ++++++++++++++++++ .../etemplate/Et2Email/test/Et2Email.test.ts | 149 ++++ api/js/etemplate/Et2Select/Tag/Et2Tag.ts | 13 +- api/js/etemplate/Et2Widget/Et2Widget.ts | 4 +- api/js/etemplate/Et2Widget/event.ts | 22 + api/js/etemplate/etemplate2.ts | 1 + 7 files changed, 1018 insertions(+), 4 deletions(-) create mode 100644 api/js/etemplate/Et2Email/Et2Email.styles.ts create mode 100644 api/js/etemplate/Et2Email/Et2Email.ts create mode 100644 api/js/etemplate/Et2Email/test/Et2Email.test.ts create mode 100644 api/js/etemplate/Et2Widget/event.ts diff --git a/api/js/etemplate/Et2Email/Et2Email.styles.ts b/api/js/etemplate/Et2Email/Et2Email.styles.ts new file mode 100644 index 0000000000..cf3fbd6f6f --- /dev/null +++ b/api/js/etemplate/Et2Email/Et2Email.styles.ts @@ -0,0 +1,96 @@ +import {css} from 'lit'; + +export default css` + .email .email__combobox { + flex: 1; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.1rem 0.5rem; + + background-color: var(--sl-input-background-color); + border: solid var(--sl-input-border-width) var(--sl-input-border-color); + + border-radius: var(--sl-input-border-radius-medium); + font-size: var(--sl-input-font-size-medium); + min-height: var(--sl-input-height-medium); + padding-block: 0; + padding-inline: var(--sl-input-spacing-medium); + + transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow, + var(--sl-transition-fast) background-color; + } + + .email.email--disabled .email__combobox { + background-color: var(--sl-input-background-color-disabled); + border-color: var(--sl-input-border-color-disabled); + color: var(--sl-input-color-disabled); + opacity: 0.5; + cursor: not-allowed; + outline: none; + } + + .email:not(.email--disabled).email--open .email__combobox, + .email:not(.email--disabled).email--focused .email__combobox { + background-color: var(--sl-input-background-color-focus); + border-color: var(--sl-input-border-color-focus); + box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-input-focus-ring-color); + } + + /* Tags */ + + .email et2-email-tag { + --icon-width: 1.8em; + } + + /* Search box */ + + .email__search { + flex: 1 1 auto; + min-width: 10em; + border: none; + outline: none; + + font-size: var(--sl-input-font-size-medium); + min-height: var(--sl-input-height-medium); + padding-block: 0; + padding-inline: var(--sl-input-spacing-medium); + } + + /* Listbox */ + + .email__listbox { + display: block; + position: relative; + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + box-shadow: var(--sl-shadow-large); + background: var(--sl-panel-background-color); + border: solid var(--sl-panel-border-width) var(--sl-panel-border-color); + border-radius: var(--sl-border-radius-medium); + padding-block: var(--sl-spacing-x-small); + padding-inline: 0; + overflow: auto; + overscroll-behavior: none; + + /* Make sure it adheres to the popup's auto size */ + max-width: var(--auto-size-available-width); + max-height: var(--auto-size-available-height); + + --icon-width: 1.8em; + } + + .email__listbox ::slotted(sl-divider) { + --spacing: var(--sl-spacing-x-small); + } + + .email__listbox ::slotted(small) { + font-size: var(--sl-font-size-small); + font-weight: var(--sl-font-weight-semibold); + color: var(--sl-color-neutral-500); + padding-block: var(--sl-spacing-x-small); + padding-inline: var(--sl-spacing-x-large); + } + +`; \ No newline at end of file diff --git a/api/js/etemplate/Et2Email/Et2Email.ts b/api/js/etemplate/Et2Email/Et2Email.ts new file mode 100644 index 0000000000..db0a3a2966 --- /dev/null +++ b/api/js/etemplate/Et2Email/Et2Email.ts @@ -0,0 +1,737 @@ +/** + * EGroupware eTemplate2 - Email WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ +import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +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 {repeat} from "lit/directives/repeat.js"; +import {HasSlotController} from "../Et2Widget/slot"; +import {SlOption, SlPopup, SlRemoveEvent} from "@shoelace-style/shoelace"; +import shoelace from "../Styles/shoelace"; +import {Et2EmailTag} from "../Et2Select/Tag/Et2EmailTag"; +import {waitForEvent} from "../Et2Widget/event"; +import styles from "./Et2Email.styles"; +import {SelectOption} from "../Et2Select/FindSelectOptions"; + +/** + * @summary Enter email addresses, offering suggestions from contacts + * @documentation https://shoelace.style/components/select + * @since 23.1 + * + * @dependency sl-icon + * @dependency sl-popup + * @dependency et2-email-tag + * @dependency et2-textbox + * + * @slot - The suggestion options. Must be `` elements. You can use `` to group items visually. + * @slot label - The input's label. Alternatively, you can use the `label` attribute. + * @slot prefix - Used to prepend a presentational icon or similar element to the combobox. + * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. + * + * @event sl-change - Emitted when the control's value changes. + * @event sl-input - Emitted when the control receives input. + * @event sl-focus - Emitted when the control gains focus. + * @event sl-blur - Emitted when the control loses focus. + * @event sl-show - Emitted when the suggestion menu opens. + * @event sl-after-show - Emitted after the suggestion menu opens and all animations are complete. + * @event sl-hide - Emitted when the suggestion menu closes. + * @event sl-after-hide - Emitted after the suggestion menu closes and all animations are complete. + * @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. + * + * @csspart form-control - The form control that wraps the label, input, and help text. + * @csspart form-control-label - The label's wrapper. + * @csspart form-control-input - The textbox's wrapper. + * @csspart form-control-help-text - The help text's wrapper. + * @csspart prefix - The container that wraps the prefix slot. + * @csspart listbox - The listbox container where options are slotted. + * @csspart tags - The container that houses email tags + * @csspart tag - The individual tags that represent each email address. + * @csspart tag__base - The tag's base part. + * @csspart tag__content - The tag's content part. + * @csspart tag__remove-button - The tag's remove button. + * @csspart tag__remove-button__base - The tag's remove button base part. + */ +export class Et2Email extends Et2InputWidget(LitElement) +{ + static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true}; + + static get styles() + { + return [ + shoelace, + ...super.styles, + styles + ]; + } + + /** + * The current value of the component, an array of valid email addresses + */ + @property({ + converter: { + fromAttribute: (value : string) => + { + // Parse string into array + if(typeof value === 'string' && value.indexOf(',') !== -1) + { + let val = value.split(','); + for(let n = 0; n < val.length - 1; n++) + { + while(val[n].indexOf('@') === -1 && n < val.length - 1) + { + val[n] += ',' + val[n + 1]; + val.splice(n + 1, 1); + } + } + return val; + } + return value; + }, + toAttribute: (value : string[]) => value.join(',') + } + }) + value : string[] = []; + + /** Placeholder text to show as a hint when the select is empty. */ + @property() placeholder = ''; + + /** Allow drag and drop tags between two or more Et2Email widgets */ + @property({type: Boolean}) + allowDragAndDrop? : boolean; + + /** Allow placeholders like {{email}}, as well as real email-addresses */ + @property({type: Boolean}) + allowPlaceholder : boolean; + + /** Include mailing lists: returns them with their integer list_id */ + @property({type: Boolean}) + includeLists : boolean; + + /** + * If the email is a contact, we normally show the contact name instead of the email. + * Set to true to turn this off and always show just the email + * Mutually exclusive with fullEmail! + */ + @property({type: Boolean}) + onlyEmail : boolean; + + /** Show the full, original value email address under all circumstances, rather than the contact name for known contacts */ + @property({type: Boolean}) + fullEmail : boolean; + + /** The component's help text. If you need to display HTML, use the `help-text` slot instead. */ + @property({attribute: 'help-text'}) helpText = ''; + + /** + * Indicates whether the suggestions are open. You can toggle this attribute to show and hide the menu, or you can + * use the `show()` and `hide()` methods and this attribute will reflect the suggestion open state. + */ + @property({type: Boolean, reflect: true}) open = false; + + @property({type: Object}) searchOptions = {}; + @property({type: String}) searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email"; + + @state() searching = false; + @state() hasFocus = false; + @state() currentOption : SlOption; + @state() currentTag : Et2EmailTag; + + + get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup");} + + get _listbox() : HTMLElement { return this.shadowRoot.querySelector("#listbox");} + + get _search() : HTMLInputElement { return this.shadowRoot.querySelector("#search");} + + get _tags() : Et2EmailTag[] { return Array.from(this.shadowRoot.querySelectorAll("et2-email-tag"));} + + get _suggestions() : SlOption[] { return Array.from(this.shadowRoot.querySelectorAll("sl-option"));} + + /** + * 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; + + protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); + + /** User preference to immediately close the search results after selecting a match */ + protected _close_on_select = true; + + protected _searchTimeout : number; + protected _searchPromise : Promise = Promise.resolve([]); + protected _selectOptions : SelectOption[] = []; + + + constructor(...args : any[]) + { + // @ts-ignore + super(...args); + + // Additional option for select email, per ticket #79694 + this._close_on_select = this.egw().preference("select_multiple_close") != "open"; + + this.handleOpenChange = this.handleOpenChange.bind(this); + this.handleLostFocus = this.handleLostFocus.bind(this); + } + + connectedCallback() + { + super.connectedCallback(); + this.open = false; + } + + update(changedProperties : PropertyValues) + { + super.update(changedProperties) + + if(changedProperties.has("open")) + { + this.handleOpenChange(); + } + } + + private addOpenListeners() + { + document.addEventListener('focusin', this.handleLostFocus); + document.addEventListener('mousedown', this.handleLostFocus); + } + + private removeOpenListeners() + { + document.removeEventListener('focusin', this.handleLostFocus); + document.removeEventListener('mousedown', this.handleLostFocus); + } + + + /** 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._search) + { + this._search.focus(options); + } + } + + /** Removes focus from the control. */ + blur() + { + this.open = false; + this.hasFocus = false; + // Should not be needed, but not firing the update + this.requestUpdate("open"); + this.requestUpdate("hasFocus"); + this._search.blur(); + + clearTimeout(this._searchTimeout); + } + + + /** Shows the listbox. */ + async show() + { + if(this.open || this.disabled) + { + this.open = false; + this.requestUpdate("open", true); + return undefined; + } + + this.open = true; + this.requestUpdate("open", false) + return waitForEvent(this, 'sl-after-show'); + } + + /** Hides the listbox. */ + async hide() + { + this.open = false; + this.requestUpdate("open"); + if(!this.open || this.disabled) + { + return undefined; + } + + return waitForEvent(this, 'sl-after-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.searching = true; + + // Start the searches + this._searchPromise = this.remoteSearch(this._search.value, this.searchOptions); + return this._searchPromise.then(async() => + { + this.searching = false; + if(!this.open) + { + this.show(); + } + + await this.updateComplete; + }); + } + + /** + * 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" + * + * + * @param {string} search + * @param {object} options + * @returns Promise + * @protected + */ + protected remoteSearch(search : string, options : object) : Promise + { + // Include a limit, even if options don't, to avoid massive lists breaking the UI + let sendOptions = { + num_rows: 10, + ...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.processRemoteResults(results); + }); + } + + /** + * Add in remote results + * + * Any results that already exist will be removed to avoid duplicates + * + * @param results + * @protected + */ + protected processRemoteResults(entries) + { + if(!entries?.length) + { + return []; + } + + this._selectOptions = entries; + + this.requestUpdate(); + + return entries; + } + + + /** + * Focus has gone somewhere else + * @param {MouseEvent} event + */ + private handleLostFocus = (event : MouseEvent | KeyboardEvent) => + { + // Close when clicking outside of the component + const path = event.composedPath(); + if(this && !path.includes(this)) + { + this.hide(); + } + }; + + async handleOpenChange() + { + if(this.open && !this.disabled) + { + // Reset the current option + // TODO + //this.setCurrentOption(this._suggestions[0]); + + // Show + this.dispatchEvent(new CustomEvent('sl-show', {bubbles: true})); + this.addOpenListeners(); + + this._listbox.hidden = false; + this._popup.active = true; + + // Select the appropriate option based on value after the listbox opens + requestAnimationFrame(() => + { + this.setCurrentOption(this.currentOption); + }); + + // Make sure the current option is scrolled into view (required for Safari) + if(this.currentOption) + { + // TODO + //scrollIntoView(this.currentOption, this._listbox, 'vertical', 'auto'); + } + + this.dispatchEvent(new CustomEvent('sl-after-show', {bubbles: true})); + } + else + { + // Hide + this.dispatchEvent(new CustomEvent('sl-hide', {bubbles: true})); + this.removeOpenListeners(); + + this._listbox.hidden = true; + this._popup.active = false; + + this.dispatchEvent(new CustomEvent('sl-after-hide', {bubbles: true})); + } + } + + private handleSearchFocus() + { + this.hasFocus = true; + // Should not be needed, but not firing the update + this.requestUpdate("hasFocus"); + + // Reset tags to not take focus + this._tags.forEach(t => t.tabIndex = -1); + this.currentTag = null; + + this._search.setSelectionRange(0, 0); + } + + private handleSearchBlur() + { + this.hasFocus = false; + // Should not be needed, but not firing the update + this.requestUpdate("hasFocus"); + } + + handleSearchKeyDown(event) + { + clearTimeout(this._searchTimeout); + + // Left at beginning goes to tags + if(this._search.selectionStart == 0 && event.key == "ArrowLeft") + { + this.hide(); + this._tags.forEach(t => t.tabIndex = 0); + this.currentTag = this._tags[this._tags.length - 1]; + this.currentTag.focus(); + event.stopPropagation(); + return; + } + + // Tab on empty leaves + if(this._search.value == "" && event.key == "Tab") + { + // Propagate, browser will do its thing + return; + } + // Up / Down navigates options + if(['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) + { + // TODO - pass focus to list + this.show(); + return; + } + // Tab or enter checks current value + 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._search.value.length - 1 > 0) + { + this._searchTimeout = window.setTimeout(() => {this.startSearch()}, Et2Email.SEARCH_TIMEOUT); + } + } + + protected handleLabelClick() + { + this._search.focus(); + } + + /** + * Keyboard events that the search input did not grab + * (tags, otion navigation) + * + * @param {KeyboardEvent} event + */ + handleComboboxKeyDown(event : KeyboardEvent) + { + // Navigate between tags + if(this.currentTag && (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))) + { + let nextTagIndex = this._tags.indexOf(this.currentTag); + const tagCount = this._tags.length + switch(event.key) + { + case 'ArrowLeft': + nextTagIndex--; + break; + case 'ArrowRight': + nextTagIndex++; + break; + case 'Home': + nextTagIndex = 0; + break; + case 'End': + nextTagIndex = this._tags.length - 1; + break; + } + nextTagIndex = Math.max(0, nextTagIndex); + if(nextTagIndex < tagCount && this._tags[nextTagIndex]) + { + this._tags.forEach(t => t.tabIndex = -1); + this.currentTag = this._tags[nextTagIndex]; + this.currentTag.tabIndex = 0; + this.currentTag.focus(); + } + else + { + // Arrow back to search, or got lost + this._search.focus(); + } + event.stopPropagation(); + return false; + } + // Remove tag + if(event.target instanceof Et2EmailTag && ["Delete", "Backspace"].includes(event.key)) + { + event.target.dispatchEvent(new CustomEvent('sl-remove', {bubbles: true})); + } + // Edit tag + else if(event.target instanceof Et2EmailTag && ["Enter"].includes(event.key)) + { + event.target.startEdit(); + } + } + + handleTagChange(event) + { + // Need to update our value, or it will just redo the tag with the old value + debugger; + if(event.originalValue && this.value.indexOf(event.originalValue)) + { + let index = this.value.indexOf(event.originalValue); + this.value[index] = event.target.value; + this.requestUpdate(); + } + } + + handleTagRemove(event : SlRemoveEvent, value : string) + { + // Find the tag value and remove it from current value + debugger; + const index = this.value.indexOf(value); + this.value.splice(index, 1); + this.requestUpdate("value"); + } + + tagsTemplate() + { + return this.value.map((value, index) => + { + // Wrap so we can handle the remove + return html` +
this.handleTagRemove(e, value)}> + ${this.tagTemplate(value)} +
`; + }); + } + + tagTemplate(value) + { + const readonly = (this.readonly); + const isEditable = !readonly; + + return html` + {this._cancelOpen = true;}} + @change=${this.handleTagChange} + > + `; + } + + inputTemplate() + { + return html` + + `; + } + + suggestionsTemplate() + { + return html`${repeat(this._selectOptions, (o : SelectOption) => o.value, this.optionTemplate.bind(this))}`; + } + + + /** + * Used to render each option into the suggestions + * + * @param {SelectOption} option + * @returns {TemplateResult} + */ + protected optionTemplate(option : SelectOption) : TemplateResult + { + const classes = option.class ? Object.fromEntries((option.class).split(" ").map(k => [k, true])) : {}; + const value = (option.value).replaceAll(" ", "___"); + return html` + + + + ${this.noLang ? option.label : this.egw().lang(option.label)} + `; + } + + render() + { + const hasLabelSlot = this.hasSlotController.test('label'); + const hasHelpTextSlot = this.hasSlotController.test('help-text'); + const hasLabel = this.label ? true : !!hasLabelSlot; + const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const isPlaceholderVisible = this.placeholder && this.value.length === 0; + + // TODO Don't forget required & disabled + + return html` +
+ +
+ + + + +
+
+ ${this.helpText} +
+
+ `; + } +} + +// @ts-ignore TypeScript is not recognizing that this widget is a LitElement +customElements.define("et2-email", Et2Email); \ No newline at end of file diff --git a/api/js/etemplate/Et2Email/test/Et2Email.test.ts b/api/js/etemplate/Et2Email/test/Et2Email.test.ts new file mode 100644 index 0000000000..49c0c4980a --- /dev/null +++ b/api/js/etemplate/Et2Email/test/Et2Email.test.ts @@ -0,0 +1,149 @@ +import {assert, elementUpdated, fixture, html, oneEvent} from '@open-wc/testing'; +import * as sinon from 'sinon'; +import {inputBasicTests} from "../../Et2InputWidget/test/InputBasicTests"; +import {Et2Email} from "../Et2Email"; + +/** + * Test file for Etemplate webComponent Select + * + * In here we test just the simple, basic widget stuff. + */ +// Stub global egw for cssImage to find +// @ts-ignore +window.egw = { + image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=", + lang: i => i + "*", + tooltipUnbind: () => {}, + webserverUrl: "" +}; + +let element : Et2Email; + +async function before() +{ + // Create an element to test with, and wait until it's ready + // @ts-ignore + element = await fixture(html` + + + `); + + // Stub egw() + sinon.stub(element, "egw").returns(window.egw); + await elementUpdated(element); + + return element; +} + +describe("Email widget basics", () => +{ + // Setup run before each test + beforeEach(before); + + // Make sure it works + it('is defined', () => + { + assert.instanceOf(element, Et2Email); + }); + + it('has a label', async() => + { + element.set_label("Label set"); + await elementUpdated(element); + + assert.equal(element.querySelector("[slot='label']").textContent, "Label set"); + }) + + it("closes when losing focus", async() => + { + // WIP + const blurSpy = sinon.spy(); + element.addEventListener('sl-hide', blurSpy); + const showPromise = new Promise(resolve => + { + element.addEventListener("sl-after-show", resolve); + }); + const hidePromise = new Promise(resolve => + { + element.addEventListener("sl-hide", resolve); + }); + await elementUpdated(element); + element.focus(); + + await showPromise; + await elementUpdated(element); + + element.blur(); + await elementUpdated(element); + + await hidePromise; + + sinon.assert.calledOnce(blurSpy); + + // Check that it actually closed dropdown + assert.isFalse(element.hasAttribute("open")); + }) +}); + +describe("Tags", () => +{ + beforeEach(async() => + { + // Create an element to test with, and wait until it's ready + // @ts-ignore + element = await fixture(html` + + + `); + element.loadFromXML(element); + + // Stub egw() + sinon.stub(element, "egw").returns(window.egw); + + return element; + }); + + it("Can remove tags", async() => + { + assert.equal(element._tags.length, 2, "Did not find tags"); + + // Await tags to render + /* TODO + let tag_updates = [] + element.select.combobox.querySelectorAll("et2-tag").forEach((t : Et2Tag) => tag_updates.push(t.updateComplete)); + await Promise.all(tag_updates); + + assert.equal(tags.length, 2); + assert.equal(tags[0].value, "one"); + assert.equal(tags[1].value, "two"); +*/ + // Set up listener + const listener = oneEvent(element, "change"); + + // Click to remove first tag + let removeButton = tags[0].shadowRoot.querySelector("[part='remove-button']"); + assert.exists(removeButton, "Could not find tag remove button"); + removeButton.dispatchEvent(new Event("click")); + + await listener; + + // Wait for widget to update + await element.updateComplete; + tag_updates = [] + element.select.combobox.querySelectorAll('et2-tag').forEach((t : Et2Tag) => tag_updates.push(t.updateComplete)); + await Promise.all(tag_updates); + + // Check + assert.sameMembers(element.value, ["two"], "Removing tag did not remove value"); + tags = element.select.combobox.querySelectorAll('.select__tags et2-tag'); + assert.equal(tags.length, 1, "Removed tag is still there"); + }); + +}); + +inputBasicTests(async() => +{ + const element = await before(); + element.noLang = true; + return element +}, "", "sl-select"); \ No newline at end of file diff --git a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts index dfe9484366..cdfe005e95 100644 --- a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts +++ b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts @@ -108,6 +108,7 @@ export class Et2Tag extends Et2Widget(SlTag) ` : '' } @@ -205,6 +206,10 @@ export class Et2Tag extends Et2Widget(SlTag) stopEdit() { this.isEditing = false; + let event = new Event("change", { + bubbles: true + }); + event.originalValue = this.value; this.dataset.original_value = this.value; if(!this.editable) { @@ -214,9 +219,6 @@ export class Et2Tag extends Et2Widget(SlTag) this.requestUpdate(); this.updateComplete.then(() => { - let event = new Event("change", { - bubbles: true - }) this.dispatchEvent(event); }) } @@ -235,6 +237,11 @@ export class Et2Tag extends Et2Widget(SlTag) { this._editNode.blur(); } + else if(["Escape"].includes(event.key)) + { + this._editNode.value = this.value; + this.stopEdit(); + } } handleChange(event : CustomEvent) diff --git a/api/js/etemplate/Et2Widget/Et2Widget.ts b/api/js/etemplate/Et2Widget/Et2Widget.ts index 96eec296b3..6a639873e4 100644 --- a/api/js/etemplate/Et2Widget/Et2Widget.ts +++ b/api/js/etemplate/Et2Widget/Et2Widget.ts @@ -620,7 +620,9 @@ const Et2WidgetMixin = (superClass : T) => return true; } - /** et2_widget compatability **/ + /** et2_widget compatability + * @deprecated + **/ destroy() { // Not really needed, use the disconnectedCallback() and let the browser handle it diff --git a/api/js/etemplate/Et2Widget/event.ts b/api/js/etemplate/Et2Widget/event.ts new file mode 100644 index 0000000000..cbff717a5d --- /dev/null +++ b/api/js/etemplate/Et2Widget/event.ts @@ -0,0 +1,22 @@ +/** + * Waits for a specific event to be emitted from an element. Ignores events that bubble up from child elements. + * + * Copied from Shoelace + * /src/internal/event.ts + */ +export function waitForEvent(el : HTMLElement, eventName : string) +{ + return new Promise(resolve => + { + function done(event : Event) + { + if(event.target === el) + { + el.removeEventListener(eventName, done); + resolve(); + } + } + + el.addEventListener(eventName, done); + }); +} diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 0c002bf252..178f746842 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -50,6 +50,7 @@ import './Et2Date/Et2DateTimeToday'; import './Et2Description/Et2Description'; import './Et2Dialog/Et2Dialog'; import './Et2DropdownButton/Et2DropdownButton'; +import './Et2Email/Et2Email'; import './Expose/Et2ImageExpose'; import './Expose/Et2DescriptionExpose'; import './Et2Favorites/Et2Favorites';