mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-12-27 09:09:04 +01:00
Et2Email: WIP - Most interactions done
This commit is contained in:
parent
710287e34f
commit
e99b398d5d
@ -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) {
|
||||
|
@ -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<SelectOption[]> = 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 = (<Array<Validator>>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 = (<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)
|
||||
{
|
||||
// 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}
|
||||
>
|
||||
</et2-email-tag>`;
|
||||
@ -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}
|
||||
>
|
||||
<et2-lavatar slot="prefix" part="icon"
|
||||
<et2-lavatar slot="prefix" part="icon" size="1.8em"
|
||||
lname=${option.lname || nothing}
|
||||
fname=${option.fname || nothing}
|
||||
image=${option.icon || nothing}
|
||||
@ -699,7 +940,6 @@ export class Et2Email extends Et2InputWidget(LitElement)
|
||||
class="email__combobox"
|
||||
slot="anchor"
|
||||
@keydown=${this.handleComboboxKeyDown}
|
||||
@mousedown=${this.handleComboboxMouseDown}
|
||||
>
|
||||
<slot part="prefix" name="prefix" class="email__prefix"></slot>
|
||||
${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()}
|
||||
</div>
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user