Source: xui/Dialogs.js

/**
 * 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) {
        let refRect;
        const html = document.documentElement;
        if (reference === null) {
            // Special case of clientWidth/clientHeight: here, it's the
            // size of the viewport.
            refRect = new DOMRect(0, 0, html.clientWidth, html.clientHeight);
        } else {
            refRect = reference.getBoundingClientRect();
        }
        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 > html.clientWidth &&
                   html.clientWidth - rect.width >= 0) {
            left = html.clientWidth - rect.width;
        }
        if (top < 0) {
            top = 0;
        } else if (top + rect.height > html.clientHeight &&
                   html.clientHeight - rect.height >= 0) {
            top = html.clientHeight - 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;