egroupware_official/api/js/etemplate/Et2Email/Et2Email.ts

1414 lines
38 KiB
TypeScript

/**
* 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 {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";
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";
import {IsEmail} from "../Validators/IsEmail";
import Sortable from "sortablejs/modular/sortable.complete.esm.js";
import {SearchMixinInterface} from "../Et2Widget/SearchMixin";
/**
* @summary Enter email addresses, offering suggestions from contacts
* @since 23.1
*
* @dependency sl-icon
* @dependency sl-popup
* @dependency et2-email-tag
* @dependency et2-textbox
*
* @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 suffix - Like prefix, but after
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
*
* @event 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.
*
* @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 combobox - The visible part of the control that is not the listbox - tags, input, prefix & suffix
* @csspart prefix - The container that wraps the prefix slot.
* @csspart suffix - The container that wraps the suffix slot.
* @csspart listbox - The listbox container where suggestions are slotted.
* @csspart input - The input element
* @csspart option - Each matching email address suggestion
* @csspart tag - The individual tags that represent each email address.
*
* @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
{
// Solves some issues with focus
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.
* If allowPlaceholder=true, placeholders are also allowed
*/
@property({
converter: {
fromAttribute: (value : string) =>
{
// Parse string into array
if(typeof value === 'string' && value.indexOf(',') !== -1)
{
return parseEmailsString(value, false);
}
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 = true;
/** 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;
/**
* What to display for the selected email addresses
*
* {@link Et2EmailTag#emailDisplay}
*/
@property({type: String})
emailDisplay : "full" | "email" | "name" | "domain";
/** 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;
/**
* Custom search options, passed to the searchUrl along with the search text
*
* @type {{includeLists : boolean}}
*/
@property({type: Object}) searchOptions = {includeLists: true};
/**
* Server-side search for suggested email addresses.
* Set to "" to cancel searching.
* @type {string}
*/
@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");}
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
* @internal
*/
public static SEARCH_TIMEOUT : number = 500;
/**
* Typing these characters will end the email address and start a new one
* @type {string[]}
*
* @internal
*/
public static TAG_BREAK : string[] = ["Tab", "Enter", ","];
protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
/** User preference to immediately close the search results after selecting a match
* @internal
*/
protected _close_on_select = true;
protected _searchTimeout : number;
protected _searchPromise : Promise<SelectOption[]> = Promise.resolve([]);
protected _selectOptions : SelectOption[] = [];
// Overflow Observer for +# display
protected tagOverflowObserver : IntersectionObserver = null;
// Drag / drop / sort
protected _sortable : Sortable;
// UID to force Lit to re-draw tags after sort
private _valueUID : string;
constructor(...args : any[])
{
// @ts-ignore
super(...args);
this.defaultValidators.push(new IsEmail(this.allowPlaceholder));
// Set email display to preference, will be overridden by template attribute
this.emailDisplay = this._getEmailDisplayPreference();
// 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);
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()
{
super.connectedCallback();
this.open = false;
this._valueUID = this.egw().uid();
this.updateComplete.then(() => this.makeSortable());
document.addEventListener('focusin', this.handleLostFocus);
}
disconnectedCallback()
{
super.disconnectedCallback();
if(this._sortable)
{
this._sortable.destroy();
}
document.removeEventListener('focusin', this.handleLostFocus);
}
set_value(_value)
{
if (!Array.isArray(_value))
{
this.value = parseEmailsString(_value, this.allowPlaceholder);
}
else
{
this.value = _value;
}
this._valueUID = this.egw().uid();
this.requestUpdate("value");
}
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)
if(changedProperties.has("open"))
{
this.handleOpenChange();
}
}
firstUpdated(changedProperties : PropertyValues)
{
super.firstUpdated(changedProperties);
// Make sure validators reflect allowPlaceholder, in case it's not caught by willUpdate()
this.defaultValidators = (<Array<Validator>>this.defaultValidators).filter(v => !(v instanceof IsEmail));
this.defaultValidators.push(new IsEmail(this.allowPlaceholder));
}
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
// Re-set sorting / drag & drop
if(changedProperties.has("value"))
{
this.makeSortable();
}
this.checkTagOverflow();
}
private _getEmailDisplayPreference()
{
const pref = this.egw().preference("emailTag", "mail") ?? "";
switch(pref)
{
case "fullemail":
return "full"
default:
case "onlyname":
return "name";
case "onlyemail":
return "email";
case "domain":
return "domain";
}
}
private addOpenListeners()
{
document.addEventListener('mousedown', this.handleLostFocus);
}
private removeOpenListeners()
{
document.removeEventListener('mousedown', this.handleLostFocus);
}
protected makeSortable()
{
if(this._sortable)
{
this._sortable.destroy();
}
if(!this.allowDragAndDrop)
{
this.classList.remove("et2-sortable-email");
return;
}
this.classList.add("et2-sortable-email");
let pull : boolean | string = !this.disabled && !this.readonly;
if(this.readonly && !this.disabled)
{
pull = 'clone';
}
this._sortable = Sortable.create(this.shadowRoot.querySelector('.email__combobox'), {
draggable: "et2-email-tag",
group: {
name: "email",
pull: pull,
put: !(this.readonly || this.disabled)
},
onEnd: this.handleSortEnd
});
}
/**
* 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
this.currentOption = option;
if(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();
}
}
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
*
* @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)
{
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()
{
if(!this.open || this.disabled)
{
return undefined;
}
this.open = false;
this.requestUpdate("open");
return waitForEvent(this, 'sl-after-hide');
}
/**
* Start searching for contacts matching what has been typed
*/
public async startSearch()
{
// Stop timeout timer
clearTimeout(this._searchTimeout);
// Clear current option, it's probably going to go away
this.setCurrentOption(null);
// If no searchUrl, no search
if(!this.searchUrl)
{
return;
}
this.searching = true;
this.requestUpdate("searching");
// Start the searches
this._searchPromise = this.remoteSearch(this._search.value, this.searchOptions);
return this._searchPromise.then(async() =>
{
this.searching = false;
this.requestUpdate("searching", true);
if(!this.open && this.hasFocus)
{
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<SelectOption[]>
* @protected
* @internal
*/
protected remoteSearch(search : string, options : object) : Promise<SelectOption[]>
{
// 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
* @internal
*/
protected processRemoteResults(entries)
{
this._selectOptions = entries;
this.updateComplete.then(() =>
{
this.currentOption = this._suggestions[0];
});
this.requestUpdate();
return entries;
}
/**
* The end of a sort, either internal or between widgets that deal with email
*
* @param event
* @protected
* @internal
*/
protected handleSortEnd(event)
{
if(this.disabled || this.readonly || !event.item?.value || !this.validateAddress(event.item.value) ||
// No real change
event.from === event.to && event.oldDraggableIndex == event.newDraggableIndex
)
{
return;
}
const tag = <Et2EmailTag>event.item;
const from = Sortable.utils.closest(event.from, "et2-email, .et2-sortable-email");
const to = Sortable.utils.closest(event.to, "et2-email, .et2-sortable-email");
if(from == this)
{
const index = this.value.indexOf(tag.value);
if(index > -1)
{
this.value.splice(index, 1);
}
// Reset focus
/*
if(typeof from.focus == "function")
{
this.updateComplete.then(() =>
{
from.focus();
});
}
*/
// Update key to force Lit to redraw tags
this._valueUID = this.egw()?.uid() ?? new Date().toISOString();
}
if(to === this)
{
let targetIndex = typeof event.newDraggableIndex == "number" ? event.newDraggableIndex : this.value.length;
this.value.splice(targetIndex, 0, tag.value);
// Update key to force Lit to redraw tags
this._valueUID = this.egw()?.uid() ?? new Date().toISOString();
}
else if(typeof to.handleSortEnd == "function")
{
to.handleSortEnd(event);
}
// Remove tag to avoid occasional duplication
tag.remove();
this.requestUpdate("value");
}
/**
* 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
this.setCurrentOption(this._suggestions[0]);
// Show
this.dispatchEvent(new CustomEvent('sl-show', {bubbles: true}));
this.addOpenListeners();
this._listbox.hidden = false;
this._popup.active = true;
// Make sure the current option is scrolled into view (required for Safari)
if(this.currentOption)
{
this.currentOption.scrollIntoView();
}
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}));
}
}
/**
* 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.
*
* @param {ClipboardEvent} event
* @protected
*/
protected handlePaste(event : ClipboardEvent)
{
event.preventDefault();
let paste = event.clipboardData.getData('text');
if(!paste)
{
return;
}
const selection = window.getSelection();
if(selection.rangeCount)
{
selection.deleteFromDocument();
}
let values = parseEmailsString(paste, this.allowPlaceholder);
if(values)
{
values.forEach(v =>
{
this.addAddress(v.trim());
});
this.hide();
// Update key to force Lit to redraw tags
this._valueUID = this.egw()?.uid() ?? new Date().toISOString();
this.dispatchEvent(new Event("change", {bubbles: true}));
}
}
private handleSearchFocus()
{
// Clear any manual message (errors on invalid search text)
this.set_validation_error(false);
this.hasFocus = true;
// Should not be needed, but not firing the update
this.requestUpdate("hasFocus");
// Reset tags to not take focus
this.setCurrentTag(null);
this._search.setSelectionRange(this._search.value.length, this._search.value.length);
}
private handleSearchBlur(event : FocusEvent)
{
this.hasFocus = false;
// Should not be needed, but not firing the update
this.requestUpdate("hasFocus");
// If they had something OK typed, use it, but only if focus went outside Et2Email
// because maybe they clicked an option which took focus
if(event.composedPath().includes(this))
{
if(this.addAddress(this._search.value.trim()))
{
this._search.value = "";
this.dispatchEvent(new Event("change", {bubbles: true}));
}
else if(this._search.value)
{
// Invalid input, show message. Not part of the value, so normal validation doesn't apply
// Can't just call this.validate(), it will get cleared immediately
this.set_validation_error(this.egw().lang("Invalid email") + ' "' + this._search.value + '"')
}
}
}
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);
if(this._tags.length > 0)
{
this.setCurrentTag(this._tags[this._tags.length - 1]);
}
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'].includes(event.key) && this._suggestions.length)
{
if(!this.open)
{
return this.show();
}
return this.handleSuggestionsKeyDown(event);
}
// Tab or enter checks current value
else if(Et2Email.TAG_BREAK.indexOf(event.key) !== -1)
{
// Check for valid email or current selection
if(!this.validateAddress(this._search.value.trim()) &&
this.currentOption && this.currentOption.value.toLowerCase().includes(this._search.value.toLowerCase()))
{
this._search.value = this.currentOption.value.replaceAll("___", " ");
}
if(this.addAddress(this._search.value.trim()))
{
this.open = false;
this._search.value = "";
this.dispatchEvent(new Event("change", {bubbles: true}));
}
if(event.key == "Tab")
{
this.blur();
// Allow tab to change the focus
}
else
{
// Don't want the key to do its normal thing
event.stopPropagation();
event.preventDefault();
}
}
// Start search immediately
else if(event.key == "Enter")
{
event.preventDefault();
this.startSearch();
return;
}
else if(event.key == "Escape")
{
this._selectOptions = [];
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.setCurrentTag(this._tags[nextTagIndex]);
}
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))
{
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))
{
event.target.startEdit();
}
}
/**
* 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
*
* @param {KeyboardEvent} event
*/
handleSuggestionsKeyDown(event : KeyboardEvent)
{
// Select the option
if(this.currentOption && ["ArrowRight", " ", ...Et2Email.TAG_BREAK].includes(event.key) &&
this.addAddress((<string>this.currentOption.value).replaceAll("___", " "))
)
{
if(this._close_on_select)
{
this.open = false;
}
this._search.focus();
this._search.value = "";
if(event.key !== "Tab")
{
event.stopPropagation();
event.preventDefault();
}
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 = suggestions.length - 1;
}
}
else if(event.key === "ArrowUp")
{
newIndex = currentIndex - 1;
if(newIndex < 0)
{
this.setCurrentOption(null);
this._search.focus();
}
}
else if(event.key === "Home")
{
newIndex = 0;
}
else if(event.key === "End")
{
newIndex = suggestions.length - 1;
}
this.setCurrentOption(suggestions[newIndex]);
}
else if(["Escape"])
{
this.open = false;
this._search.focus();
}
}
/**
* Mouse up from the suggestion list
* @param event
*/
handleSuggestionsMouseUp(event : MouseEvent)
{
if(typeof event.target.value == "undefined")
{
return;
}
const value = ((<SlOption>event.target).value).replaceAll("___", " ");
this.addAddress(value);
this._search.value = "";
this._search.focus();
this.requestUpdate("value");
this.dispatchEvent(new Event("change", {bubbles: true}));
if(this._close_on_select)
{
this.open = false;
}
}
handleTagChange(event)
{
// Need to update our value, or it will just redo the tag with the old value
if(event.originalValue && this.value.includes(event.originalValue))
{
let index = this.value.indexOf(event.originalValue);
this.value[index] = event.target.value;
this.requestUpdate();
this.dispatchEvent(new Event("change", {bubbles: true}));
}
if(event.target.current)
{
this.setCurrentTag(event.target);
}
}
handleTagRemove(event : SlRemoveEvent, value : string)
{
event.stopPropagation();
// Find the tag value and remove it from current value
const index = this.value.indexOf(value);
this.value.splice(index, 1);
this._valueUID = this.egw().uid();
this.requestUpdate("value");
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)) + 1;
return html`
<sl-popup
active
anchor=${this}
auto-size="both"
class="hover__popup details hoist details__body"
distance=${distance}
placement="bottom"
sync="width"
>
${this.tagsTemplate()}
</sl-popup>
`;
}
tagsTemplate()
{
return html`${keyed(this._valueUID, map(this.value, (value, index) => this.tagTemplate(value)))}`;
}
tagTemplate(value)
{
const readonly = (this.readonly || this.disabled);
const isEditable = !readonly;
const isValid = this.validateAddress(value);
return html`
<et2-email-tag
exportparts="image"
part="tag"
class=${classMap({
"et2-select-draggable": !this.readonly && this.allowDragAndDrop,
})}
variant=${this.isValid ? nothing : "danger"}
.emailDisplay=${this.emailDisplay ?? nothing}
.value=${live(value)}
?removable=${!readonly}
?readonly=${readonly}
?editable=${isEditable}
@sl-remove=${(e : SlRemoveEvent) => this.handleTagRemove(e, value)}
@mousedown=${(e) => {this._cancelOpen = true;}}
@dblclick=${(e) => {e.target.startEdit();}}
@change=${this.handleTagChange}
>
</et2-email-tag>`;
}
protected tagLimitTemplate() : TemplateResult | typeof nothing
{
if(this._tagsHidden == 0)
{
return nothing;
}
return html`
<sl-tag
part="tag__limit"
class="tag_limit"
slot="expand-icon"
>+${this._tagsHidden}
</sl-tag>`;
}
inputTemplate()
{
return html`
<input id="search" type="text" part="input"
class="email__search"
exportparts="base:search__base"
autocomplete="off"
?disabled=${this.disabled}
?readonly=${this.readonly}
placeholder="${this.hasFocus || this.value.length > 0 || this.disabled || this.readonly ? "" : this.placeholder}"
tabindex="0"
@keydown=${this.handleSearchKeyDown}
@blur=${this.handleSearchBlur}
@focus=${this.handleSearchFocus}
@paste=${this.handlePaste}
/>
`;
}
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 = (<string>option.value).replaceAll(" ", "___");
return html`
<sl-option
part="option"
exportparts="prefix:tag__prefix, suffix:tag__suffix, image"
title="${!egwIsMobile() && option.title ? (this.noLang ? option.title : this.egw().lang(option.title)) : nothing}"
class=${classMap({
...classes
})}
.value="${value}"
.option=${option}
?disabled=${option.disabled}
>
<et2-lavatar slot="prefix" exportparts="image" part="icon" size="1.8em"
lname=${option.lname || nothing}
fname=${option.fname || nothing}
image=${option.icon || nothing}
>
</et2-lavatar>
${this.noLang ? option.label : this.egw().lang(option.label)}
</sl-option>`;
}
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 && !this.disabled && !this.readonly;
let styles = {};
if(this.rows !== 0)
{
styles["--height"] = this.rows;
}
// TODO Don't forget required & disabled
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--medium': true,
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
style=${styleMap(styles)}
@click=${this.handleLabelClick}
@mouseenter=${this.handleMouseEnter}
@mousedown=${() =>
{
if(!this.hasFocus)
{
// Helps Sortable work every time
this.focus();
}
}}
>
<label
id="label"
part="form-control-label"
class="form-control__label"
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${this.handleLabelClick}
>
<slot name="label">${this.label}</slot>
</label>
${this.readonlyHoverTemplate()}
<div part="form-control-input" class="form-control-input">
<sl-popup
class=${classMap({
email: true,
input: true,
'email--open': this.open,
'email--disabled': this.disabled,
'email--readonly': this.readonly,
'email--focused': this.hasFocus,
'email--placeholder-visible': isPlaceholderVisible,
'email--top': this.placement === 'top',
'email--bottom': this.placement === 'bottom',
})}
placement="bottom"
strategy="fixed"
flip
shift
sync="width"
auto-size="vertical"
auto-size-padding="10"
?active=${this.open}
>
<div
part="combobox base"
class="email__combobox"
slot="anchor"
@keydown=${this.handleComboboxKeyDown}
>
<slot part="prefix" name="prefix" class="email__prefix"></slot>
${this.tagsTemplate()}
${this.inputTemplate()}
${this.tagLimitTemplate()}
${this.searching ? html`
<sl-spinner class="email__loading"></sl-spinner>` : nothing}
<slot part="suffix" name="suffix" class="email__suffix"></slot>
</div>
<div
id="listbox"
role="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
aria-labelledby="label"
part="listbox"
class="email__listbox"
tabindex="-1"
@keydown=${this.handleSuggestionsKeyDown}
@mouseup=${this.handleSuggestionsMouseUp}
>
${(this._selectOptions && this._selectOptions.length) ? this.suggestionsTemplate() : this.egw().lang("no matches found")}
</div>
</sl-popup>
</div>
<div
part="form-control-help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
}
customElements.define("et2-email", Et2Email);
/**
* Parse string that may contain multiple comma separated email addresses into an array
*
* @param {string} value
* @returns {string[]}
* @protected
*/
function parseEmailsString(value : string, allowPlaceholder = false) : string[]
{
if (!value) return [];
let preg = allowPlaceholder ? IsEmail.EMAIL_PLACEHOLDER_PREG : IsEmail.EMAIL_PREG;
// Trim line start / end anchors off validation regex, make global
let regex = new RegExp(preg.toString().substring(2, preg.toString().length - 3), 'g');
return value.match(regex);
}