/**
* Low-level dialog management.
* Also used for menus as a menu is a kind of dialog.
* <p>Part of the XUI module which, for now, has an undocumented API.
*/
export class Dialogs {
/**
* Create and show a dialog containing specified element.
*/
static open(options) {
let opts = Object.assign({
form: null,
type: "modal",
position: "center", reference: null,
classes: null
}, options);
let form = opts.form;
let type = opts.type;
let position = opts.position;
let reference = opts.reference;
let classes = opts.classes;
// --------------
// Implementation
// --------------
// * Each layer has Dialogs.LAYER_CAPACITY z-index capacity.
// * Layer #0 contains all non modal dialogs (type=dialog),
// each having a different z-index.
// * Layer #ODD_NUMBER contains a glass pane which blocks
// user interaction such as mouse clicks.
// There is always a glass pane in the layer which is just below
// the layer containing a modal dialog or a group of popup dialogs.
// * Layer #EVEN_NUMBER contains either:
// - a single modal dialog (type=modal),
// - one of more popup dialogs (type=popup),
// each having a different z-index.
switch (type) {
case "dialog":
case "popup":
break;
default:
type = "modal";
break;
}
if (!Array.isArray(position)) {
switch (position) {
case "menu":
case "submenu":
case "comboboxmenu":
case "startmenu":
case "stopmenu":
if (reference === null) {
// Specified position does not make sense.
position = "center";
}
break;
case "topleft":
case "topright":
case "bottomleft":
case "bottomright":
case "top":
case "bottom":
case "left":
case "right":
break;
default:
position = "center";
break;
}
}
const html = document.documentElement;
const body = document.body;
let zIndex = 0;
let [max1, topGlass, topGlassType, topGlassZIndex, max2] =
Dialogs.scanExisting(body);
switch (type) {
case "dialog":
// No glass.
zIndex = max1 + 1;
Dialogs.checkNextZIndex(zIndex);
break;
case "popup":
if (topGlassType === "popup") {
// Glass already exists because other popups already exist in
// this layer.
zIndex = max2 + 1;
Dialogs.checkNextZIndex(zIndex);
break;
}
//FALLTHROUGH
default: // modal.
{
zIndex = (Math.floor(max2 / Dialogs.LAYER_CAPACITY) *
Dialogs.LAYER_CAPACITY) + Dialogs.LAYER_CAPACITY;
let glass = document.createElement("div");
glass.setAttribute("data-xui-glass", `${zIndex} ${type}`);
glass.setAttribute("style", `z-index: ${zIndex}; \
width: ${body.scrollWidth - 1}px; height: ${body.scrollHeight - 1}px;`);
body.appendChild(glass);
for (let eventType of [ "click", "auxclick", "contextmenu" ]) {
glass.addEventListener(eventType, Dialogs.onClickGlass,
/*capture*/ true);
}
zIndex += Dialogs.LAYER_CAPACITY;
}
break;
}
let dialog = document.createElement("div");
dialog.setAttribute("data-xui-dialog", `${zIndex} ${type}`);
dialog.setAttribute("tabindex", "-1");
if (classes !== null && (classes = classes.trim()).length > 0) {
dialog.setAttribute("class", classes);
}
dialog.setAttribute("style", `position: absolute; left: 0px; top: 0px; \
z-index: ${zIndex}; visibility: hidden;`);
dialog.addEventListener("keydown", Dialogs.onKeydownDialog);
if (form !== null) {
dialog.appendChild(form);
}
body.appendChild(dialog);
if (type !== "dialog") {
let focused = document.activeElement;
if (focused !== null && !(focused instanceof HTMLBodyElement)) {
dialog.xuiWasFocused = focused;
}
}
// Do not focus a non-modal dialog added below a modal or
// popup dialog.
const focusDialog = (zIndex > max2);
// doOpen after 100ms is needed because otherwise we have
// a FOUC and/or an incorrect dialog.getBoundingClientRect.
// (Just Oms does not work.)
setTimeout(() => {
Dialogs.doOpen(dialog, focusDialog, position, reference);
}, 100 /*ms*/);
return dialog;
}
static doOpen(dialog, focusDialog, position, reference) {
const html = document.documentElement;
let htmlClientWidth = html.clientWidth;
if (htmlClientWidth > 1) {
--htmlClientWidth;
}
let htmlClientHeight = html.clientHeight;
if (htmlClientHeight > 1) {
--htmlClientHeight;
}
const htmlRect = new DOMRect(0, 0, htmlClientWidth, htmlClientHeight);
let refRect = null;
if (reference !== null && Util.pageContains(reference)) {
refRect = reference.getBoundingClientRect();
if (!Util.intersects(refRect, htmlRect)) {
refRect = null;
}
}
if (refRect === null) {
// Fallback to using viewport rect.
refRect = htmlRect;
}
const rect = dialog.getBoundingClientRect();
// left and top are relative to the body=dialog.offsetParent
// (because dialog is added to the body and absolute position
// is relative to the nearest positioned ancestor).
//
// However at first, we'll use client coordinates.
// (client rectangle = element rectangle, contents+padding+border,
// expressed in window coordinates, that is,
// the top/left corner of the window viewport is point 0,0.)
let left = 0;
let top = 0;
if (Array.isArray(position)) {
// Generally comes from a MouseEvent.clientX/Y.
left = position[0] + 1;
top = position[1] + 1;
} else {
switch (position) {
case "menu":
left = refRect.left;
top = refRect.bottom;
break;
case "submenu":
left = refRect.right;
top = refRect.top;
break;
case "comboboxmenu":
left = refRect.right - rect.width;
top = refRect.bottom;
break;
case "startmenu":
left = refRect.left;
top = refRect.top - rect.height;
break;
case "stopmenu":
// A "startmenu" which would found at the bottom/right corner of
// a window.
left = refRect.right - rect.width;
top = refRect.top - rect.height;
break;
case "topleft":
left = refRect.left;
top = refRect.top;
break;
case "topright":
left = refRect.right - rect.width;
top = refRect.top;
break;
case "bottomleft":
left = refRect.left;
top = refRect.bottom - rect.height;
break;
case "bottomright":
left = refRect.right - rect.width;
top = refRect.bottom - rect.height;
break;
case "top":
left = refRect.left + ((refRect.width - rect.width)/2);
top = refRect.top;
break;
case "bottom":
left = refRect.left + ((refRect.width - rect.width)/2);
top = refRect.bottom - rect.height;
break;
case "left":
left = refRect.left;
top = refRect.top + ((refRect.height - rect.height)/2);
break;
case "right":
left = refRect.right - rect.width;
top = refRect.top + ((refRect.height - rect.height)/2);
break;
case "center":
left = refRect.left + ((refRect.width - rect.width)/2);
top = refRect.top + ((refRect.height - rect.height)/2);
break;
}
}
// We favor the fact that the dialog is entirely visible over the fact
// it is positioned as specified by client code.
if (left < 0) {
left = 0;
} else if (left + rect.width > htmlClientWidth &&
htmlClientWidth - rect.width >= 0) {
left = htmlClientWidth - rect.width;
}
if (top < 0) {
top = 0;
} else if (top + rect.height > htmlClientHeight &&
htmlClientHeight - rect.height >= 0) {
top = htmlClientHeight - rect.height;
}
/* What follows is not practical to use because the body
almost always has a margin (8px by default):
const bodyRect = body.getBoundingClientRect();
left = left - bodyRect.left + LEFT_BODY_MARGIN;
top = top - bodyRect.top + TOP_BODY_MARGIN;
*/
left += html.scrollLeft;
top += html.scrollTop;
dialog.style.visibility = "visible";
dialog.style.left = `${left}px`;
dialog.style.top = `${top}px`;
// Wait until the dialog is visible to focus a field in it.
if (focusDialog) {
setTimeout(() => { Dialogs.focus(dialog); }, 100 /*ms*/);
}
}
static checkNextZIndex(zIndex) {
if ((zIndex % Dialogs.LAYER_CAPACITY) === 0) {
throw new Error(`Dialogs.open: INTERNAL ERROR: cannot open dialog: \
reached maximum layer capacity=${Dialogs.LAYER_CAPACITY}`);
}
}
static scanExisting(body) {
let max1 = 0; // zIndex of the topmost NON-MODAL dialog.
let topGlass = null;
let topGlassType = null;
let topGlassZIndex = 0;
let max2 = 0; // zIndex of the topmost dialog, WHATEVER ITS TYPE.
let node = body.lastChild;
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE) {
let [kind, type, zIndex] = Dialogs.getData(node);
if (kind === null) {
// Done.
break;
}
if (kind === "glass") {
if (zIndex > topGlassZIndex) {
topGlass = node;
topGlassType = type;
topGlassZIndex = zIndex;
}
} else {
// dialog kind ---
if (zIndex > max2) {
max2 = zIndex;
}
if (zIndex < Dialogs.LAYER_CAPACITY && zIndex > max1) {
max1 = zIndex;
}
}
}
node = node.previousSibling;
}
return [max1, topGlass, topGlassType, topGlassZIndex, max2];
}
static getData(elem) {
let kind = null;
let type = null;
let zIndex = 0;
if (elem !== null && elem.nodeType === Node.ELEMENT_NODE) {
let attr = elem.getAttribute("data-xui-dialog");
if (attr) {
kind = "dialog";
} else {
attr = elem.getAttribute("data-xui-glass");
if (attr) {
kind = "glass";
}
}
if (kind !== null) {
let split = attr.split(' ');
if (split.length !== 2) {
kind = null;
} else {
zIndex = parseInt(split[0]);
if (isNaN(zIndex) || zIndex <= 0) {
kind = null;
}
type = split[1];
switch (type) {
case "dialog":
case "popup":
case "modal":
break;
default:
kind = null;
break;
}
}
}
}
let data = [null, null, 0];
if (kind !== null) {
data[0] = kind;
data[1] = type;
data[2] = zIndex;
}
return data;
}
static onClickGlass(event) {
Dialogs.closeAllPopups(event.currentTarget);
event.preventDefault();
event.stopPropagation();
}
static closeAllPopups(glass=null) {
if (glass === null) {
let [max1, topGlass, topGlassType, topGlassZIndex, max2] =
Dialogs.scanExisting(document.body);
if (topGlass === null || topGlassType !== "popup") {
// Nothing to do.
return;
}
glass = topGlass;
}
let [kind, type, zIndex] = Dialogs.getData(glass);
assertOrError(kind === "glass", `${glass}, not a glass pane`);
if (type === "popup") {
const layerZIndex = zIndex + Dialogs.LAYER_CAPACITY;
let popups = Dialogs.getPopups(layerZIndex);
assertOrError(popups.length > 0);
// Closing the popup having the smallest zIndex
// 1) closes all popups in this popup layer;
// 2) removes the corresponding glass.
let closed = Dialogs.doClose(popups[popups.length-1].dialog);
assertOrError(glass.parentNode === null || !closed);
}
}
static onKeydownDialog(event) {
let dialog = event.currentTarget;
let [kind, type, zIndex] = Dialogs.getData(dialog);
assertOrError(kind === "dialog", `${dialog}, not a dialog`);
if (event.key === "Escape" &&
!event.altKey && !event.ctrlKey &&
!event.metaKey && !event.shiftKey) {
Dialogs.doClose(dialog);
event.preventDefault();
event.stopPropagation();
}
}
static getPopups(minZIndex) {
let popups = [];
const layerZIndex =
(Math.floor(minZIndex / Dialogs.LAYER_CAPACITY) *
Dialogs.LAYER_CAPACITY);
const nextLayerZIndex = layerZIndex + Dialogs.LAYER_CAPACITY;
let node = document.body.lastChild;
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE) {
let [kind, type, zIndex] = Dialogs.getData(node);
if (kind === null) {
// Done.
break;
}
if (kind === "dialog" && type === "popup" &&
zIndex >= minZIndex && zIndex < nextLayerZIndex) {
popups.push({ dialog: node, zIndex: zIndex });
}
}
node = node.previousSibling;
}
if (popups.length > 1) {
// The popups having the largest zIndex come first.
popups.sort((popup1, popup2) => {
return popup2.zIndex - popup1.zIndex;
});
}
return popups;
}
/**
* Close dialog containing specified element.
*/
static close(elem, result=null) {
let dialog = Dialogs.lookup(elem);
if (dialog === null) {
return false;
}
return Dialogs.doClose(dialog, result);
}
static lookup(elem) {
let dialog = null;
while (elem !== null) {
if (elem.hasAttribute("data-xui-dialog")) {
dialog = elem;
break;
}
elem = elem.parentElement;
}
return dialog;
}
static doClose(dialog, result=null) {
let [kind, type, zIndex] = Dialogs.getData(dialog);
assertOrError(kind === "dialog", `${dialog}, not a dialog`);
let glassZIndex = 0;
switch (type) {
case "popup":
{
if (!Dialogs.closeSubsequentPopups(dialog)) {
return false;
}
const layerZIndex =
(Math.floor(zIndex / Dialogs.LAYER_CAPACITY) *
Dialogs.LAYER_CAPACITY);
if (zIndex === layerZIndex) {
// Closing last popup of the layer. Close corresponding
// glass too.
glassZIndex = layerZIndex - Dialogs.LAYER_CAPACITY;
}
}
break;
case "modal":
glassZIndex = zIndex - Dialogs.LAYER_CAPACITY;
break;
}
if (!Dialogs.notifyListeners(dialog, result)) {
// Close canceled by a listener. Give up.
return false;
}
if (glassZIndex > 0) {
let glass = document.body.querySelector(
`[data-xui-glass='${glassZIndex} ${type}']`);
assertOrError(glass !== null,
`no ${type} glass at z-index=${glassZIndex}`);
glass.parentNode.removeChild(glass);
}
Dialogs.discard(dialog);
Dialogs.restoreFocus(dialog);
return true;
}
static closeSubsequentPopups(dialog) {
let [kind, type, zIndex] = Dialogs.getData(dialog);
assertOrError(kind === "dialog", `${dialog}, not a dialog`);
if (type === "popup") {
const layerZIndex = (Math.floor(zIndex / Dialogs.LAYER_CAPACITY) *
Dialogs.LAYER_CAPACITY);
let popups = Dialogs.getPopups(layerZIndex);
for (let popup of popups) {
if (popup.zIndex === zIndex) {
assertOrError(popup.dialog === dialog);
break;
}
if (!Dialogs.notifyListeners(popup.dialog,
/*result*/ null)) {
// Close canceled by a listener. Give up.
return false;
}
Dialogs.discard(popup.dialog);
}
}
return true;
}
static notifyListeners(dialog, result) {
let event =
new Event("dialogclosed",
{ bubbles: true, cancelable: true, composed: true });
event.xuiDialogResult = result;
dialog.dispatchEvent(event);
return !event.defaultPrevented;
}
static discard(dialog) {
dialog.parentNode.removeChild(dialog);
// Detach form so it can be reused in a new dialog.
const form = dialog.firstElementChild;
if (form !== null) {
dialog.removeChild(form);
}
}
static restoreFocus(closedDialog) {
if (closedDialog.xuiWasFocused) {
closedDialog.xuiWasFocused.focus();
} else {
let [max1, topGlass, topGlassType, topGlassZIndex, max2] =
Dialogs.scanExisting(document.body);
if (max2 >= 2*Dialogs.LAYER_CAPACITY) {
let dialog =
document.body.querySelector(`[data-xui-dialog^='${max2}']`);
assertOrError(dialog !== null,
`no modal or popup dialog at z-index=${max2}`);
Dialogs.focus(dialog);
}
}
}
static focus(dialog) {
let focusable = dialog.querySelector("[autofocus]");
if (focusable === null) {
focusable = dialog.querySelector(
`input:not([disabled]):not([tabindex='-1']),\
select:not([disabled]):not([tabindex='-1']),\
textarea:not([disabled]):not([tabindex='-1']),\
button:not([disabled]):not([tabindex='-1']),
a[href]:not([tabindex='-1']),\
area[href]:not([tabindex='-1']),\
[tabindex]:not([tabindex='-1']),\
[contenteditable=true]:not([tabindex='-1'])`);
if (focusable === null) {
focusable = dialog;
}
}
if (focusable !== null) {
focusable.focus();
}
}
}
Dialogs.LAYER_CAPACITY = 100;