Source: ui/Dropdown.js

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

export { Dropdown };

/**
 * Dropdown menu
 * 
 * @author rhess <robin.hess@awi.de>
 * @memberof vef.ui
 */
class Dropdown extends UiElement {

    classes = 'vef-dropdown';
    html = `
        <div class='vef-dropdown-header' tabindex="0">
        <div class='vef-dropdown-header-inner'>
            <span class="preset-title"></span>
            <i class='fas fa-chevron-down'></i>
        </div>
        </div>
        <div class='vef-dropdown-menu'>
            <div class='vef-dropdown-search'></div>
            <div class='vef-dropdown-content'></div>            
        </div>
    `;

    /**
     * options = {
     *   items: object (object with entries for dropdown),
     *   showDeselectItem: boolean
     *   deselectLabel: string
     * }
     * 
     * @param {HTMLElement | string} target 
     * @param {object} options 
     */
    constructor(target, options) {
        super(target, {
            "select": []
        });

        this.content_ = null;
        this.selected_ = null;
        this.searchbar_ = null;

        this.items_ = [];
        this.allItems_ = [];

        this.placeholder_ = (options && options.placeholder) ? options.placeholder : "select an item ...";
        this.deselectLabel_ = (options && (options.deselectLabel)) ? options.deselectLabel : "... deselect";
        this.showDeselectItem = (options && (typeof options.showDeselectItem == "boolean")) ? options.showDeselectItem : true;

        this.initElement_();

        if (options && options.items) this.setItems(options.items);
        if (options.search) this.initSearch_(options.searchPlaceholder);
    }

    /**
     * initialize the Ui element.
     * Called once in the constructor
     * 
     * @private
     */
    initElement_() {
        this.setHtml(this.html);
        this.setClass(this.classes);
        this.query(".preset-title").innerText = this.placeholder_;

        let open = false;
        const event = () => {
            if (!open) {
                this.setClass("open");
                if (this.searchbar_) this.searchbar_.focus();
                document.body.addEventListener("click", event);
                document.body.addEventListener("keydown", (e) => {
                    if (e.key === "Enter") event();
                });
            } else {
                this.removeClass("open");
                document.body.removeEventListener("click", event);
                document.body.addEventListener("keydown", (e) => {
                    if (e.key === "Enter") event();
                });
            }
            open = !open;
        }
        this.getElement().addEventListener("keydown", (e) => {
            if (e.key === "Enter") {
                e.stopPropagation();
                if (e.target.nodeName == "INPUT") {
                    return;
                } else {
                    event();
                }
            }
        });

        this.getElement().addEventListener("click", (e) => {
            e.stopPropagation();
            if (e.target.nodeName == "INPUT") {
                return;
            } else {
                event();
            }
        });

        this.content_ = this.query(".vef-dropdown-content");
    }

    /**
     * helper method to add an element to deselect
     * the current value from the dropdown
     * 
     * @private
     */
    addDeselectItem_() {
        if (this.showDeselectItem) {
            const deselectNodes = [...this.content_.childNodes].filter(el => el.textContent === this.deselectLabel_);

            if (deselectNodes.length === 0) {
                const element = this.createItemElement_(this.deselectLabel_, null, null);

                element.onclick = () => {
                    this.deselect();
                    this.fire("select", null);
                };

                this.content_.prepend(element);
            }
        }
    }

    /**
     * Removes the helper method set by addDeselectItem_()
     * 
     * @private
     */
    removeDeselectItem_() {
        if (this.showDeselectItem) {
            const deselectNodes = [...this.content_.childNodes].filter(el => el.textContent === this.deselectLabel_);
            deselectNodes.forEach((node) => {
                this.content_.removeChild(node)
            })

        }
    }

    /**
     * Helper method for creating the
     * HTMLElement of an item
     * 
     * @param {string} text 
     * @param {string} title 
     * @param {object} css 
     * @returns {HTMLElement} element
     */
    createItemElement_(label, title, css) {
        const element = document.createElement("div");
        element.setAttribute("tabindex", "0")
        element.classList.add("vef-dropdown-item");

        if (css) {
            for (let i in css) {
                element.style[i] = css[i];
            }
        }

        const text = document.createElement("div");
        text.classList.add("vef-dropdown-item-text");
        text.title = title;
        text.innerText = label;
        element.appendChild(text);

        return element;
    }

    /**
     * called when an item gets selected.
     * Sets the name in the Ui
     * 
     * @param {object} item
     * @private
     */
    select_(item) {
        if (!item && !this.selected_) return false;

        const title = this.query(".vef-dropdown-header .preset-title");

        if (item) {
            title.innerText = item.key;
            this.selected_ = item;
            this.addDeselectItem_();
        } else {
            title.innerText = this.placeholder_;
            this.selected_ = null;
        }

        return true;
    }

    /**
     * Set the available items in the dropdown
     * 
     * @param {object} items 
     */
    setItems(items) {
        this.allItems_ = items;
        this.setItems_(items);
    }

    /**
     * Internal method to set the visible items
     * 
     * @param {object} items 
     * @private
     */
    setItems_(items) {
        this.clear();
        if (this.selected_)
            this.addDeselectItem_();

        for (let i in items) {
            this.addItem(i, items[i]);
        }
    }

    /**
     * Add an item to the dropdown
     * 
     * @param {string} key
     * @param {*} item
     * @param {object} css (optional) styling for dropdown item
     */
    addItem(key, item, css) {
        const element = this.createItemElement_(key, item.title, css)
        const itemWrapper = {
            key: key,
            item: item
        };

        element.onclick = () => {
            this.select_(itemWrapper);
            this.fire("select", itemWrapper);
        };
        element.addEventListener("keydown", (e) => {
            if (e.key === "Enter") {
                this.select_(itemWrapper);
                this.fire("select", itemWrapper);
            }
        })

        this.content_.appendChild(element);
        this.items_.push(itemWrapper);
    }

    /**
     * Remove all items
     */
    clear() {
        this.content_.innerHTML = "";
        this.items_ = [];
    }

    /**
     * Get the currently selected color item
     */
    getSelectedItem() {
        return this.selected_;
    }

    /**
     * Set the dropdown menu to deselected.
     */
    deselect() {
        this.removeDeselectItem_()
        this.select_(null);
    }

    /**
     * Selet an item by the given key
     * 
     * @param {string} key
     */
    select(key) {
        for (let i = 0; i < this.items_.length; ++i) {
            const item = this.items_[i];
            if (item.key == key) {
                this.select_(item);
                return;
            }
        }
    }

    /**
     * Add search in titles if needed
     * 
     * @private
     */
    initSearch_(placeholder) {
        let search = this.query(".vef-dropdown-search")
        search.innerHTML = `<input spellcheck="false" placeholder="${(placeholder) ? placeholder : 'search items ...'}" class="query" type="text"/>`;

        const input = search.querySelector(".query");
        input.addEventListener("focus", (e) => {
            e.stopPropagation();
        });

        // init search events
        let searchTimeout = null;
        input.addEventListener("input", () => {
            clearTimeout(searchTimeout);
            searchTimeout = setTimeout(() => {
                this.search(input.value.toLowerCase().trim());
                searchTimeout = null;
            }, 750);
        });

        input.addEventListener("keydown", (e) => {
            if (e.key == "Enter") {
                clearTimeout(searchTimeout);
                this.search(input.value.toLowerCase().trim());
            }
        });

        this.searchbar_ = input;
    }

    /**
     * Search in titles and add to dropdown
     * @param {string} query
     */
    search(query) {
        /**
         * Computes the size of a text based on the font.
         * Source: Domi (2014, January 9). Calculate text width with JavaScript. Stackoverflow. https://stackoverflow.com/a/21015393.
         * @param {String} text - Text.
         * @param {String} font - CSS font, i.e., 'bold 14px "PT Sans", sans-serif'.
         * @returns {Number} Text width.
         */
        function _getTextWidth(text, font) {
            // re-usage of canvas object for better performance
            const canvas = _getTextWidth.canvas || (_getTextWidth.canvas = document.createElement("canvas"));
            const context = canvas.getContext("2d");
            context.font = font;
            const metrics = context.measureText(text);
            return metrics.width;
        }

        const font = 'bold ' + getComputedStyle(this.getElement()).font;
        const availableTextSpace = this.getElement().querySelector("input").clientWidth;

        const matchedObjectEntries = Object.entries(this.allItems_).filter(
            ([key, val]) => key.toLowerCase().includes(query)
        )

        const matchedObjectEntriesShortened = matchedObjectEntries.map(([key, value]) => {

            const queryPosition = key.toLowerCase().indexOf(query);

            let keyShortened;
            let offset = 0;
            const requiredTextSpace = Math.ceil(_getTextWidth(key, font));
            while (Math.ceil(_getTextWidth(keyShortened, font)) < availableTextSpace) {
                if (queryPosition > offset && requiredTextSpace > availableTextSpace) {
                    keyShortened = "..." + key.toLowerCase().substring(queryPosition - offset);
                    offset += 1
                }
                else break
            }

            return [keyShortened || key, value]
        })

        const filtered = Object.fromEntries(matchedObjectEntriesShortened);
        this.setItems_(filtered);
    }

}