From e99b398d5da633476aeac1ce4be71df445dfc357 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 11 Dec 2023 15:12:59 -0700 Subject: [PATCH] Et2Email: WIP - Most interactions done --- api/js/etemplate/Et2Email/Et2Email.styles.ts | 20 +- api/js/etemplate/Et2Email/Et2Email.ts | 299 +++++++++++++++++-- api/js/etemplate/Et2Select/Tag/Et2Tag.ts | 7 +- 3 files changed, 294 insertions(+), 32 deletions(-) diff --git a/api/js/etemplate/Et2Email/Et2Email.styles.ts b/api/js/etemplate/Et2Email/Et2Email.styles.ts index cf3fbd6f6f..3c8b3d4090 100644 --- a/api/js/etemplate/Et2Email/Et2Email.styles.ts +++ b/api/js/etemplate/Et2Email/Et2Email.styles.ts @@ -1,6 +1,18 @@ import {css} from 'lit'; export default css` + :host([open]) { + /* Handles z-index issues with toolbar of html editor on the page*/ + position: relative; + z-index: 2; + } + + .form-control-input { + /* This allows the dropdown to show over other inputs */ + position: relative; + z-index: 1; + } + .email .email__combobox { flex: 1; display: flex; @@ -39,8 +51,13 @@ export default css` /* Tags */ + .email .email__combobox > div { + margin: auto 0px; + } .email et2-email-tag { --icon-width: 1.8em; + + outline: none; } /* Search box */ @@ -78,7 +95,8 @@ export default css` max-width: var(--auto-size-available-width); max-height: var(--auto-size-available-height); - --icon-width: 1.8em; + /* This doesn't work for some reason, it's overwritten somewhere */ + --size: 1.8em; } .email__listbox ::slotted(sl-divider) { diff --git a/api/js/etemplate/Et2Email/Et2Email.ts b/api/js/etemplate/Et2Email/Et2Email.ts index db0a3a2966..d298f4bf65 100644 --- a/api/js/etemplate/Et2Email/Et2Email.ts +++ b/api/js/etemplate/Et2Email/Et2Email.ts @@ -19,6 +19,10 @@ import {Et2EmailTag} from "../Et2Select/Tag/Et2EmailTag"; import {waitForEvent} from "../Et2Widget/event"; import styles from "./Et2Email.styles"; import {SelectOption} from "../Et2Select/FindSelectOptions"; +import {SearchMixinInterface} from "../Et2Select/SearchMixin"; +import {IsEmail} from "../Validators/IsEmail"; +import {Validator} from "@lion/form-core"; +import Sortable from "sortablejs/modular/sortable.complete.esm.js"; /** * @summary Enter email addresses, offering suggestions from contacts @@ -58,7 +62,7 @@ import {SelectOption} from "../Et2Select/FindSelectOptions"; * @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) +export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinInterface { static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true}; @@ -159,7 +163,13 @@ export class Et2Email extends Et2InputWidget(LitElement) * @type {number} * @protected */ - protected static SEARCH_TIMEOUT = 500; + public static SEARCH_TIMEOUT = 500; + + /** + * Typing these characters will end the email address and start a new one + * @type {string[]} + */ + public static TAG_BREAK : string[] = ["Tab", "Enter", ","]; protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); @@ -170,12 +180,15 @@ export class Et2Email extends Et2InputWidget(LitElement) protected _searchPromise : Promise = Promise.resolve([]); protected _selectOptions : SelectOption[] = []; + protected _sortable : Sortable; constructor(...args : any[]) { // @ts-ignore super(...args); + this.defaultValidators.push(new IsEmail(this.allowPlaceholder)); + // Additional option for select email, per ticket #79694 this._close_on_select = this.egw().preference("select_multiple_close") != "open"; @@ -189,6 +202,17 @@ export class Et2Email extends Et2InputWidget(LitElement) this.open = false; } + willUpdate(changedProperties : PropertyValues) + { + super.willUpdate(changedProperties); + + if(changedProperties.has('allowPlaceholder')) + { + this.defaultValidators = (>this.defaultValidators).filter(v => !(v instanceof IsEmail)); + this.defaultValidators.push(new IsEmail(this.allowPlaceholder)); + } + } + update(changedProperties : PropertyValues) { super.update(changedProperties) @@ -199,6 +223,17 @@ export class Et2Email extends Et2InputWidget(LitElement) } } + updated(changedProperties : PropertyValues) + { + super.updated(changedProperties); + + // Re-set sorting / drag & drop + if(changedProperties.has("value")) + { + this.makeSortable(); + } + } + private addOpenListeners() { document.addEventListener('focusin', this.handleLostFocus); @@ -211,6 +246,93 @@ export class Et2Email extends Et2InputWidget(LitElement) document.removeEventListener('mousedown', this.handleLostFocus); } + protected makeSortable() + { + // TODO + } + + /** + * Sets the current suggestion, which is the option the user is currently interacting with (e.g. via keyboard). + * Only one option may be "current" at a time. + */ + private setCurrentOption(option : SlOption | null) + { + // Clear selection + this._suggestions.forEach(el => + { + el.current = false; + el.tabIndex = -1; + }); + + // Select the target option + if(option) + { + this.currentOption = option; + option.current = true; + option.tabIndex = 0; + option.focus(); + } + } + + private setCurrentTag(tag : Et2EmailTag) + { + this._tags.forEach(t => + { + t.tabIndex = -1; + if(t.current) + { + t.current = false; + t.requestUpdate(); + } + }); + this.currentTag = tag; + if(tag) + { + this.currentTag.tabIndex = 0; + this.currentTag.current = true; + this.currentTag.requestUpdate(); + this.currentTag.focus(); + } + } + + /** + * 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 addAddress(text : string) : boolean + { + if(!text || !this.validateAddress(text)) + { + return false; + } + // Make sure not to double-add + if(!this.value.includes(text.replace(/'/g, "\\\'"))) + { + this.value.push(text.trim()); + this.requestUpdate('value'); + } + + this.dispatchEvent(new Event("change", {bubbles: true})); + + return true; + } + + /** + * Check if a free entry value is acceptable. + * We use validators directly using the proposed value + * + * @param text + * @returns {boolean} + */ + public validateAddress(text) : boolean + { + let validators = [...this.validators, ...this.defaultValidators]; + let result = validators.filter(v => + v.execute(text, v.param, {node: this}), + ); + return validators.length > 0 && result.length == 0 || validators.length == 0; + } /** Sets focus on the control. */ focus(options? : FocusOptions) @@ -286,7 +408,7 @@ export class Et2Email extends Et2InputWidget(LitElement) return this._searchPromise.then(async() => { this.searching = false; - if(!this.open) + if(!this.open && this.hasFocus) { this.show(); } @@ -334,12 +456,11 @@ export class Et2Email extends Et2InputWidget(LitElement) */ protected processRemoteResults(entries) { - if(!entries?.length) - { - return []; - } - this._selectOptions = entries; + this.updateComplete.then(() => + { + this.currentOption = this._suggestions[0]; + }); this.requestUpdate(); @@ -366,8 +487,7 @@ export class Et2Email extends Et2InputWidget(LitElement) if(this.open && !this.disabled) { // Reset the current option - // TODO - //this.setCurrentOption(this._suggestions[0]); + this.setCurrentOption(this._suggestions[0]); // Show this.dispatchEvent(new CustomEvent('sl-show', {bubbles: true})); @@ -385,8 +505,7 @@ export class Et2Email extends Et2InputWidget(LitElement) // Make sure the current option is scrolled into view (required for Safari) if(this.currentOption) { - // TODO - //scrollIntoView(this.currentOption, this._listbox, 'vertical', 'auto'); + this.currentOption.scrollIntoView(); } this.dispatchEvent(new CustomEvent('sl-after-show', {bubbles: true})); @@ -411,8 +530,7 @@ export class Et2Email extends Et2InputWidget(LitElement) this.requestUpdate("hasFocus"); // Reset tags to not take focus - this._tags.forEach(t => t.tabIndex = -1); - this.currentTag = null; + this.setCurrentTag(null); this._search.setSelectionRange(0, 0); } @@ -433,8 +551,10 @@ export class Et2Email extends Et2InputWidget(LitElement) { this.hide(); this._tags.forEach(t => t.tabIndex = 0); - this.currentTag = this._tags[this._tags.length - 1]; - this.currentTag.focus(); + if(this._tags.length > 0) + { + this.setCurrentTag(this._tags[this._tags.length - 1]); + } event.stopPropagation(); return; } @@ -446,13 +566,34 @@ export class Et2Email extends Et2InputWidget(LitElement) return; } // Up / Down navigates options - if(['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) + if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._suggestions.length) { - // TODO - pass focus to list - this.show(); + if(!this.open) + { + this.show(); + } + event.stopPropagation(); + this.setCurrentOption(this._suggestions[0]); return; } // Tab or enter checks current value + else if(Et2Email.TAG_BREAK.indexOf(event.key) !== -1) + { + if(!this.validateAddress(this._search.value.trim()) && this.currentOption) + { + this._search.value = this.currentOption.value.replaceAll("___", " "); + } + if(this.addAddress(this._search.value.trim())) + { + this.open = false; + this._search.value = ""; + } + if(event.key == "Tab") + { + this.blur(); + } + } + // Start search immediately else if(event.key == "Enter") { event.preventDefault(); @@ -461,7 +602,7 @@ export class Et2Email extends Et2InputWidget(LitElement) } else if(event.key == "Escape") { - this.handleSearchAbort(event); + this._selectOptions = []; this.hide(); return; } @@ -510,10 +651,7 @@ export class Et2Email extends Et2InputWidget(LitElement) 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(); + this.setCurrentTag(this._tags[nextTagIndex]); } else { @@ -526,7 +664,18 @@ export class Et2Email extends Et2InputWidget(LitElement) // Remove tag if(event.target instanceof Et2EmailTag && ["Delete", "Backspace"].includes(event.key)) { + const tags = this._tags; + let index = tags.indexOf(event.target); event.target.dispatchEvent(new CustomEvent('sl-remove', {bubbles: true})); + index += event.key == "Delete" ? 1 : -1; + if(index >= 0 && index < tags.length) + { + this.setCurrentTag(this._tags[index]); + } + else + { + this._search.focus(); + } } // Edit tag else if(event.target instanceof Et2EmailTag && ["Enter"].includes(event.key)) @@ -535,6 +684,92 @@ export class Et2Email extends Et2InputWidget(LitElement) } } + /** + * Keyboard events from the suggestion list + * + * @param {KeyboardEvent} event + */ + handleSuggestionsKeyDown(event : KeyboardEvent) + { + // Select the option + const value = (this.currentOption.value).replaceAll("___", " "); + if(this.currentOption && ["ArrowRight", " ", ...Et2Email.TAG_BREAK].includes(event.key) && this.addAddress(value)) + { + event.preventDefault(); + this._search.value = ""; + this.open = false; + if(this._close_on_select) + { + this.blur(); + } + else + { + this._search.focus(); + } + event.stopPropagation(); + return; + } + // Navigate options + if(["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) + { + event.stopPropagation() + const suggestions = this._suggestions; + const currentIndex = suggestions.indexOf(this.currentOption); + let newIndex = Math.max(0, currentIndex); + + // Prevent scrolling + event.preventDefault(); + + if(event.key === "ArrowDown") + { + newIndex = currentIndex + 1; + if(newIndex > suggestions.length - 1) + { + newIndex = 0; + } + } + else if(event.key === "ArrowUp") + { + newIndex = currentIndex - 1; + if(newIndex < 0) + { + newIndex = suggestions.length - 1; + } + } + else if(event.key === "Home") + { + newIndex = 0; + } + else if(event.key === "End") + { + newIndex = suggestions.length - 1; + } + + this.setCurrentOption(suggestions[newIndex]); + } + } + + /** + * Mouse up from the suggestion list + * @param event + */ + handleSuggestionsMouseUp(event : MouseEvent) + { + const value = ((event.target).value).replaceAll("___", " "); + this.value.push(value); + this.open = false; + this._search.value = ""; + this.requestUpdate("value"); + if(this._close_on_select) + { + this.blur(); + } + else + { + this._search.focus(); + } + } + handleTagChange(event) { // Need to update our value, or it will just redo the tag with the old value @@ -545,12 +780,16 @@ export class Et2Email extends Et2InputWidget(LitElement) this.value[index] = event.target.value; this.requestUpdate(); } + if(event.target.current) + { + this.setCurrentTag(event.target); + ; + } } 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"); @@ -585,6 +824,7 @@ export class Et2Email extends Et2InputWidget(LitElement) ?readonly=${readonly} ?editable=${isEditable} @mousedown=${(e) => {this._cancelOpen = true;}} + @dblclick=${(e) => {e.target.startEdit();}} @change=${this.handleTagChange} > `; @@ -597,7 +837,8 @@ export class Et2Email extends Et2InputWidget(LitElement) class="email__search" exportparts="base:search__base" autocomplete="off" - placeholder="${this.hasFocus ? "" : this.placeholder}" + placeholder="${this.hasFocus || this.value.length > 0 ? "" : this.placeholder}" + tabindex="0" @keydown=${this.handleSearchKeyDown} @blur=${this.handleSearchBlur} @focus=${this.handleSearchFocus} @@ -633,7 +874,7 @@ export class Et2Email extends Et2InputWidget(LitElement) .option=${option} ?disabled=${option.disabled} > - ${this.tagsTemplate()} @@ -714,7 +954,8 @@ export class Et2Email extends Et2InputWidget(LitElement) part="listbox" class="email__listbox" tabindex="-1" - @mouseup=${this.handleOptionClick} + @keydown=${this.handleSuggestionsKeyDown} + @mouseup=${this.handleSuggestionsMouseUp} > ${this.suggestionsTemplate()} diff --git a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts index cdfe005e95..e87e8c35ef 100644 --- a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts +++ b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts @@ -11,6 +11,7 @@ import {SlTag} from "@shoelace-style/shoelace"; import {css, html, TemplateResult} from "lit"; import {classMap} from "lit/directives/class-map.js"; import shoelace from "../../Styles/shoelace"; +import {state} from "lit/decorators/state.js"; /** * Tag is usually used in a Select with multiple=true, but there's no reason it can't go anywhere @@ -77,6 +78,8 @@ export class Et2Tag extends Et2Widget(SlTag) } } + @state() current = false; // the user has keyed into the tag (focused), but hasn't done anything yet (shows a highlight) + constructor(...args : []) { super(...args); @@ -137,9 +140,9 @@ export class Et2Tag extends Et2Widget(SlTag) 'tag--editable': this.editable, 'tag--editing': this.isEditing, // Types - 'tag--primary': this.variant === 'primary', + 'tag--primary': this.variant === 'primary' || this.current, 'tag--success': this.variant === 'success', - 'tag--neutral': this.variant === 'neutral', + 'tag--neutral': this.variant === 'neutral' && !this.current, 'tag--warning': this.variant === 'warning', 'tag--danger': this.variant === 'danger', 'tag--text': this.variant === 'text',