mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-12-22 14:41:29 +01:00
1415 lines
39 KiB
TypeScript
1415 lines
39 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(" ", "___");
|
|
const isMobile = typeof egwIsMobile == "function" ? egwIsMobile() : false
|
|
return html`
|
|
<sl-option
|
|
part="option"
|
|
exportparts="prefix:tag__prefix, suffix:tag__suffix, image"
|
|
title="${!isMobile && 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);
|
|
} |