Source: xxe/view/TreeCollapser.js

/*
  ----------------------------------------------------------------------------
  GENERATED HTML EXPECTATIONS: 

  Element tree view:
  
      data-e="" id=UID
        + data-be=UID (contains an <xxe-collapser>)
            + CHILDREN
        + data-ae=UID
  ----------------------------------------------------------------------------
 */

/**
 * A collapser/expander button for an element tree view.
 */
class TreeCollapser extends HTMLElement {
    constructor() {
        super();
        
        this._settingCollapsed = false;
        
        let shadow = this.attachShadow({mode: "open"});
        shadow.appendChild(this.getTemplate().content.cloneNode(true));
        this._iconSpan = shadow.lastElementChild;
        
        const blockEvent = (event) => {
            XUI.Util.consumeEvent(event);
        };
        for (const eventName of ["mousedown", "mousemove", "mouseup",
                                 "auxclick"]) {
            this.addEventListener(eventName, blockEvent);
        }

        this.addEventListener("click", (e) => {
            if (!this.disabled) {
                e.preventDefault();
                e.stopPropagation();
                
                switch (e.detail) {
                case 1: // First click.
                    this.setCollapsed(!this.collapsed, /*deep*/ false);
                    break;
                case 2:
                    this.setCollapsed(this.collapsed, /*deep*/ true);
                    break;
                }
            }
        });

        this.addEventListener("contextmenu", (e) => {
            if (!this.disabled) {
                e.preventDefault();
                e.stopPropagation();

                const menu = XUI.Menu.create([
                    { text: "Collapse All", name: "collapseAll" },
                    { text: "Expand All", name: "expandAll" }
                ]);

                menu.addEventListener("menuitemselected", (menuEvent) => {
                    switch (menuEvent.xuiMenuItem.name) {
                    case "collapseAll":
                        this.setCollapsed(true, /*deep*/ true);
                        break;
                    case "expandAll":
                        this.setCollapsed(false, /*deep*/ true);
                        break;
                    }
                });

                menu.open([e.clientX, e.clientY]);
            }
        });
    }

    getTemplate() {
        return TreeCollapser.TEMPLATE;
    }
    
    setCollapsed(collapsed, deep) {
        this.applyCollapsed(collapsed, deep);

        let docView = DOMUtil.lookupAncestorByTag(this, "xxe-document-view");
        if (docView !== null) {
            // Wait until the collapser menu is hidden before focusing the
            // document view.
            setTimeout(() => { docView.requestFocus(); }, 0 /*ms*/);
        }
    }

    // -----------------------------------------------------------------------
    // Custom element
    // -----------------------------------------------------------------------

    connectedCallback() {
        this.applyCollapsed(this.collapsed, /*deep*/ false);
    }

    static get observedAttributes() {
        return [ "collapsed" ];
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
        if (!this._settingCollapsed) {
            this.applyCollapsed(newVal, /*deep*/ false);
        }
    }

    // -----------------------------------------------------------------------
    // API
    // -----------------------------------------------------------------------

    get disabled() {
        return this.hasAttribute("disabled");
    }

    set disabled(val) {
        if (val) {
            this.setAttribute("disabled", "disabled");
        } else {
            this.removeAttribute("disabled");
        }
    }

    get collapsed() {
        return this.hasAttribute("collapsed");
    }

    set collapsed(val) {
        this.applyCollapsed(val, /*deep*/ false);
    }

    applyCollapsed(collapsed, deep) {
        this.setCollapsedAttribute(collapsed, XUI.StockIcon,
                                   "plus-squared", "minus-squared");

        // ---
        
        let be = this.parentElement;
        if (be !== null && be.hasAttribute("data-be")) {
            let e = be.parentElement;
            if (e !== null && e.hasAttribute("data-e")) {
                let ag = this.findAttributeGroup(be);
                if (ag !== null) {
                    this.setHidden(ag, collapsed);
                }

                // ---

                let sibling = be.nextSibling;
                while (sibling !== null) {
                    if (sibling.nodeType === Node.ELEMENT_NODE &&
                        !sibling.hasAttribute("data-ae")) {
                        this.setHidden(sibling, collapsed);

                        if (deep) {
                            let collapser = this.findCollapser(sibling);
                            if (collapser !== null) {
                                collapser.applyCollapsed(collapsed, true);
                            }
                        }
                    }

                    sibling = sibling.nextSibling;
                }
            }
        }
    }

    setCollapsedAttribute(collapsed,
                          iconSet, expandIconName, collapseIconName) {
        if (collapsed) {
            if (!this.hasAttribute("collapsed")) {
                this._settingCollapsed = true;
                // JS bug? hasAttribute returns false for collapsed="".
                this.setAttribute("collapsed", "collapsed");
                this._settingCollapsed = false;
            }

            this._iconSpan.textContent = iconSet[expandIconName];
        } else {
            if (this.hasAttribute("collapsed")) {
                this._settingCollapsed = true;
                this.removeAttribute("collapsed");
                this._settingCollapsed = false;
            }

            this._iconSpan.textContent = iconSet[collapseIconName];
        }
    }
    
    setHidden(element, hide) {
        if (hide) {
            if (!element.classList.contains("xxe-collapsed")) {
                element.classList.add("xxe-collapsed");
            }
        } else {
            if (element.classList.contains("xxe-collapsed")) {
                element.classList.remove("xxe-collapsed");
            }
        }
    }

    findAttributeGroup(element) {
        let node = element.firstChild;
        while (node !== null) {
            if (node.nodeType === Node.ELEMENT_NODE &&
                node.classList.contains("xxe-ag")) {
                return node;
            }

            node = node.nextSibling;
        }

        return null;
    }

    findCollapser(element) {
        if (element !== null &&
            element.nodeType === Node.ELEMENT_NODE &&
            element.hasAttribute("data-e")) {
            let firstChild = element.firstChild;
            if (firstChild !== null &&
                firstChild.nodeType === Node.ELEMENT_NODE &&
                firstChild.hasAttribute("data-be")) {
                firstChild = firstChild.firstChild;
                if (firstChild !== null &&
                    firstChild.nodeType === Node.ELEMENT_NODE &&
                    firstChild.localName === "xxe-collapser") {
                    return firstChild;
                }
            }
        }

        return null;
    }
}

TreeCollapser.TEMPLATE = document.createElement("template");
TreeCollapser.TEMPLATE.innerHTML = `
<style>
.collapser {
    display: inline-block;
    width: 14px;
    /* Note that inheritable styles (e.g. font-style) are also inherited by 
       the Shadow DOM. */
    font-family: "xui-stock-icons";
    font-size: 12px;
    font-style: normal;
    font-weight: normal;
    text-decoration: none;
    color: gray;
    cursor: default;
}
</style>
<span class="collapser"></span>
`;
// The :host{display:inline} default is OK.

window.customElements.define("xxe-collapser", TreeCollapser);