Et2Email: WIP - Most interactions done

This commit is contained in:
nathan 2023-12-11 15:12:59 -07:00
parent 580466f9b8
commit a60844d45a
3 changed files with 294 additions and 32 deletions

View File

@ -1,6 +1,18 @@
import {css} from 'lit'; import {css} from 'lit';
export default css` 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 { .email .email__combobox {
flex: 1; flex: 1;
display: flex; display: flex;
@ -39,8 +51,13 @@ export default css`
/* Tags */ /* Tags */
.email .email__combobox > div {
margin: auto 0px;
}
.email et2-email-tag { .email et2-email-tag {
--icon-width: 1.8em; --icon-width: 1.8em;
outline: none;
} }
/* Search box */ /* Search box */
@ -78,7 +95,8 @@ export default css`
max-width: var(--auto-size-available-width); max-width: var(--auto-size-available-width);
max-height: var(--auto-size-available-height); 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) { .email__listbox ::slotted(sl-divider) {

View File

@ -19,6 +19,10 @@ import {Et2EmailTag} from "../Et2Select/Tag/Et2EmailTag";
import {waitForEvent} from "../Et2Widget/event"; import {waitForEvent} from "../Et2Widget/event";
import styles from "./Et2Email.styles"; import styles from "./Et2Email.styles";
import {SelectOption} from "../Et2Select/FindSelectOptions"; 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 * @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 - The tag's remove button.
* @csspart tag__remove-button__base - The tag's remove button base part. * @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}; static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true};
@ -159,7 +163,13 @@ export class Et2Email extends Et2InputWidget(LitElement)
* @type {number} * @type {number}
* @protected * @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'); protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
@ -170,12 +180,15 @@ export class Et2Email extends Et2InputWidget(LitElement)
protected _searchPromise : Promise<SelectOption[]> = Promise.resolve([]); protected _searchPromise : Promise<SelectOption[]> = Promise.resolve([]);
protected _selectOptions : SelectOption[] = []; protected _selectOptions : SelectOption[] = [];
protected _sortable : Sortable;
constructor(...args : any[]) constructor(...args : any[])
{ {
// @ts-ignore // @ts-ignore
super(...args); super(...args);
this.defaultValidators.push(new IsEmail(this.allowPlaceholder));
// Additional option for select email, per ticket #79694 // Additional option for select email, per ticket #79694
this._close_on_select = this.egw().preference("select_multiple_close") != "open"; this._close_on_select = this.egw().preference("select_multiple_close") != "open";
@ -189,6 +202,17 @@ export class Et2Email extends Et2InputWidget(LitElement)
this.open = false; this.open = false;
} }
willUpdate(changedProperties : PropertyValues)
{
super.willUpdate(changedProperties);
if(changedProperties.has('allowPlaceholder'))
{
this.defaultValidators = (<Array<Validator>>this.defaultValidators).filter(v => !(v instanceof IsEmail));
this.defaultValidators.push(new IsEmail(this.allowPlaceholder));
}
}
update(changedProperties : PropertyValues) update(changedProperties : PropertyValues)
{ {
super.update(changedProperties) 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() private addOpenListeners()
{ {
document.addEventListener('focusin', this.handleLostFocus); document.addEventListener('focusin', this.handleLostFocus);
@ -211,6 +246,93 @@ export class Et2Email extends Et2InputWidget(LitElement)
document.removeEventListener('mousedown', this.handleLostFocus); 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. */ /** Sets focus on the control. */
focus(options? : FocusOptions) focus(options? : FocusOptions)
@ -286,7 +408,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
return this._searchPromise.then(async() => return this._searchPromise.then(async() =>
{ {
this.searching = false; this.searching = false;
if(!this.open) if(!this.open && this.hasFocus)
{ {
this.show(); this.show();
} }
@ -334,12 +456,11 @@ export class Et2Email extends Et2InputWidget(LitElement)
*/ */
protected processRemoteResults(entries) protected processRemoteResults(entries)
{ {
if(!entries?.length)
{
return [];
}
this._selectOptions = entries; this._selectOptions = entries;
this.updateComplete.then(() =>
{
this.currentOption = this._suggestions[0];
});
this.requestUpdate(); this.requestUpdate();
@ -366,8 +487,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
if(this.open && !this.disabled) if(this.open && !this.disabled)
{ {
// Reset the current option // Reset the current option
// TODO this.setCurrentOption(this._suggestions[0]);
//this.setCurrentOption(this._suggestions[0]);
// Show // Show
this.dispatchEvent(new CustomEvent('sl-show', {bubbles: true})); 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) // Make sure the current option is scrolled into view (required for Safari)
if(this.currentOption) if(this.currentOption)
{ {
// TODO this.currentOption.scrollIntoView();
//scrollIntoView(this.currentOption, this._listbox, 'vertical', 'auto');
} }
this.dispatchEvent(new CustomEvent('sl-after-show', {bubbles: true})); this.dispatchEvent(new CustomEvent('sl-after-show', {bubbles: true}));
@ -411,8 +530,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
this.requestUpdate("hasFocus"); this.requestUpdate("hasFocus");
// Reset tags to not take focus // Reset tags to not take focus
this._tags.forEach(t => t.tabIndex = -1); this.setCurrentTag(null);
this.currentTag = null;
this._search.setSelectionRange(0, 0); this._search.setSelectionRange(0, 0);
} }
@ -433,8 +551,10 @@ export class Et2Email extends Et2InputWidget(LitElement)
{ {
this.hide(); this.hide();
this._tags.forEach(t => t.tabIndex = 0); this._tags.forEach(t => t.tabIndex = 0);
this.currentTag = this._tags[this._tags.length - 1]; if(this._tags.length > 0)
this.currentTag.focus(); {
this.setCurrentTag(this._tags[this._tags.length - 1]);
}
event.stopPropagation(); event.stopPropagation();
return; return;
} }
@ -446,13 +566,34 @@ export class Et2Email extends Et2InputWidget(LitElement)
return; return;
} }
// Up / Down navigates options // 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 if(!this.open)
this.show(); {
this.show();
}
event.stopPropagation();
this.setCurrentOption(this._suggestions[0]);
return; return;
} }
// Tab or enter checks current value // 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") else if(event.key == "Enter")
{ {
event.preventDefault(); event.preventDefault();
@ -461,7 +602,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
} }
else if(event.key == "Escape") else if(event.key == "Escape")
{ {
this.handleSearchAbort(event); this._selectOptions = [];
this.hide(); this.hide();
return; return;
} }
@ -510,10 +651,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
nextTagIndex = Math.max(0, nextTagIndex); nextTagIndex = Math.max(0, nextTagIndex);
if(nextTagIndex < tagCount && this._tags[nextTagIndex]) if(nextTagIndex < tagCount && this._tags[nextTagIndex])
{ {
this._tags.forEach(t => t.tabIndex = -1); this.setCurrentTag(this._tags[nextTagIndex]);
this.currentTag = this._tags[nextTagIndex];
this.currentTag.tabIndex = 0;
this.currentTag.focus();
} }
else else
{ {
@ -526,7 +664,18 @@ export class Et2Email extends Et2InputWidget(LitElement)
// Remove tag // Remove tag
if(event.target instanceof Et2EmailTag && ["Delete", "Backspace"].includes(event.key)) 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})); 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 // Edit tag
else if(event.target instanceof Et2EmailTag && ["Enter"].includes(event.key)) 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 = (<string>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 = ((<SlOption>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) handleTagChange(event)
{ {
// Need to update our value, or it will just redo the tag with the old value // 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.value[index] = event.target.value;
this.requestUpdate(); this.requestUpdate();
} }
if(event.target.current)
{
this.setCurrentTag(event.target);
;
}
} }
handleTagRemove(event : SlRemoveEvent, value : string) handleTagRemove(event : SlRemoveEvent, value : string)
{ {
// Find the tag value and remove it from current value // Find the tag value and remove it from current value
debugger;
const index = this.value.indexOf(value); const index = this.value.indexOf(value);
this.value.splice(index, 1); this.value.splice(index, 1);
this.requestUpdate("value"); this.requestUpdate("value");
@ -585,6 +824,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
?readonly=${readonly} ?readonly=${readonly}
?editable=${isEditable} ?editable=${isEditable}
@mousedown=${(e) => {this._cancelOpen = true;}} @mousedown=${(e) => {this._cancelOpen = true;}}
@dblclick=${(e) => {e.target.startEdit();}}
@change=${this.handleTagChange} @change=${this.handleTagChange}
> >
</et2-email-tag>`; </et2-email-tag>`;
@ -597,7 +837,8 @@ export class Et2Email extends Et2InputWidget(LitElement)
class="email__search" class="email__search"
exportparts="base:search__base" exportparts="base:search__base"
autocomplete="off" autocomplete="off"
placeholder="${this.hasFocus ? "" : this.placeholder}" placeholder="${this.hasFocus || this.value.length > 0 ? "" : this.placeholder}"
tabindex="0"
@keydown=${this.handleSearchKeyDown} @keydown=${this.handleSearchKeyDown}
@blur=${this.handleSearchBlur} @blur=${this.handleSearchBlur}
@focus=${this.handleSearchFocus} @focus=${this.handleSearchFocus}
@ -633,7 +874,7 @@ export class Et2Email extends Et2InputWidget(LitElement)
.option=${option} .option=${option}
?disabled=${option.disabled} ?disabled=${option.disabled}
> >
<et2-lavatar slot="prefix" part="icon" <et2-lavatar slot="prefix" part="icon" size="1.8em"
lname=${option.lname || nothing} lname=${option.lname || nothing}
fname=${option.fname || nothing} fname=${option.fname || nothing}
image=${option.icon || nothing} image=${option.icon || nothing}
@ -699,7 +940,6 @@ export class Et2Email extends Et2InputWidget(LitElement)
class="email__combobox" class="email__combobox"
slot="anchor" slot="anchor"
@keydown=${this.handleComboboxKeyDown} @keydown=${this.handleComboboxKeyDown}
@mousedown=${this.handleComboboxMouseDown}
> >
<slot part="prefix" name="prefix" class="email__prefix"></slot> <slot part="prefix" name="prefix" class="email__prefix"></slot>
${this.tagsTemplate()} ${this.tagsTemplate()}
@ -714,7 +954,8 @@ export class Et2Email extends Et2InputWidget(LitElement)
part="listbox" part="listbox"
class="email__listbox" class="email__listbox"
tabindex="-1" tabindex="-1"
@mouseup=${this.handleOptionClick} @keydown=${this.handleSuggestionsKeyDown}
@mouseup=${this.handleSuggestionsMouseUp}
> >
${this.suggestionsTemplate()} ${this.suggestionsTemplate()}
</div> </div>

View File

@ -11,6 +11,7 @@ import {SlTag} from "@shoelace-style/shoelace";
import {css, html, TemplateResult} from "lit"; import {css, html, TemplateResult} from "lit";
import {classMap} from "lit/directives/class-map.js"; import {classMap} from "lit/directives/class-map.js";
import shoelace from "../../Styles/shoelace"; 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 * 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 : []) constructor(...args : [])
{ {
super(...args); super(...args);
@ -137,9 +140,9 @@ export class Et2Tag extends Et2Widget(SlTag)
'tag--editable': this.editable, 'tag--editable': this.editable,
'tag--editing': this.isEditing, 'tag--editing': this.isEditing,
// Types // Types
'tag--primary': this.variant === 'primary', 'tag--primary': this.variant === 'primary' || this.current,
'tag--success': this.variant === 'success', 'tag--success': this.variant === 'success',
'tag--neutral': this.variant === 'neutral', 'tag--neutral': this.variant === 'neutral' && !this.current,
'tag--warning': this.variant === 'warning', 'tag--warning': this.variant === 'warning',
'tag--danger': this.variant === 'danger', 'tag--danger': this.variant === 'danger',
'tag--text': this.variant === 'text', 'tag--text': this.variant === 'text',