Source: ui/Tree.js

import { UiElement } from "./UiElement.js";
import "./Tree.css";

/**
 * Generic Tree Class to display hierarchical structures
 * 
 * item = {
 *   title: string,
 *   icon: [string],
 *   active: boolean,
 *   expanded: boolean
 * }
 * 
 * structure = {
 *   "#": [children_ids],
 *   "parent_id": [children_ids],
 *  ...
 * }
 * 
 * items = { "id": item, ... }
 * 
 * @memberof vef.ui
 */
class Tree extends UiElement {

    classes = ["vef-tree"];
    itemTemplate = `
        <div class="item-inner">
            <span class="fa-stack item-checkbox">
                <i class="far fa-square fa-stack-1x"></i>
                <i class="fas fa-check fa-stack-1x"></i>
            </span>
            <i class="item-icon"></i>
            <span class="item-title"></span>
        </div>
    `;

    /**
     * @param {HTMLElement | string} target 
     * @param {object} items 
     * @param {object} structure 
     */
    constructor(target, items, structure) {
        super(target, {
            "select": [],
            "deselect": []
        });

        this.items_ = {};

        this.setClass(this.classes);
        this.getElement().dataset.id = "#";

        if (items && structure) this.addStructuredItems(items, structure);
    }

    /**
     * Internal method for deselecting items
     * @param {HTMLElement} element 
     * @private
     */
    deselect_(element) {
        if (element.classList.contains("selected")) {
            element.classList.remove("selected");
            const item = this.items_[element.dataset.id];
            item.item.active = false;
            this.fire("deselect", item.item);
        }
        element.classList.remove("partly");
    }

    /**
     * Internal method for selecting items
     * @param {HTMLElement} element
     * @param {boolean} partly 
     * @private
     */
    select_(element, partly) {
        if (!element.classList.contains("selected")) {
            element.classList.add("selected");
            const item = this.items_[element.dataset.id];
            item.item.active = true;
            this.fire("select", item.item);
        }
        (partly)
            ? element.classList.add("partly")
            : element.classList.remove("partly");
    }

    /**
     * Internal method for automatically selecting or deselecting
     * the parent based on the children
     * 
     * @param {HTMLElement} element 
     * @private
     */
    toggleParent_(element) {
        const parent = element.parentElement;
        if (!parent || !parent.classList.contains("tree-item")) return;

        const selectedChildren = parent.querySelectorAll(":scope > .tree-item.selected");

        if (selectedChildren.length > 0) {
            const allChildren = parent.querySelectorAll(":scope > .tree-item");
            this.select_(parent, (selectedChildren.length < allChildren.length));
        } else {
            this.deselect_(parent);
        }

        this.toggleParent_(parent);
    }

    /**
     * Select all items
     */
    selectAll() {
        const children = this.queryAll(".tree-item");
        children.forEach(child => this.select_(child));
    }

    /**
     * Deselect all items
     */
    deselectAll() {
        const children = this.queryAll(".tree-item");
        children.forEach(child => this.deselect_(child));
    }

    /**
     * select an item
     * 
     * @param {string} id 
     */
    select(id) {
        if (!(id in this.items_)) return;
        const element = this.items_[id].element;
        const children = element.querySelectorAll(".tree-item");
        children.forEach(child => this.select_(child));
        this.select_(element);
        this.toggleParent_(element);
    }

    /**
     * deselect an item
     * 
     * @param {*} id 
     */
    deselect(id) {
        if (!(id in this.items_)) return;
        const element = this.items_[id].element;
        const children = element.querySelectorAll(".tree-item");
        children.forEach(child => this.deselect_(child));
        this.deselect_(element);
        this.toggleParent_(element);
    }

    /**
     * swap the state of an item to the opposite.
     * partly selected items will also be deselected.
     * 
     * @param {string} id 
     */
    toggle(id) {
        if (!(id in this.items_)) return;
        (this.items_[id].element.classList.contains("selected"))
            ? this.deselect(id)
            : this.select(id);
    }

    /**
     * @param {string} parent 
     * @param {string} id 
     * @param {object} item 
     */
    addFolder(parent, id, item) {
        this.addItem(parent, id, item);
    }

    /**
     * @param {string} parent 
     * @param {string} id 
     * @param {object} item 
     */
    addItem(parent, id, item) {
        const parentElement = (parent == "#")
            ? this.getElement()
            : this.query(`.tree-item[data-id='${parent}']`);
        if (!parentElement) return null;

        const element = document.createElement("div");
        element.classList.add("tree-item");
        element.dataset.id = id;
        element.innerHTML = this.itemTemplate;
        element.querySelector(".item-title").innerText = item.title;
        element.querySelector(".item-title").title = item.title;
        element.querySelector(".item-icon").classList.add(...item.icon.split(" "));

        element.querySelector(".item-inner").addEventListener("click", e => {
            this.toggle(id);
        })

        parentElement.appendChild(element);

        this.items_[id] = {
            element: element,
            item: item
        };

        if (item.active) this.select(id);
    }

    /**
     * @param {object} items 
     * @param {object} structure 
     * @param {string} root element to append item tree to (default = "#")
     */
    addStructuredItems(items, structure, root) {
        // resolve folders recursively
        const resolveFolder = parent => {
            if ((parent in structure) && ((parent in this.items_) || (parent == "#"))) {
                structure[parent].forEach(child => {
                    if (!(child in items)) return;
                    if (child in structure) {
                        this.addFolder(parent, child, items[child])
                        resolveFolder(child);
                    } else {
                        this.addItem(parent, child, items[child])
                    }
                });
            }
        }

        resolveFolder(root || "#");
    }

    /**
     * @param {boolean} selected only get selected layers
     * @returns {object} structure
     */
    getStructure(selected = false) {
        const structure = {};
        const items = this.queryAll(".tree-item");

        items.forEach(item => {
            if (selected && !(item.classList.contains("selected"))) return;
            const parent = item.parentElement.dataset.id;
            if (!(parent in structure)) structure[parent] = [];
            structure[parent].push(item.dataset.id);
        });

        return structure;
    }

    /**
     * @param {boolean} selected only get selected layers
     * @returns {object} items
     */
    getItems(selected = false) {
        const items = {}
        const nodes = this.queryAll((selected) ? ".tree-item.selected" : ".tree-item");

        nodes.forEach(node => {
            const id = node.dataset.id;
            items[id] = this.items_[id].item;
        });

        return items;
    }

    /**
     * Clear all items from tree
     */
    clear() {
        this.getElement().innerHTML = "";
        this.items_ = {};
    }

}

export { Tree };