Source: xxe/view/StyledCollapser.js

/**
 * A collapser/expander button for a styled element rendered as a block.
 */
class StyledCollapser extends TreeCollapser {
    constructor() {
        super();
        this._onClickCollapsedContent = this.onClickCollapsedContent.bind(this);
        this.unconfigure();
    }

    onClickCollapsedContent(event) {
        event.preventDefault();
        event.stopPropagation();
        if (!this.disabled && event.detail === 1) {
            this.setCollapsed(false, /*deep*/ false);
        }
    }
    
    getTemplate() {
        return StyledCollapser.TEMPLATE;
    }
    
    unconfigure() {
        this._collapsibleView = null;
        this._notCollapsibleHead = 0;
        this._notCollapsibleFoot = 0;
        this._collapsedContentAlign = null;
        this._collapsedContent = null;
        this._collapsedIconName = "collapsed-right";
        this._expandedIconName = "expanded-down";
    }
    
    configure(options) {
        this.unconfigure();

        let collapsibleInfo = null;
        let ancestor = this.parentElement;
        while (ancestor !== null) {
            collapsibleInfo = ancestor.getAttribute("data-collapsible");
            if (collapsibleInfo !== null) {
                this._collapsibleView = ancestor;
                break;
            }
            ancestor = ancestor.parentElement;
        }

        let collapsibleUID = null;
        if (this._collapsibleView !== null &&
            (collapsibleUID =
             NodeView.uidIfElementView(this._collapsibleView)) === null) {
            this._collapsibleView = null;
            collapsibleInfo = null;
        }
        
        let initiallyCollapsed = false;
        if (collapsibleInfo !== null) {
            let infoList = collapsibleInfo.split(';', 4);
            if (infoList.length >= 3) {
                initiallyCollapsed = (infoList[0] === "true");
                let notCollapsibleHead = parseInt(infoList[1]);
                if (!isNaN(notCollapsibleHead) && notCollapsibleHead >= 0) {
                    this._notCollapsibleHead = notCollapsibleHead;
                }
                let notCollapsibleFoot = parseInt(infoList[2]);
                if (!isNaN(notCollapsibleFoot) && notCollapsibleFoot >= 0) {
                    this._notCollapsibleFoot = notCollapsibleFoot;
                }
                
                if (infoList.length >= 4) {
                    this._collapsedContentAlign = infoList[3];
                    if (this._collapsedContentAlign.length === 0) {
                        this._collapsedContentAlign = null;
                    }

                    // After collapsedContentAlign, it's collapsedContent, some
                    // HTML which may contain ';'.
                    let pos = -1;
                    for (let k = 0; k <= 3; ++k) {
                        pos = collapsibleInfo.indexOf(';', pos+1);
                        if (pos < 0) {
                            // Should not happen.
                            break;
                        }
                    }
                    if (pos > 0 && pos+1 < collapsibleInfo.length) {
                        this._collapsedContent =
                            collapsibleInfo.substring(pos+1);
                    }
                }
            }
        }
        
        // ---
        
        if (options !== null) {
            let optionList = options.split(';');
            if (optionList.length >= 2) {
                let collapsedIconName = optionList[0];
                let expandedIconName = optionList[1];
                
                if (collapsedIconName.length > 0 &&
                    (collapsedIconName in CSSIcon)) {
                    this._collapsedIconName = collapsedIconName;
                }
                if (expandedIconName.length > 0 &&
                    (expandedIconName in CSSIcon)) {
                    this._expandedIconName = expandedIconName;
                }
            }
        }

        // ---
        
        if (this._collapsibleView === null) {
            // Show a grayed icon.
            this._iconSpan.textContent = CSSIcon[this._expandedIconName];
            this.disabled = true;
        } else {
            this.setAttribute("for", collapsibleUID);
            if (initiallyCollapsed) {
                this._settingCollapsed = true;
                this.setAttribute("collapsed", "collapsed");
                this._settingCollapsed = false;
            }
        }
    }
    
    connectedCallback() {
        this.configure(this.getAttribute("options"));
        
        super.connectedCallback();
    }

    applyCollapsed(collapsed, deep) {
        if (this._collapsibleView !== null) {
            StyledCollapser.doApplyCollapsed(this._collapsibleView, collapsed,
                                             deep);
        }
    }
    
    static doApplyCollapsed(tree, collapsed, deep) {
        let uid = null;
        if (tree.hasAttribute("data-collapsible") &&
            (uid = NodeView.uidIfElementView(tree)) !== null) {
            let collapsers =
                tree.querySelectorAll(`xxe-collapser2[for="${uid}"]`);
            
            const count = collapsers.length;
            if (count > 0) {
                const noCollapsibleChildren =
                      (collapsers[0].getCollapsibleChildren(tree) === null);
                
                for (let i = 0; i < count; ++i) {
                    let collapser = collapsers[i];

                    let expandIcon = collapser._collapsedIconName;
                    let collapseIcon = collapser._expandedIconName;
                    if (noCollapsibleChildren) {
                        expandIcon = collapseIcon = "pop-se";
                    }
                    collapser.setCollapsedAttribute(collapsed, CSSIcon,
                                                    expandIcon, collapseIcon);
                }

                collapsers[0].collapseView(tree, collapsed);
            }
            // Otherwise, no collapsers yet, which may happen
            // (e.g. table without a caption yet).
        }
        // Otherwise, tree is not a collapsible element view.
        
        // ---
        
        if (deep) {
            let child = tree.firstElementChild;
            while (child !== null) {
                StyledCollapser.doApplyCollapsed(child, collapsed, true);
                child = child.nextElementSibling;
            }
        }
    }

    collapseView(collapsibleView, collapse) {
        if (collapse) {
            if (!collapsibleView.hasAttribute("data-collapsed")) {
                collapsibleView.setAttribute("data-collapsed", "true");
                this.doCollapseView(collapsibleView, true);
            }
        } else {
            if (collapsibleView.hasAttribute("data-collapsed")) {
                collapsibleView.removeAttribute("data-collapsed");
                this.doCollapseView(collapsibleView, false);
            }
        }
    }
    
    doCollapseView(collapsibleView, collapse) {
        let collapsibleChildren = this.getCollapsibleChildren(collapsibleView);
        if (collapsibleChildren === null) {
            return;
        }
        
        for (let child of collapsibleChildren) {
            let style = child.getAttribute("style");
            if (style === null) {
                style = "";
            }

            if (collapse) {
                style += ";display:none";
            } else {
                if (style.endsWith(";display:none")) {
                    style = style.substring(0, style.length - 13);
                }
            }

            if (style.length === 0) {
                child.removeAttribute("style");
            } else {
                child.setAttribute("style", style);
            }
        }

        if (this._collapsedContent !== null) {
            let lastCollapsibleChild =
                collapsibleChildren[collapsibleChildren.length-1];
            if (collapse) {
                let placeholder = document.createElement("div");
                placeholder.innerHTML = this._collapsedContent;
                placeholder.onclick = this._onClickCollapsedContent;
                
                const clsList = placeholder.classList;
                clsList.add("xxe-collapsed-content");
                for (let cls of lastCollapsibleChild.classList.values()) {
                    if (cls.startsWith("xxe-s")) {
                        clsList.add(cls);
                    }
                }
                
                if (this._collapsedContentAlign !== null) {
                    // May be non-effective depending on the collapsedContent
                    // HTML.
                    placeholder.style.textAlign = this._collapsedContentAlign;
                }
                
                lastCollapsibleChild.parentNode.insertBefore(
                    placeholder, lastCollapsibleChild.nextElementSibling);
            } else {
                let placeholder = lastCollapsibleChild.nextElementSibling;
                if (placeholder !== null &&
                    placeholder.classList.contains("xxe-collapsed-content")) {
                    placeholder.parentNode.removeChild(placeholder);
                }
            }
        } 
    }

    getCollapsibleChildren(collapsibleView) {
        // Generated content being display:block or display:marker before or
        // after actual content is NOT considered to be collapsible children.
        //
        // Generated content contained inside actual content is considered
        // to be collapsible children.
        //
        // This seems to be the behavior of desktop app and how
        // not-collapsible-head, not-collapsible-foot are interpreted.
        
        const collapsibleContent = NodeView.getContent(collapsibleView);
        
        let collapsibleChildren = [];
        let child = collapsibleContent.firstElementChild;
        while (child !== null) {
            if (this._collapsedContent !== null &&
                child.classList.contains("xxe-collapsed-content")) {
                // Always added after last collapsible child.
                child = child.nextElementSibling;
                continue;
            }
            
            collapsibleChildren.push(child);
            child = child.nextElementSibling;
        }
        
        const start = this._notCollapsibleHead;
        const end = collapsibleChildren.length - this._notCollapsibleFoot;
        if (end <= start) {
            return null;
        } else {
            if (end < collapsibleChildren.length) {
                collapsibleChildren.splice(end);
            }
            if (start > 0) {
                collapsibleChildren.splice(0, start);
            }
            return ((collapsibleChildren.length === 0)?
                    null : collapsibleChildren);
        }
    }
}

StyledCollapser.TEMPLATE = document.createElement("template");
StyledCollapser.TEMPLATE.innerHTML = `
<style>
.collapser {
    font-family: "xxe-css-icons";
    font-size: 14px;
    font-style: normal;
    font-weight: normal;
    text-decoration: none;
    /*color is inherited*/
    vertical-align: 2px;
    cursor: default;
}
:host([disabled]) {
    color: #A6A6A6;
}
</style>
<span class="collapser"></span>
`;

window.customElements.define("xxe-collapser2", StyledCollapser);