mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-12-22 14:41:29 +01:00
Et2Email WIP
This commit is contained in:
parent
e5657b8a42
commit
710287e34f
96
api/js/etemplate/Et2Email/Et2Email.styles.ts
Normal file
96
api/js/etemplate/Et2Email/Et2Email.styles.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import {css} from 'lit';
|
||||||
|
|
||||||
|
export default css`
|
||||||
|
.email .email__combobox {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 0.5rem;
|
||||||
|
|
||||||
|
background-color: var(--sl-input-background-color);
|
||||||
|
border: solid var(--sl-input-border-width) var(--sl-input-border-color);
|
||||||
|
|
||||||
|
border-radius: var(--sl-input-border-radius-medium);
|
||||||
|
font-size: var(--sl-input-font-size-medium);
|
||||||
|
min-height: var(--sl-input-height-medium);
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: var(--sl-input-spacing-medium);
|
||||||
|
|
||||||
|
transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow,
|
||||||
|
var(--sl-transition-fast) background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email.email--disabled .email__combobox {
|
||||||
|
background-color: var(--sl-input-background-color-disabled);
|
||||||
|
border-color: var(--sl-input-border-color-disabled);
|
||||||
|
color: var(--sl-input-color-disabled);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email:not(.email--disabled).email--open .email__combobox,
|
||||||
|
.email:not(.email--disabled).email--focused .email__combobox {
|
||||||
|
background-color: var(--sl-input-background-color-focus);
|
||||||
|
border-color: var(--sl-input-border-color-focus);
|
||||||
|
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-input-focus-ring-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
|
||||||
|
.email et2-email-tag {
|
||||||
|
--icon-width: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search box */
|
||||||
|
|
||||||
|
.email__search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 10em;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
font-size: var(--sl-input-font-size-medium);
|
||||||
|
min-height: var(--sl-input-height-medium);
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: var(--sl-input-spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Listbox */
|
||||||
|
|
||||||
|
.email__listbox {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
font-family: var(--sl-font-sans);
|
||||||
|
font-size: var(--sl-font-size-medium);
|
||||||
|
font-weight: var(--sl-font-weight-normal);
|
||||||
|
box-shadow: var(--sl-shadow-large);
|
||||||
|
background: var(--sl-panel-background-color);
|
||||||
|
border: solid var(--sl-panel-border-width) var(--sl-panel-border-color);
|
||||||
|
border-radius: var(--sl-border-radius-medium);
|
||||||
|
padding-block: var(--sl-spacing-x-small);
|
||||||
|
padding-inline: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
|
||||||
|
/* Make sure it adheres to the popup's auto size */
|
||||||
|
max-width: var(--auto-size-available-width);
|
||||||
|
max-height: var(--auto-size-available-height);
|
||||||
|
|
||||||
|
--icon-width: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email__listbox ::slotted(sl-divider) {
|
||||||
|
--spacing: var(--sl-spacing-x-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email__listbox ::slotted(small) {
|
||||||
|
font-size: var(--sl-font-size-small);
|
||||||
|
font-weight: var(--sl-font-weight-semibold);
|
||||||
|
color: var(--sl-color-neutral-500);
|
||||||
|
padding-block: var(--sl-spacing-x-small);
|
||||||
|
padding-inline: var(--sl-spacing-x-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
737
api/js/etemplate/Et2Email/Et2Email.ts
Normal file
737
api/js/etemplate/Et2Email/Et2Email.ts
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
/**
|
||||||
|
* 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 {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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Enter email addresses, offering suggestions from contacts
|
||||||
|
* @documentation https://shoelace.style/components/select
|
||||||
|
* @since 23.1
|
||||||
|
*
|
||||||
|
* @dependency sl-icon
|
||||||
|
* @dependency sl-popup
|
||||||
|
* @dependency et2-email-tag
|
||||||
|
* @dependency et2-textbox
|
||||||
|
*
|
||||||
|
* @slot - The suggestion options. Must be `<sl-option>` elements. You can use `<sl-divider>` to group items visually.
|
||||||
|
* @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 help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||||
|
*
|
||||||
|
* @event sl-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.
|
||||||
|
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||||
|
*
|
||||||
|
* @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 prefix - The container that wraps the prefix slot.
|
||||||
|
* @csspart listbox - The listbox container where options are slotted.
|
||||||
|
* @csspart tags - The container that houses email tags
|
||||||
|
* @csspart tag - The individual tags that represent each email address.
|
||||||
|
* @csspart tag__base - The tag's base part.
|
||||||
|
* @csspart tag__content - The tag's content part.
|
||||||
|
* @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)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
@property({
|
||||||
|
converter: {
|
||||||
|
fromAttribute: (value : string) =>
|
||||||
|
{
|
||||||
|
// Parse string into array
|
||||||
|
if(typeof value === 'string' && value.indexOf(',') !== -1)
|
||||||
|
{
|
||||||
|
let val = value.split(',');
|
||||||
|
for(let n = 0; n < val.length - 1; n++)
|
||||||
|
{
|
||||||
|
while(val[n].indexOf('@') === -1 && n < val.length - 1)
|
||||||
|
{
|
||||||
|
val[n] += ',' + val[n + 1];
|
||||||
|
val.splice(n + 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the email is a contact, we normally show the contact name instead of the email.
|
||||||
|
* Set to true to turn this off and always show just the email
|
||||||
|
* Mutually exclusive with fullEmail!
|
||||||
|
*/
|
||||||
|
@property({type: Boolean})
|
||||||
|
onlyEmail : boolean;
|
||||||
|
|
||||||
|
/** Show the full, original value email address under all circumstances, rather than the contact name for known contacts */
|
||||||
|
@property({type: Boolean})
|
||||||
|
fullEmail : boolean;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
@property({type: Object}) searchOptions = {};
|
||||||
|
@property({type: String}) searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email";
|
||||||
|
|
||||||
|
@state() searching = false;
|
||||||
|
@state() hasFocus = false;
|
||||||
|
@state() currentOption : SlOption;
|
||||||
|
@state() currentTag : Et2EmailTag;
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
protected static SEARCH_TIMEOUT = 500;
|
||||||
|
|
||||||
|
protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||||
|
|
||||||
|
/** User preference to immediately close the search results after selecting a match */
|
||||||
|
protected _close_on_select = true;
|
||||||
|
|
||||||
|
protected _searchTimeout : number;
|
||||||
|
protected _searchPromise : Promise<SelectOption[]> = Promise.resolve([]);
|
||||||
|
protected _selectOptions : SelectOption[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor(...args : any[])
|
||||||
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
super(...args);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback()
|
||||||
|
{
|
||||||
|
super.connectedCallback();
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(changedProperties : PropertyValues)
|
||||||
|
{
|
||||||
|
super.update(changedProperties)
|
||||||
|
|
||||||
|
if(changedProperties.has("open"))
|
||||||
|
{
|
||||||
|
this.handleOpenChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addOpenListeners()
|
||||||
|
{
|
||||||
|
document.addEventListener('focusin', this.handleLostFocus);
|
||||||
|
document.addEventListener('mousedown', this.handleLostFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeOpenListeners()
|
||||||
|
{
|
||||||
|
document.removeEventListener('focusin', this.handleLostFocus);
|
||||||
|
document.removeEventListener('mousedown', this.handleLostFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
{
|
||||||
|
this.open = false;
|
||||||
|
this.requestUpdate("open");
|
||||||
|
if(!this.open || this.disabled)
|
||||||
|
{
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitForEvent(this, 'sl-after-hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start searching
|
||||||
|
*
|
||||||
|
* If we have local options, we'll search & display any matches.
|
||||||
|
* If serverUrl is set, we'll ask the server for results as well.
|
||||||
|
*/
|
||||||
|
public async startSearch()
|
||||||
|
{
|
||||||
|
// Stop timeout timer
|
||||||
|
clearTimeout(this._searchTimeout);
|
||||||
|
|
||||||
|
this.searching = true;
|
||||||
|
|
||||||
|
// Start the searches
|
||||||
|
this._searchPromise = this.remoteSearch(this._search.value, this.searchOptions);
|
||||||
|
return this._searchPromise.then(async() =>
|
||||||
|
{
|
||||||
|
this.searching = false;
|
||||||
|
if(!this.open)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
protected processRemoteResults(entries)
|
||||||
|
{
|
||||||
|
if(!entries?.length)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selectOptions = entries;
|
||||||
|
|
||||||
|
this.requestUpdate();
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
// TODO
|
||||||
|
//this.setCurrentOption(this._suggestions[0]);
|
||||||
|
|
||||||
|
// Show
|
||||||
|
this.dispatchEvent(new CustomEvent('sl-show', {bubbles: true}));
|
||||||
|
this.addOpenListeners();
|
||||||
|
|
||||||
|
this._listbox.hidden = false;
|
||||||
|
this._popup.active = true;
|
||||||
|
|
||||||
|
// Select the appropriate option based on value after the listbox opens
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
{
|
||||||
|
this.setCurrentOption(this.currentOption);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure the current option is scrolled into view (required for Safari)
|
||||||
|
if(this.currentOption)
|
||||||
|
{
|
||||||
|
// TODO
|
||||||
|
//scrollIntoView(this.currentOption, this._listbox, 'vertical', 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
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}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSearchFocus()
|
||||||
|
{
|
||||||
|
this.hasFocus = true;
|
||||||
|
// Should not be needed, but not firing the update
|
||||||
|
this.requestUpdate("hasFocus");
|
||||||
|
|
||||||
|
// Reset tags to not take focus
|
||||||
|
this._tags.forEach(t => t.tabIndex = -1);
|
||||||
|
this.currentTag = null;
|
||||||
|
|
||||||
|
this._search.setSelectionRange(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSearchBlur()
|
||||||
|
{
|
||||||
|
this.hasFocus = false;
|
||||||
|
// Should not be needed, but not firing the update
|
||||||
|
this.requestUpdate("hasFocus");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.currentTag = this._tags[this._tags.length - 1];
|
||||||
|
this.currentTag.focus();
|
||||||
|
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', 'Home', 'End'].includes(event.key))
|
||||||
|
{
|
||||||
|
// TODO - pass focus to list
|
||||||
|
this.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab or enter checks current value
|
||||||
|
else if(event.key == "Enter")
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
this.startSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if(event.key == "Escape")
|
||||||
|
{
|
||||||
|
this.handleSearchAbort(event);
|
||||||
|
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._tags.forEach(t => t.tabIndex = -1);
|
||||||
|
this.currentTag = this._tags[nextTagIndex];
|
||||||
|
this.currentTag.tabIndex = 0;
|
||||||
|
this.currentTag.focus();
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
event.target.dispatchEvent(new CustomEvent('sl-remove', {bubbles: true}));
|
||||||
|
}
|
||||||
|
// Edit tag
|
||||||
|
else if(event.target instanceof Et2EmailTag && ["Enter"].includes(event.key))
|
||||||
|
{
|
||||||
|
event.target.startEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTagChange(event)
|
||||||
|
{
|
||||||
|
// Need to update our value, or it will just redo the tag with the old value
|
||||||
|
debugger;
|
||||||
|
if(event.originalValue && this.value.indexOf(event.originalValue))
|
||||||
|
{
|
||||||
|
let index = this.value.indexOf(event.originalValue);
|
||||||
|
this.value[index] = event.target.value;
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsTemplate()
|
||||||
|
{
|
||||||
|
return this.value.map((value, index) =>
|
||||||
|
{
|
||||||
|
// Wrap so we can handle the remove
|
||||||
|
return html`
|
||||||
|
<div @sl-remove=${(e : SlRemoveEvent) => this.handleTagRemove(e, value)}>
|
||||||
|
${this.tagTemplate(value)}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tagTemplate(value)
|
||||||
|
{
|
||||||
|
const readonly = (this.readonly);
|
||||||
|
const isEditable = !readonly;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<et2-email-tag
|
||||||
|
class=${classMap({
|
||||||
|
"et2-select-draggable": !this.readonly && this.allowDragAndDrop,
|
||||||
|
})}
|
||||||
|
.fullEmail=${this.fullEmail}
|
||||||
|
.onlyEmail=${this.onlyEmail}
|
||||||
|
.value=${value}
|
||||||
|
?removable=${!readonly}
|
||||||
|
?readonly=${readonly}
|
||||||
|
?editable=${isEditable}
|
||||||
|
@mousedown=${(e) => {this._cancelOpen = true;}}
|
||||||
|
@change=${this.handleTagChange}
|
||||||
|
>
|
||||||
|
</et2-email-tag>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputTemplate()
|
||||||
|
{
|
||||||
|
return html`
|
||||||
|
<input id="search" type="text" part="input"
|
||||||
|
class="email__search"
|
||||||
|
exportparts="base:search__base"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="${this.hasFocus ? "" : this.placeholder}"
|
||||||
|
@keydown=${this.handleSearchKeyDown}
|
||||||
|
@blur=${this.handleSearchBlur}
|
||||||
|
@focus=${this.handleSearchFocus}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
|
||||||
|
class=${classMap({
|
||||||
|
...classes
|
||||||
|
})}
|
||||||
|
.value="${value}"
|
||||||
|
.option=${option}
|
||||||
|
?disabled=${option.disabled}
|
||||||
|
>
|
||||||
|
<et2-lavatar slot="prefix" part="icon"
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})}
|
||||||
|
@click=${this.handleLabelClick}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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--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"
|
||||||
|
class="email__combobox"
|
||||||
|
slot="anchor"
|
||||||
|
@keydown=${this.handleComboboxKeyDown}
|
||||||
|
@mousedown=${this.handleComboboxMouseDown}
|
||||||
|
>
|
||||||
|
<slot part="prefix" name="prefix" class="email__prefix"></slot>
|
||||||
|
${this.tagsTemplate()}
|
||||||
|
${this.inputTemplate()}
|
||||||
|
<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"
|
||||||
|
@mouseup=${this.handleOptionClick}
|
||||||
|
>
|
||||||
|
${this.suggestionsTemplate()}
|
||||||
|
</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
|
||||||
|
customElements.define("et2-email", Et2Email);
|
149
api/js/etemplate/Et2Email/test/Et2Email.test.ts
Normal file
149
api/js/etemplate/Et2Email/test/Et2Email.test.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import {assert, elementUpdated, fixture, html, oneEvent} from '@open-wc/testing';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import {inputBasicTests} from "../../Et2InputWidget/test/InputBasicTests";
|
||||||
|
import {Et2Email} from "../Et2Email";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test file for Etemplate webComponent Select
|
||||||
|
*
|
||||||
|
* In here we test just the simple, basic widget stuff.
|
||||||
|
*/
|
||||||
|
// Stub global egw for cssImage to find
|
||||||
|
// @ts-ignore
|
||||||
|
window.egw = {
|
||||||
|
image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
|
||||||
|
lang: i => i + "*",
|
||||||
|
tooltipUnbind: () => {},
|
||||||
|
webserverUrl: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
let element : Et2Email;
|
||||||
|
|
||||||
|
async function before()
|
||||||
|
{
|
||||||
|
// Create an element to test with, and wait until it's ready
|
||||||
|
// @ts-ignore
|
||||||
|
element = await fixture<Et2Select>(html`
|
||||||
|
<et2-email label="I'm an email">
|
||||||
|
</et2-email>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Stub egw()
|
||||||
|
sinon.stub(element, "egw").returns(window.egw);
|
||||||
|
await elementUpdated(element);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Email widget basics", () =>
|
||||||
|
{
|
||||||
|
// Setup run before each test
|
||||||
|
beforeEach(before);
|
||||||
|
|
||||||
|
// Make sure it works
|
||||||
|
it('is defined', () =>
|
||||||
|
{
|
||||||
|
assert.instanceOf(element, Et2Email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a label', async() =>
|
||||||
|
{
|
||||||
|
element.set_label("Label set");
|
||||||
|
await elementUpdated(element);
|
||||||
|
|
||||||
|
assert.equal(element.querySelector("[slot='label']").textContent, "Label set");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("closes when losing focus", async() =>
|
||||||
|
{
|
||||||
|
// WIP
|
||||||
|
const blurSpy = sinon.spy();
|
||||||
|
element.addEventListener('sl-hide', blurSpy);
|
||||||
|
const showPromise = new Promise(resolve =>
|
||||||
|
{
|
||||||
|
element.addEventListener("sl-after-show", resolve);
|
||||||
|
});
|
||||||
|
const hidePromise = new Promise(resolve =>
|
||||||
|
{
|
||||||
|
element.addEventListener("sl-hide", resolve);
|
||||||
|
});
|
||||||
|
await elementUpdated(element);
|
||||||
|
element.focus();
|
||||||
|
|
||||||
|
await showPromise;
|
||||||
|
await elementUpdated(element);
|
||||||
|
|
||||||
|
element.blur();
|
||||||
|
await elementUpdated(element);
|
||||||
|
|
||||||
|
await hidePromise;
|
||||||
|
|
||||||
|
sinon.assert.calledOnce(blurSpy);
|
||||||
|
|
||||||
|
// Check that it actually closed dropdown
|
||||||
|
assert.isFalse(element.hasAttribute("open"));
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tags", () =>
|
||||||
|
{
|
||||||
|
beforeEach(async() =>
|
||||||
|
{
|
||||||
|
// Create an element to test with, and wait until it's ready
|
||||||
|
// @ts-ignore
|
||||||
|
element = await fixture<Et2Email>(html`
|
||||||
|
<et2-email label="I'm a select" value="one@example.com, two@example.com">
|
||||||
|
</et2-email>
|
||||||
|
`);
|
||||||
|
element.loadFromXML(element);
|
||||||
|
|
||||||
|
// Stub egw()
|
||||||
|
sinon.stub(element, "egw").returns(window.egw);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can remove tags", async() =>
|
||||||
|
{
|
||||||
|
assert.equal(element._tags.length, 2, "Did not find tags");
|
||||||
|
|
||||||
|
// Await tags to render
|
||||||
|
/* TODO
|
||||||
|
let tag_updates = []
|
||||||
|
element.select.combobox.querySelectorAll("et2-tag").forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
|
||||||
|
await Promise.all(tag_updates);
|
||||||
|
|
||||||
|
assert.equal(tags.length, 2);
|
||||||
|
assert.equal(tags[0].value, "one");
|
||||||
|
assert.equal(tags[1].value, "two");
|
||||||
|
*/
|
||||||
|
// Set up listener
|
||||||
|
const listener = oneEvent(element, "change");
|
||||||
|
|
||||||
|
// Click to remove first tag
|
||||||
|
let removeButton = tags[0].shadowRoot.querySelector("[part='remove-button']");
|
||||||
|
assert.exists(removeButton, "Could not find tag remove button");
|
||||||
|
removeButton.dispatchEvent(new Event("click"));
|
||||||
|
|
||||||
|
await listener;
|
||||||
|
|
||||||
|
// Wait for widget to update
|
||||||
|
await element.updateComplete;
|
||||||
|
tag_updates = []
|
||||||
|
element.select.combobox.querySelectorAll('et2-tag').forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
|
||||||
|
await Promise.all(tag_updates);
|
||||||
|
|
||||||
|
// Check
|
||||||
|
assert.sameMembers(element.value, ["two"], "Removing tag did not remove value");
|
||||||
|
tags = element.select.combobox.querySelectorAll('.select__tags et2-tag');
|
||||||
|
assert.equal(tags.length, 1, "Removed tag is still there");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
inputBasicTests(async() =>
|
||||||
|
{
|
||||||
|
const element = await before();
|
||||||
|
element.noLang = true;
|
||||||
|
return element
|
||||||
|
}, "", "sl-select");
|
@ -108,6 +108,7 @@ export class Et2Tag extends Et2Widget(SlTag)
|
|||||||
<et2-button-icon
|
<et2-button-icon
|
||||||
label=${this.egw().lang("edit")}
|
label=${this.egw().lang("edit")}
|
||||||
image="pencil"
|
image="pencil"
|
||||||
|
noSubmit="true"
|
||||||
@click=${this.startEdit}
|
@click=${this.startEdit}
|
||||||
></et2-button-icon>` : ''
|
></et2-button-icon>` : ''
|
||||||
}
|
}
|
||||||
@ -205,6 +206,10 @@ export class Et2Tag extends Et2Widget(SlTag)
|
|||||||
stopEdit()
|
stopEdit()
|
||||||
{
|
{
|
||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
|
let event = new Event("change", {
|
||||||
|
bubbles: true
|
||||||
|
});
|
||||||
|
event.originalValue = this.value;
|
||||||
this.dataset.original_value = this.value;
|
this.dataset.original_value = this.value;
|
||||||
if(!this.editable)
|
if(!this.editable)
|
||||||
{
|
{
|
||||||
@ -214,9 +219,6 @@ export class Et2Tag extends Et2Widget(SlTag)
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
this.updateComplete.then(() =>
|
this.updateComplete.then(() =>
|
||||||
{
|
{
|
||||||
let event = new Event("change", {
|
|
||||||
bubbles: true
|
|
||||||
})
|
|
||||||
this.dispatchEvent(event);
|
this.dispatchEvent(event);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -235,6 +237,11 @@ export class Et2Tag extends Et2Widget(SlTag)
|
|||||||
{
|
{
|
||||||
this._editNode.blur();
|
this._editNode.blur();
|
||||||
}
|
}
|
||||||
|
else if(["Escape"].includes(event.key))
|
||||||
|
{
|
||||||
|
this._editNode.value = this.value;
|
||||||
|
this.stopEdit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event : CustomEvent)
|
handleChange(event : CustomEvent)
|
||||||
|
@ -620,7 +620,9 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** et2_widget compatability **/
|
/** et2_widget compatability
|
||||||
|
* @deprecated
|
||||||
|
**/
|
||||||
destroy()
|
destroy()
|
||||||
{
|
{
|
||||||
// Not really needed, use the disconnectedCallback() and let the browser handle it
|
// Not really needed, use the disconnectedCallback() and let the browser handle it
|
||||||
|
22
api/js/etemplate/Et2Widget/event.ts
Normal file
22
api/js/etemplate/Et2Widget/event.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Waits for a specific event to be emitted from an element. Ignores events that bubble up from child elements.
|
||||||
|
*
|
||||||
|
* Copied from Shoelace
|
||||||
|
* /src/internal/event.ts
|
||||||
|
*/
|
||||||
|
export function waitForEvent(el : HTMLElement, eventName : string)
|
||||||
|
{
|
||||||
|
return new Promise<void>(resolve =>
|
||||||
|
{
|
||||||
|
function done(event : Event)
|
||||||
|
{
|
||||||
|
if(event.target === el)
|
||||||
|
{
|
||||||
|
el.removeEventListener(eventName, done);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener(eventName, done);
|
||||||
|
});
|
||||||
|
}
|
@ -50,6 +50,7 @@ import './Et2Date/Et2DateTimeToday';
|
|||||||
import './Et2Description/Et2Description';
|
import './Et2Description/Et2Description';
|
||||||
import './Et2Dialog/Et2Dialog';
|
import './Et2Dialog/Et2Dialog';
|
||||||
import './Et2DropdownButton/Et2DropdownButton';
|
import './Et2DropdownButton/Et2DropdownButton';
|
||||||
|
import './Et2Email/Et2Email';
|
||||||
import './Expose/Et2ImageExpose';
|
import './Expose/Et2ImageExpose';
|
||||||
import './Expose/Et2DescriptionExpose';
|
import './Expose/Et2DescriptionExpose';
|
||||||
import './Et2Favorites/Et2Favorites';
|
import './Et2Favorites/Et2Favorites';
|
||||||
|
Loading…
Reference in New Issue
Block a user