diff --git a/api/etemplate.php b/api/etemplate.php
index f7cf3f05db..5d12ce9ced 100644
--- a/api/etemplate.php
+++ b/api/etemplate.php
@@ -143,7 +143,6 @@ function send_template()
if (!empty($matches[3])) $tag = str_replace($matches[3], '', $tag);
if ($type !== 'float') $tag .= ' precision="0"';
return $tag.'>';
-
}, $str);
// fix -->
@@ -156,16 +155,14 @@ function send_template()
(substr($matches[4], -1) === '/' ? substr($matches[4], 0, -1) . '>';
}, $str);
- // handling of partially implemented select and date widget (only readonly or simple select without tags or search attribute or options)
+ // handling of date and partially implemented select widget (no search or tags attribute), incl. removing of type attribute
$str = preg_replace_callback('#<(select|date)(-[^ ]+)? ([^>]+)/>#', static function (array $matches)
{
preg_match_all('/(^| )([a-z0-9_-]+)="([^"]+)"/', $matches[3], $attrs, PREG_PATTERN_ORDER);
$attrs = array_combine($attrs[2], $attrs[3]);
- // add et2-prefix for #', static function($matches)
+ {
+ if (strpos($matches[2], 'readonly="true"')) return $matches[0]; // leave readonly alone for now
+ return str_replace('';
+ }, $str);
+
$processing = microtime(true);
if(isset($cache) && (file_exists($cache_dir = dirname($cache)) || mkdir($cache_dir, 0755, true)))
diff --git a/api/js/etemplate/Et2Url/Et2InvokerMixin.ts b/api/js/etemplate/Et2Url/Et2InvokerMixin.ts
new file mode 100644
index 0000000000..22ac203042
--- /dev/null
+++ b/api/js/etemplate/Et2Url/Et2InvokerMixin.ts
@@ -0,0 +1,139 @@
+/**
+ * EGroupware eTemplate2 - InvokerMixing
+ *
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package api
+ * @link https://www.egroupware.org
+ * @author Ralf Becker
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+import {dedupeMixin, html, render} from '@lion/core';
+import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
+
+/**
+ * Invoker mixing adds an invoker button to a widget to trigger some action, e.g.:
+ * - searchbox to delete input
+ * - url to open url
+ * - url-email to open mail compose
+ *
+ * Inspired by Lion date-picker.
+ */
+export const Et2InvokerMixin = dedupeMixin((superclass) =>
+{
+ class Et2Invoker extends Et2InputWidget(superclass)
+ {
+ /** @type {any} */
+ static get properties()
+ {
+ return {
+ _invokerLabel: {
+ type: String,
+ },
+ _invokerTitle: {
+ type: String,
+ },
+ _invokerAction: {
+ type: Function,
+ }
+ };
+ }
+
+ get slots()
+ {
+ return {
+ ...super.slots,
+ suffix: () =>
+ {
+ const renderParent = document.createElement('div');
+ render(
+ this._invokerTemplate(),
+ renderParent
+ );
+ return /** @type {HTMLElement} */ (renderParent.firstElementChild);
+ },
+ };
+ }
+
+ /**
+ * @protected
+ */
+ get _invokerNode()
+ {
+ return /** @type {HTMLElement} */ (this.querySelector(`#${this.__invokerId}`));
+ }
+
+ constructor()
+ {
+ super();
+ /** @private */
+ this.__invokerId = this.__createUniqueIdForA11y();
+ // default for properties
+ this._invokerLabel = '⎆';
+ this._invokerTitle = 'Click to open';
+ this._invokerAction = () => alert('Invoked :)');
+ }
+
+ /** @private */
+ __createUniqueIdForA11y()
+ {
+ return `${this.localName}-${Math.random().toString(36).substr(2, 10)}`;
+ }
+
+ /**
+ * @param {PropertyKey} name
+ * @param {?} oldValue
+ */
+ requestUpdate(name, oldValue)
+ {
+ super.requestUpdate(name, oldValue);
+
+ if (name === 'disabled' || name === 'showsFeedbackFor' || name === 'modelValue')
+ {
+ this._toggleInvokerDisabled();
+ }
+ }
+
+ /**
+ * Method to check if invoker can be activated: not disabled, empty or invalid
+ *
+ * @protected
+ * */
+ _toggleInvokerDisabled()
+ {
+ if (this._invokerNode)
+ {
+ const invokerNode = /** @type {HTMLElement & {disabled: boolean}} */ (this._invokerNode);
+ invokerNode.disabled = this.disabled || this._isEmpty() || this.hasFeedbackFor.length > 0;
+ }
+ }
+
+ /** @param {import('@lion/core').PropertyValues } changedProperties */
+ firstUpdated(changedProperties)
+ {
+ super.firstUpdated(changedProperties);
+ this._toggleInvokerDisabled();
+ }
+
+ /**
+ * Subclassers can replace this with their custom extension invoker,
+ * like ``
+ */
+ // eslint-disable-next-line class-methods-use-this
+ _invokerTemplate()
+ {
+ return html`
+
+ `;
+ }
+ }
+ return Et2Invoker;
+})
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Url/Et2UrlEmail.ts b/api/js/etemplate/Et2Url/Et2UrlEmail.ts
new file mode 100644
index 0000000000..fdb87f9b4a
--- /dev/null
+++ b/api/js/etemplate/Et2Url/Et2UrlEmail.ts
@@ -0,0 +1,39 @@
+/**
+ * EGroupware eTemplate2 - Email input widget
+ *
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package api
+ * @link https://www.egroupware.org
+ * @author Ralf Becker
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+import {Et2InvokerMixin} from "./Et2InvokerMixin";
+import {IsEmail} from "../Validators/IsEmail";
+import {Et2Textbox} from "../Et2Textbox/Et2Textbox";
+
+/**
+ * @customElement et2-url-email
+ */
+export class Et2UrlEmail extends Et2InvokerMixin(Et2Textbox)
+{
+ constructor()
+ {
+ super();
+ this.defaultValidators.push(new IsEmail());
+ this._invokerLabel = '@';
+ this._invokerTitle = 'Compose mail to';
+ this._invokerAction = () => this.__invokerAction();
+ }
+
+ __invokerAction()
+ {
+ if (!this._isEmpty() && !this.hasFeedbackFor.length &&
+ this.egw().user('apps').mail && this.egw().preference('force_mailto','addressbook') != '1' )
+ {
+ egw.open_link('mailto:'+this.value);
+ }
+ }
+}
+// @ts-ignore TypeScript is not recognizing that this is a LitElement
+customElements.define("et2-url-email", Et2UrlEmail);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Url/Et2UrlPhone.ts b/api/js/etemplate/Et2Url/Et2UrlPhone.ts
new file mode 100644
index 0000000000..b78bff37be
--- /dev/null
+++ b/api/js/etemplate/Et2Url/Et2UrlPhone.ts
@@ -0,0 +1,69 @@
+/**
+ * EGroupware eTemplate2 - Phone input widget
+ *
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package api
+ * @link https://www.egroupware.org
+ * @author Ralf Becker
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+import {Et2InvokerMixin} from "./Et2InvokerMixin";
+import {Et2Textbox} from "../Et2Textbox/Et2Textbox";
+
+/**
+ * @customElement et2-url-phone
+ */
+export class Et2UrlPhone extends Et2InvokerMixin(Et2Textbox)
+{
+ constructor()
+ {
+ super();
+ //this.defaultValidators.push(...);
+ this._invokerLabel = '✆';
+ this._invokerTitle = 'Call';
+ this._invokerAction = () => this.__invokerAction();
+ }
+
+ __invokerAction()
+ {
+ let value = this.value;
+ // Clean number
+ value = value.replace('♥','').replace('(0)','');
+ value = value.replace(/[abc]/gi,2).replace(/[def]/gi,3).replace(/[ghi]/gi,4).replace(/[jkl]/gi,5).replace(/[mno]/gi,6);
+ value = value.replace(/[pqrs]/gi,7).replace(/[tuv]/gi,8).replace(/[wxyz]/gi,9);
+ // remove everything but numbers and plus, as telephon software might not like it
+ value = value.replace(/[^0-9+]/g, '');
+
+ // mobile Webkit (iPhone, Android) have precedence over server configuration!
+ if (navigator.userAgent.indexOf('AppleWebKit') !== -1 &&
+ (navigator.userAgent.indexOf("iPhone") !== -1 || navigator.userAgent.indexOf("Android") !== -1))
+ {
+ window.open("tel:"+value);
+ }
+ else if (this.egw().config("call_link"))
+ {
+ var link = this.egw().config("call_link")
+ // tel: links use no URL encoding according to rfc3966 section-5.1.4
+ .replace("%1", this.egw().config("call_link").substr(0, 4) == 'tel:' ?
+ value : encodeURIComponent(value))
+ .replace("%u",this.egw().user('account_lid'))
+ .replace("%t",this.egw().user('account_phone'));
+ var popup = this.egw().config("call_popup");
+ if (popup && popup !== '_self' || !link.match(/^https?:/)) // execute non-http(s) links eg. tel: like before
+ {
+ egw.open_link(link, '_phonecall', popup);
+ }
+ else
+ {
+ // No popup, use AJAX. We don't care about the response.
+ window.fetch(link, {
+ headers: { 'Content-Type': 'application/json'},
+ method: "GET",
+ });
+ }
+ }
+ }
+}
+// @ts-ignore TypeScript is not recognizing that this is a LitElement
+customElements.define("et2-url-phone", Et2UrlPhone);
\ No newline at end of file
diff --git a/api/js/etemplate/Validators/IsEmail.ts b/api/js/etemplate/Validators/IsEmail.ts
new file mode 100644
index 0000000000..949c74dae3
--- /dev/null
+++ b/api/js/etemplate/Validators/IsEmail.ts
@@ -0,0 +1,44 @@
+import {Pattern} from "@lion/form-core";
+
+export class IsEmail extends Pattern
+{
+ /**
+ * Regexes for validating email addresses incl. email in angle-brackets eg.
+ * + "Ralf Becker "
+ * + "Ralf Becker (EGroupware GmbH) "
+ * + "" or "rb@egroupware.org"
+ * + '"Becker, Ralf" '
+ * + "'Becker, Ralf' "
+ * but NOT:
+ * - "Becker, Ralf " (contains comma outside " or ' enclosed block)
+ * - "Becker < Ralf " (contains < ----------- " ---------------)
+ *
+ * About umlaut or IDN domains: we currently only allow German umlauts in domain part!
+ * We forbid all non-ascii chars in local part, as Horde does not yet support SMTPUTF8 extension (rfc6531)
+ * and we get a "SMTP server does not support internationalized header data" error otherwise.
+ *
+ * Using \042 instead of " to NOT stall minifyer!
+ *
+ * Similar, but not identical, preg is in Etemplate\Widget\Url PHP class!
+ * We can not use "(?@,;:\042\[\]\x80-\xff]+@([a-z0-9ÄÖÜäöüß](|[a-z0-9ÄÖÜäöüß_-]*[a-z0-9ÄÖÜäöüß])\.)+[a-z]{2,}>?$/i);
+
+ constructor()
+ {
+ super(IsEmail.EMAIL_PREG);
+ }
+
+ /**
+ * Give a message about this field being required. Could be customised according to MessageData.
+ * @param {MessageData | undefined} data
+ * @returns {Promise}
+ */
+ static async getMessage(data)
+ {
+ return data.formControl.egw().lang("Invalid email");
+ }
+}
\ No newline at end of file
diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts
index 89913a1294..5841b19465 100644
--- a/api/js/etemplate/etemplate2.ts
+++ b/api/js/etemplate/etemplate2.ts
@@ -46,6 +46,8 @@ import './Et2Textbox/Et2Number';
import './Et2Textbox/Et2NumberReadonly';
import './Et2Colorpicker/Et2Colorpicker';
import './Et2Taglist/Et2Taglist';
+import './Et2Url/Et2UrlEmail';
+import './Et2Url/Et2UrlPhone';
/* Include all widget classes here, we only care about them registering, not importing anything*/
import './et2_widget_vfs'; // Vfs must be first (before et2_widget_file) due to import cycle