Dialog work

- Switch from LionDialog to SlDialog as base
- First input should get focus
- First button gets set as primary (if no default set), Enter key will act as a click on it
- Escape key closes dialog
This commit is contained in:
nathan 2022-11-30 15:59:25 -07:00
parent 350dd31b2b
commit 390fbf3608
6 changed files with 325 additions and 207 deletions

View File

@ -620,8 +620,8 @@ class AdminApp extends EgwApp
{
dialog_options['width'] = 550;
modifications.tabs = {
add_tabs: true,
tabs: [{
addTabs: true,
extraTabs: [{
label: egw.lang('Documentation'),
template: 'policy.admin_cmd',
prepend: false

View File

@ -11,17 +11,17 @@
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {et2_button} from "../et2_widget_button";
import {LionDialog} from "@lion/dialog";
import {et2_widget} from "../et2_core_widget";
import {html, LitElement, ScopedElementsMixin, SlotMixin} from "@lion/core";
import {Et2DialogOverlay} from "./Et2DialogOverlay";
import {Et2DialogContent} from "./Et2DialogContent";
import {classMap, css, html, ifDefined, LitElement, render, repeat, SlotMixin, styleMap} from "@lion/core";
import {et2_template} from "../et2_widget_template";
import {etemplate2} from "../etemplate2";
import {IegwAppLocal} from "../../jsapi/egw_global";
import {egw, IegwAppLocal} from "../../jsapi/egw_global";
import interact from "@interactjs/interactjs";
import type {InteractEvent} from "@interactjs/core/InteractEvent";
import {Et2Button} from "../Et2Button/Et2Button";
import shoelace from "../Styles/shoelace";
import {SlDialog} from "@shoelace-style/shoelace";
import {egwIsMobile} from "../../egw_action/egw_action_common";
export interface DialogButton
{
@ -122,11 +122,9 @@ export interface DialogButton
* let result = await dialog.getComplete();
*```
*
* Because of how LionDialog does its layout and rendering, it's easiest to separate the dialog popup from
* the dialog content. This allows us to easily preserve the WebComponent styling. You should interact with the
* Et2Dialog though, and ignore the Et2DialogOverlay & Et2DialogContent.
* Customize initial focus by setting the "autofocus" attribute on a control, otherwise first input will have focus
*/
export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialog)))
export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
{
/**
* Dialogs don't always get added to an etemplate, so we keep our own egw
@ -184,6 +182,31 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
public static readonly YES_BUTTON : number = 2;
public static readonly NO_BUTTON : number = 3;
static get styles()
{
return [
...shoelace,
...(super.styles || []),
css`
:host {
--header-spacing: var(--sl-spacing-medium);
--body-spacing: var(--sl-spacing-medium)
}
.dialog__title {
user-select: none;
}
.dialog__footer {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: stretch;
gap: 5px;
}
`
];
}
static get properties()
{
return {
@ -245,21 +268,14 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
get slots()
{
return {
...super.slots
...super.slots,
'': () =>
{
return this._contentTemplate();
}
}
}
// Still not sure what this does, but it's important.
// Seems to be related to the constructor, and what's available during the "creation"
static get scopedElements()
{
return {
...super.scopedElements,
'et2-dialog-overlay-frame': Et2DialogOverlay,
'et2-dialog-content': Et2DialogContent
};
}
/*
* List of properties that get translated
* Done separately to not interfere with properties - if we re-define label property,
@ -323,14 +339,20 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
this.destroyOnClose = true;
this.hideOnEscape = this.hideOnEscape === false ? false : true;
this.__value = {};
this.open = true;
this._onOpen = this._onOpen.bind(this);
this._onClose = this._onClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleClose = this.handleClose.bind(this);
this._onClick = this._onClick.bind(this);
this._onButtonClick = this._onButtonClick.bind(this);
this._onMoveResize = this._onMoveResize.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this._adoptTemplateButtons = this._adoptTemplateButtons.bind(this);
// Don't leave it undefined, it's easier to deal with if it's just already resolved.
// It will be re-set if a template is loaded
this._template_promise = Promise.resolve(false);
// Create this here so we have something, otherwise the creator might continue with undefined while we
// wait for the dialog to complete & open
this._complete_promise = new Promise<[number, Object]>((resolve) =>
@ -343,32 +365,82 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
{
super.connectedCallback();
// Wait for everything to complete, then auto-open
this.getUpdateComplete().then(() =>
// Prevent close if they click the overlay when the dialog is modal
this.addEventListener('sl-request-close', event =>
{
// _overlayCtrl is not available earlier
this._overlayCtrl?.addEventListener("show", this._onOpen);
this._overlayCtrl.addEventListener('hide', this._onClose);
if(this.modal && event.detail.source === 'overlay')
{
event.preventDefault();
}
})
this.addEventListener("sl-after-show", this.handleOpen);
this.addEventListener('sl-hide', this.handleClose);
// Bind on the ancestor, not the buttons, so their click handler gets a chance to run
this._overlayContentNode.addEventListener("click", this._onButtonClick);
window.setTimeout(this.open, 0);
});
}
disconnectedCallback()
{
super.disconnectedCallback();
this._overlayCtrl.removeEventListener("hide", this._onClose);
this._overlayCtrl.removeEventListener("show", this._onOpen);
this._overlayContentNode.removeEventListener("click", this._onButtonClick);
this.removeEventListener("sl-hide", this.handleClose);
this.removeEventListener("sl-after-show", this.handleOpen);
}
addOpenListeners()
{
//super.addOpenListeners();
// Bind on the ancestor, not the buttons, so their click handler gets a chance to run
this.addEventListener("click", this._onButtonClick);
this.addEventListener("keydown", this.handleKeyDown);
}
removeOpenListeners()
{
//super.removeOpenListeners();
this.removeEventListener("click", this._onButtonClick);
this.removeEventListener("keydown", this.handleKeyDown);
}
handleKeyDown(event : KeyboardEvent)
{
// Parent handles escape, but is already bound
super.handleKeyDown(event);
// Trigger the "primary" or first button
if(this.open && event.key === 'Enter')
{
let button = this.querySelectorAll("[varient='primary']");
if(button.length == 0)
{
// Nothing explicitly marked, check for buttons in the footer
button = this.querySelectorAll("et2-button[slot='footer']");
}
if(button && button[0])
{
event.stopPropagation();
button[0].dispatchEvent(new CustomEvent('click', {bubbles: true}));
}
}
}
firstUpdated()
{
super.firstUpdated();
// If we start open, fire handler to get setup done
if(this.open)
{
this.handleOpenChange();
}
this.updateComplete.then(() => this._setDefaultAutofocus());
}
// Need to wait for Overlay
async getUpdateComplete()
{
await super.getUpdateComplete();
await this._overlayContentNode.getUpdateComplete();
// Wait for template to finish loading
if(this._template_widget)
@ -382,18 +454,28 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
return this._complete_promise;
}
_onOpen()
handleOpen()
{
this.addOpenListeners();
this._button_id = null;
this._complete_promise = this._complete_promise || new Promise<[number, Object]>((resolve) => this._completeResolver);
this._setupMoveResize(this._overlayContentNode);
// Now consumers can listen for "open" event, though getUpdateComplete().then(...) also works
this.dispatchEvent(new Event('open'));
Promise.all([this._template_promise, this.updateComplete])
.then(() => this._setupMoveResize());
}
_onClose(ev : PointerEvent)
handleClose(ev : PointerEvent)
{
// Avoid closing if a selectbox is closed
if(ev.target !== this)
{
return;
}
this.removeOpenListeners();
this._completeResolver([this._button_id, this.value]);
this._button_id = null;
this._complete_promise = undefined;
@ -406,7 +488,6 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
{
this._template_widget.clear();
}
this._overlayCtrl.teardown();
this.remove();
}
}
@ -468,7 +549,7 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
{
console.log(e);
}
this.close();
this.hide();
}
/**
@ -518,15 +599,6 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
return value;
}
/**
* Fire the close-overlay event to use all registered listeners
* @deprecated
*/
destroy()
{
this._overlayContentNode.dispatchEvent(new Event('close-overlay'));
}
/**
* @deprecated
* @returns {Object}
@ -555,6 +627,12 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
return this._template_widget || null;
}
get title() : string { return this.label }
set title(new_title : string)
{
this.label = new_title;
}
updated(changedProperties)
{
@ -563,6 +641,11 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
{
this._loadTemplate();
}
if(changedProperties.has("buttons"))
{
render(this._buttonsTemplate(), this);
this.requestUpdate();
}
}
_loadTemplate()
@ -571,13 +654,14 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
{
this._template_widget.clear();
}
this._contentNode.replaceChildren();
// Etemplate wants a content
if(typeof this.__value.content === "undefined")
{
this.__value.content = {};
}
this._template_widget = new etemplate2(this._overlayContentNode._contentNode);
this._template_widget = new etemplate2(this._contentNode);
// Fire an event so consumers can do their thing - etemplate will fire its own load event when its done
if(!this.dispatchEvent(new CustomEvent("before-load", {
@ -631,83 +715,103 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
this._template_widget.DOMContainer.setAttribute('id', this.__template.replace(/^(.*\/)?([^/]+?)(\.xet)?(\?.*)?$/, '$2').replace(/\./g, '-'));
// Look for buttons after load
this._overlayContentNode._contentNode.addEventListener("load", this._adoptTemplateButtons);
}
this._contentNode.addEventListener("load", this._adoptTemplateButtons);
render()
{
return this._overlayTemplate();
}
// Default autofocus to first input if autofocus is not set
this._contentNode.addEventListener("load", this._setDefaultAutofocus);
/**
* Don't allow any children here, pass them on to the content node
*
* @param {HTMLElement} node
* @returns {any}
*/
appendChild(node : HTMLElement)
{
return this._overlayContentNode.appendChild(node);
}
/**
* Defining this overlay as a templates from OverlayMixin
* this is our source to give as .contentNode to OverlayController.
* @protected
*/
protected _overlayTemplate()
{
return html`
<div id="overlay-content-node-wrapper">
<et2-dialog-overlay-frame class="dialog__overlay-frame"
.width=${this.width}
.height=${this.height}
._dialog=${this}
.buttons=${this._getButtons()}>
<span slot="heading">${this.title}</span>
${this._contentTemplate()}
</et2-dialog-overlay-frame>
</div>
`;
}
/**
* @override Configures OverlayMixin
*/
get _overlayContentNode()
{
if(this._cachedOverlayContentNode)
{
return this._cachedOverlayContentNode;
}
this._cachedOverlayContentNode = /** @type {HTMLElement} */ (
/** @type {ShadowRoot} */ (this.shadowRoot).querySelector('.dialog__overlay-frame')
);
return this._cachedOverlayContentNode;
// Need to update to pick up changes
this.requestUpdate();
}
_contentTemplate()
{
if(this.__template)
/**
* Classes for dialog type options
*/
const _dialogTypes : any = [
//PLAIN_MESSAGE: 0
"",
//INFORMATION_MESSAGE: 1,
"dialog_info",
//QUESTION_MESSAGE: 2,
"dialog_help",
//WARNING_MESSAGE: 3,
"dialog_warning",
//ERROR_MESSAGE: 4,
"dialog_error"
];
let icon = this.icon || this.egw().image(_dialogTypes[this.dialogType] || "") || "";
let type = _dialogTypes[this.dialogType];
let classes = {
dialog_content: true,
"dialog--has_message": this.message,
"dialog--has_template": this.__template
};
if(type)
{
return html`
<div slot="content"></div>`;
classes[type] = true;
}
else
// Add in styles set via property
let styles = {};
if(this.width)
{
return html`
<et2-dialog-content slot="content" ?icon=${this.icon}
.dialog_type=${this.dialog_type}
.value=${this.value}
>
${this.message}
</et2-dialog-content>
`;
styles["--width"] = this.width;
}
if(this.height)
{
styles.height = "--height: " + this.height;
}
return html`
<div class=${classMap(classes)} style="${styleMap(styles)}">
${this.__template ? "" :
html` <img class="dialog_icon" src=${icon}/>
<slot>${this.message}</slot>`
}
</div>`;
}
_getButtons()
_buttonsTemplate()
{
if(!this.buttons)
{
return;
}
let buttons = this._getButtons();
let hasDefault = false;
buttons.forEach((button) =>
{
if(button.default)
{
hasDefault = true;
}
})
// Set button._parent here, otherwise button will have trouble finding our egw()
return html`${repeat(buttons, (button : DialogButton) => button.id, (button, index) =>
{
let isDefault = hasDefault && button.default || !hasDefault && index == 0;
return html`
<et2-button ._parent=${this} id=${button.id} button_id=${button.button_id}
label=${button.label}
slot="footer"
.image=${ifDefined(button.image)}
.noSubmit=${true}
?disabled=${button.disabled}
variant=${isDefault ? "primary" : "default"}
?outline=${isDefault}
align=${ifDefined(button.align)}>
</et2-button>
`
})}`;
}
_getButtons() : DialogButton[]
{
if(Number.isInteger(this.buttons))
{
@ -733,7 +837,7 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
_adoptTemplateButtons()
{
// Check for something with buttons slot set
let search_in = this._template_widget?.DOMContainer || this._overlayContentNode._contentNode;
let search_in = <HTMLElement>(this._template_widget?.DOMContainer || this._contentNode);
let template_buttons = [
...search_in.querySelectorAll('[slot="buttons"]'),
// Look for a dialog footer, which will contain several buttons and possible other widgets
@ -745,8 +849,8 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
{
template_buttons.forEach((button) =>
{
button.setAttribute("slot", "buttons");
this._overlayContentNode.appendChild(button);
button.setAttribute("slot", "footer");
this.appendChild(button);
})
}
// do NOT submit dialog, if it has no etemplate_exec_id, it only gives and error on server-side
@ -761,36 +865,56 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
}
/**
* @override Configures OverlayMixin
* @desc overrides default configuration options for this component
* @returns {Object}
* Set autofocus on first input element if nothing has autofocus
*/
_defineOverlayConfig()
_setDefaultAutofocus()
{
let not_modal = {
hasBackdrop: false,
preventsScroll: false,
trapsKeyboardFocus: false,
const autofocused = this.querySelector("[autofocus]");
if(autofocused)
{
return;
}
return {
...super._defineOverlayConfig(),
hidesOnEscape: this.hideOnEscape,
...(this.modal ? {} : not_modal)
if(this.template)
{
this.template.focusOnFirstInput();
}
else
{
// Not a template, but maybe something?
const $input = jQuery('input:visible,et2-textbox:visible,et2-select-email:visible', this)
// Date fields open the calendar popup on focus
.not('.et2_date')
.filter(function()
{
// Skip inputs that are out of tab ordering
const $this = jQuery(this);
return !$this.attr('tabindex') || parseInt($this.attr('tabIndex')) >= 0;
}).first();
// mobile device, focus only if the field is empty (usually means new entry)
// should focus always for non-mobile one
if(egwIsMobile() && $input.val() == "" || !egwIsMobile())
{
$input.focus();
}
}
}
_setupMoveResize(element)
get _contentNode() : HTMLElement
{
return this.querySelector('.dialog_content');
}
_setupMoveResize()
{
// Quick calculation of min size - dialog is made up of header, content & buttons
let minHeight = 0;
for(let e of element.shadowRoot.querySelector('.overlay').children)
for(let e of this.panel.children)
{
minHeight += e.getBoundingClientRect().height + parseFloat(getComputedStyle(e).marginTop) + parseFloat(getComputedStyle(e).marginBottom)
}
interact(element)
interact(this.panel)
.resizable({
edges: {bottom: true, right: true},
listeners: {
@ -810,7 +934,8 @@ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialo
})
.draggable({
allowFrom: ".overlay__heading",
allowFrom: ".dialog__header",
ignoreFrom: ".dialog__close",
listeners: {
move: this._onMoveResize
},

View File

@ -445,47 +445,50 @@ export function nm_open_popup(_action, _selected)
var popup = document.body.querySelector("et2-dialog[id*='" + _action.id + "_popup']") || document.body.querySelector("#" + (uid || "") + "_" + _action.id + "_popup") || document.body.querySelector("[id*='" + _action.id + "_popup']");
if (popup && popup instanceof Et2Dialog)
{
popup.open();
popup.show();
}
else if (popup)
{
let dialog = new Et2Dialog();
dialog.destroy_on_close = false;
dialog.destroyOnClose = false;
dialog.id = popup.id;
popup.setAttribute("id", "_" + popup.id);
popup.removeAttribute("id");
// Set title
let title = popup.querySelector(".promptheader")
if (title)
{
title.slot = "label"
dialog.appendChild(title);
}
popup.slot = "";
dialog.addEventListener("close", () =>
{
window.nm_popup_action = null;
})
dialog.getUpdateComplete().then(() =>
{
let title = popup.querySelector(".promptheader")
if (title)
{
title.slot = "heading"
dialog.appendChild(title);
}
popup.slot = "content";
// Move buttons
popup.querySelectorAll('et2-button').forEach((button) =>
{
button.slot = "buttons";
let button_click = button.onclick;
button.onclick = (e) =>
{
window.nm_popup_action = button_click ? action : null;
window.nm_popup_ids = selected;
dialog.close();
return button_click?.apply(button, e.currentTarget);
};
dialog.appendChild(button);
})
dialog.appendChild(popup);
});
// Move buttons
popup.querySelectorAll('et2-button').forEach((button, index) =>
{
button.slot = "footer";
if (index == 0)
{
button.variant = "primary";
button.outline = true;
}
let button_click = button.onclick;
button.onclick = (e) =>
{
window.nm_popup_action = button_click ? action : null;
window.nm_popup_ids = selected;
dialog.hide();
return button_click?.apply(button, e.currentTarget);
};
dialog.appendChild(button);
})
dialog.appendChild(popup);
dialog.requestUpdate();
document.body.appendChild(dialog);

View File

@ -88,25 +88,25 @@ export class et2_dialog extends Et2Dialog
return super._getButtons();
}
_onOpen()
handleOpen()
{
super._onOpen();
super.handleOpen();
// move the overlay dialog into appendTo dom since we want it to be shown in that container
if (this.appendTo)
if(this.appendTo)
{
document.getElementsByClassName(this.appendTo.replace('.',''))[0].appendChild(this._cachedOverlayContentNode);
document.getElementsByClassName(this.appendTo.replace('.', ''))[0].appendChild(this._cachedOverlayContentNode);
}
}
_onClose(ev: PointerEvent)
handleClose(ev : PointerEvent)
{
// revert the moved container back to its original position in order to be able to teardown the overlay properly
if (this.appendTo)
if(this.appendTo)
{
document.getElementsByClassName('global-overlays__overlay-container')[0].appendChild(this._cachedOverlayContentNode);
}
super._onClose(ev);
super.handleClose(ev);
}
/**

View File

@ -186,7 +186,7 @@ egw.extend('timer', egw.MODULE_GLOBAL, function()
if (!dialog) return;
// disable not matching / available menu-items
dialog._overlayContentNode.querySelectorAll('et2-button').forEach(button =>
dialog.querySelectorAll('et2-button').forEach(button =>
{
if (button.id.substring(0, 7) === 'overall')
{
@ -261,10 +261,16 @@ egw.extend('timer', egw.MODULE_GLOBAL, function()
// if dialog is open, it shows both timers
if (dialog)
{
const specific_timer = dialog._overlayContentNode.querySelector('div#_specific_timer');
const overall_timer = dialog._overlayContentNode.querySelector('div#_overall_timer');
if (specific_timer) updateTimer(specific_timer, specific);
if (overall_timer) updateTimer(overall_timer, overall);
const specific_timer = dialog.querySelector('div#_specific_timer');
const overall_timer = dialog.querySelector('div#_overall_timer');
if (specific_timer)
{
updateTimer(specific_timer, specific);
}
if (overall_timer)
{
updateTimer(overall_timer, overall);
}
}
}
@ -356,8 +362,8 @@ egw.extend('timer', egw.MODULE_GLOBAL, function()
// if dialog is shown, update its timer(s) too
if (dialog)
{
const specific_timer = dialog._overlayContentNode.querySelector('div#_specific_timer');
const overall_timer = dialog?._overlayContentNode.querySelector('div#_overall_timer');
const specific_timer = dialog.querySelector('div#_specific_timer');
const overall_timer = dialog?.querySelector('div#_overall_timer');
if (specific_timer && _timer === specific)
{
updateTimer(specific_timer, specific)
@ -489,8 +495,9 @@ egw.extend('timer', egw.MODULE_GLOBAL, function()
};
// disable not matching / available menu-items
dialog._overlayContentNode.querySelectorAll('et2-date-time-today').forEach(_widget => {
const [,timer, action] = _widget.id.match(/times\[([^\]]+)\]\[([^\]]+)\]/);
dialog.querySelectorAll('et2-date-time-today').forEach(_widget =>
{
const [, timer, action] = _widget.id.match(/times\[([^\]]+)\]\[([^\]]+)\]/);
_widget.value = times[timer][action];
});
}

View File

@ -776,35 +776,18 @@ button.et2_timestamper:hover {
/**
* Dialog widget
* It uses jQueryUI, so this is just our little bits - icon on left
*/
.ui-dialog-content .dialog_icon {
et2-dialog .dialog_icon {
vertical-align: middle;
width: 2em;
margin: 0.5em;
}
.ui-dialog-content {
et2-dialog .dialog--has_message {
vertical-align: middle;
display: flex;
}
.ui-dialog-content > div {
white-space: pre-wrap;
display: inline-block;
vertical-align: middle;
}
/* These change button alignment, but it seems the standard is right-aligned for
action buttons, left aligned for "extra" controls
.ui-dialog .ui-dialog-buttonpane {
text-align: left;
}
.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset {
float: none;
}
.ui-dialog .ui-dialog-buttonpane button {
float: none;
}
*/
/**
* Custom field list
*/