Source: controller.js

import { kebabToCamel, parseBoolean, parseDuration } from "./util.js";

/**
 * @class
 * @name Controller
 * @namespace Controller
 */
class Controller extends HTMLElement {
    /**
     * @static
     * @name observedAttributes
     * @type String[]
     * @memberof! Controller
     * @description These are the attributes to watch for and react to changes
     * This is handled by `attributeChangedCallback()`
     * The default implementation will call `set{AttributeName}(oldValue, newValue)`
     */
    static observedAttributes = [];
    static tag = undefined;

    static withTag(tag) {
        return class extends this {
            static tag = tag;
        };
    }

    /**
     * Create a new custom controller element
     * @param {*} args
     */
    constructor(args) {
        super();
        this.debug({ msg: "Constructing binder element" });

        // Store for internal data
        this._internal = {};

        this.root = this;
        this.args = args || {};
        this.data = {};

        // Keep track of all attached events
        this._events = [];

        // Add the data-controller attribute to the element
        this.setAttribute("data-controller", this.localName);
        this.emit("binder:created", {});
    }

    /**
     * Work in progress
     * If the element has a `<template>` with a `:use-shadow` attribute, it will be used to create a shadow root
     * When using the shadow DOM the `bind()` call fails
     */
    handleShadow() {
        // If the component has a template then we will clone it and render that to the DOM
        // If the template has the :use-shadow attribute then we will clone it onto the shadow DOM
        // This allows isolating the component from the regular DOM (including styles)
        this.template = this.querySelector("template");

        // The template is optional, if not specified then we will do everything directly on the DOM within the component
        if (this.template) {
            this.content = this.template.content.cloneNode(true);

            // Only use the shadowDOM when specified
            if (this.template.hasAttribute(":use-shadow")) {
                this.debug({ msg: "Initialising shadow DOM" });
                this.attachShadow({ mode: "open" }).appendChild(this.content.cloneNode(true));

                this.root = this.shadowRoot;
                this.hasShadow = true;
            } else {
                this.appendChild(this.content.cloneNode(true));
                this.hasShadow = false;
            }
        }
    }

    /**
     * @method
     * @name connectCallback
     * @memberof! Controller
     * @description Called when element is rendered in the DOM
     * See: {@link https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks}
     */
    async connectedCallback() {
        if (!this.isConnected) return;

        this.handleShadow();

        // Bind the element to this instance
        this.bind();

        // Init the element
        if ("renderOnInit" in this.args) {
            this.renderOnInit = parseBoolean(this.args.renderOnInit);
        } else {
            this.renderOnInit = true;
        }
        await this.init(this.args);

        // Render
        if (this.args.autoRender) {
            const interval = parseDuration(this.args.autoRender);
            this.setAutoRender(interval);
        }

        if (this.renderOnInit) this.render();

        this.emit("binder:connected", {});
    }

    /**
     * Runs when the element in removed from the DOM
     */
    disconnectedCallback() {
        this._events.forEach(e => e.el.removeEventListener(e.eventType, e.event));
        this._events = [];

        this.emit("binder:disconnected", { detail: { from: this } });
    }

    /**
     * @method
     * @name attributeChangedCallback
     * @memberof! Controller
     * @description The default implementation of attributeChangedCallback
     * See: {@link https://developers.google.com/web/fundamentals/web-components/customelements#attrchanges}
     * We will convert the attribute name to camel case, strip out the leading `data-` or `aria-` parts and call `set{AttributeName}(oldValue, newValue)` (if it exists)
     * Eg. A change to `data-disabled` will call `setDisabled(oldValue, newValue)`
     * @param {string} name The name of the attribute that changed
     * @param {string} oldValue The old value of the attribute
     * @param {string} newValue The new value of the attribute
     */
    attributeChangedCallback(name, oldValue, newValue) {
        let handler = name.replace(/^data-/, "");
        handler = handler.replace(/^aria-/, "");
        handler = kebabToCamel(handler);
        handler = `set${handler.charAt(0).toUpperCase()}${handler.slice(1)}`;

        if (handler in this && typeof this[handler] === "function") {
            this[handler](oldValue, newValue);
        }
    }

    /**
     * @method
     * @name Controller#emit
     * @memberof! Controller
     * @description Emit a new event from this controller
     * @param {string} eventName The name of the event, automatically prefixed with `${this.localName}:`
     * @param {object} detail Optional object that is passed in the event under the key `detail`
     * @param {object} config Optional configuration object that can be passed to `new CustomEvent()`
     */
    emit(eventName, detail = {}, config = {}) {
        this.dispatchEvent(
            new CustomEvent(eventName, {
                bubbles: true,
                cancelable: true,
                composed: true,
                detail: detail,
                ...config,
            })
        );
    }

    /**
     * @method
     * @name listenFor
     * @memberof! Controller
     * @description Listens for an event to be fired from a child element
     * @param {Element} target The element to listen for events from, use `window` to listen for global events
     * @param {string} eventName The name of the event to listen for
     * @param {function} callback The callback to call when the event is fired
     */
    listenFor(target, eventName, callback) {
        target.addEventListener(eventName, e => callback(e));
    }

    /**
     * @method
     * @name bind
     * @memberof! Controller
     * @description Initializes the controller instance
     * Can be called manaually when the child elements change to force refreshing the controller state
     * eg. re-attach events etc...
     */
    bind() {
        // We only want to configure the arguments on the first bind()
        if (!this._internal.bound) this.#bindArgs();

        this.#bindElements();
        this.#bindEvents();

        this._internal.bound = true;
    }

    /**
     * @method
     * @name setAutoRender
     * @memberof! Controller
     * @description Sets an interval to auto call `this.render()`
     * Overwrites previously set render intervals
     * @param {*} interval Duration in milliseconds
     */
    setAutoRender(interval) {
        if (interval === undefined) {
            console.error(`[${this.localName}] Undefined interval passed to setAutoRender`);
            return;
        }

        if (this._internal.autoRenderInterval) {
            window.clearInterval(this._internal.autoRenderInterval);
        }

        this._internal.autoRenderInterval = window.setInterval(() => this.render(), interval);
    }

    /**
     * @method
     * @name init
     * @memberof! Controller
     * @description Called during the `connectedCallback()` (when an element is created in the DOM)
     * Expected to be overridden
     * @param {*} args
     */
    async init(_args) {}

    /**
     * @method
     * @name render
     * @memberof! Controller
     * @param {Element} rootNode The root node to search from
     * @description Re-renders everything with the :render attribute
     */
    async render(rootNode = null) {
        if (!rootNode) rootNode = this;

        this.#findRenderableElements(rootNode).forEach(el => {
            // Store the original template as an attribute on the element on first render
            let template = el.getAttribute("_template");
            if (!template) {
                template = el.innerText;
                el.setAttribute("_template", template);
            }

            // If the element has the attribute with .eval then eval the template
            // This should be used sparingly and only when the content is trusted
            const evalMode = el.hasAttribute(":render.eval");

            // TODO: Make the replacer syntax configurable
            let replacerRegex = /\{(.*?)\}/g; // Find template vars, eg {var}

            template.replace(replacerRegex, (replacer, key) => {
                if (evalMode) {
                    const fn = new Function(`return ${key}`);
                    template = template.replace(replacer, fn.call(this));
                } else {
                    // If not in `evalMode` then we do an eval-like replacement
                    // We will dig into the controller instance and replace in the variables
                    // This handles dot notation and array notation
                    let pos = null;

                    // Split on dots and brackets and strip out any quotes
                    key.split(/[.[\]]/)
                        .filter(item => !!item)
                        .forEach(part => {
                            part = part.replace(/["']/g, ""); // Strip out square brackets
                            part = part.replace(/\(\)/g, ""); // Strip out function parens

                            if (pos == null && part === "this") {
                                pos = this;
                                return;
                            }

                            if (pos && part in pos) {
                                pos = pos[part];
                            } else {
                                pos = null;
                                return;
                            }
                        });

                    if (pos == null) pos = "";
                    if (typeof pos === "function") pos = pos.call(this);
                    template = template.replace(replacer, pos.toString() || "");
                }
            });

            // TODO: This may be innefecient
            el.innerHTML = template;
        });

        this.emit("binder:render", {});
    }

    /**
     * @method
     * @private
     * @name findRenderableElements
     * @memberof! Controller
     * @param {Element} rootNode The root node to search from
     * @description Find all elements on the controller which have :render attributes
     * :render is a special action that let's the controller know to render this elements content when the render() method is called
     */
    #findRenderableElements(rootNode = null) {
        if (!rootNode) rootNode = this;
        return [...rootNode.querySelectorAll("[\\:render]"), ...rootNode.querySelectorAll("[\\:render\\.eval]")].filter(el => this.belongsToController(el));
    }

    /**
     * @method
     * @private
     * @name bindArgs
     * @memberof! Controller
     * @description Bind all attributes on the controller tag into the instance under `this`
     * Converts kebab-case to camelCase
     * EG. <controller :some-arg="150" /> will set `this.args.someArg = 150`
     */
    #bindArgs() {
        this.args = {};

        this.getAttributeNames().forEach(attr => {
            const value = this.getAttribute(attr);
            const key = kebabToCamel(attr).replace(":", "");
            this.args[key] = value;
        });
    }

    /**
     * @method
     * @private
     * @name bindElements
     * @memberof! Controller
     * @description Bind any elements with a `:bind` attribute to the controller under `this.binds`
     */
    #bindElements() {
        this.binds = {};

        const boundElements = this.querySelectorAll("[\\:bind]");
        boundElements.forEach(el => {
            if (this.belongsToController(el)) {
                const key = el.getAttribute(":bind");

                if (Object.hasOwn(this.binds, key)) {
                    if (Array.isArray(this.binds[key])) {
                        this.binds[key].push(el);
                    } else {
                        this.binds[key] = [this.binds[key], el];
                    }
                } else {
                    this.binds[key] = el;
                }
            }
        });
    }

    /**
     * @method
     * @private
     * @name bindEvents
     * @memberof! Controller
     * @description Finds all events within a controller element
     * Events are in the format `@{eventType}={method}"`
     * EG. @click="handleClick"
     *
     * The attribute key can also end with a combination of modifiers:
     * - `.prevent`: Automatically calls `event.preventDefault()`
     * - `.stop`: Automatically calls `event.stopPropagation()`
     * - `.eval`: Will evaluate the attribute value
     */
    #bindEvents() {
        // We need to delete all events and before binding
        // Otherwise we would end up with duplicate events upon muliple bind() calls
        this._events.forEach(e => e.el.removeEventListener(e.eventType, e.event));
        this._events = [];

        const bindEvent = async (el, eventType, modifiers) => {
            let attributeName = `@${eventType}`;
            if (modifiers.length) attributeName += `.${modifiers.join(".")}`;

            const value = el.getAttribute(attributeName);
            const action = value.replace("this.", "").replace("()", "");

            const callable = async event => {
                if (modifiers.includes("prevent")) event.preventDefault();
                if (modifiers.includes("stop")) event.stopPropagation();

                if (modifiers.includes("eval")) {
                    const fn = new Function("e", `${value}`);
                    fn.call(this, event);
                } else {
                    try {
                        if (action === "render") {
                            // Render doesn't take an event
                            await this[action].call(this);
                        } else {
                            await this[action].call(this, event);
                        }
                    } catch (e) {
                        console.error(`Failed to call '${action}' to handle '${event.type}' event on tag '${this.localName}'`, e);
                    }
                }

                if (modifiers.includes("render")) this.render();
            };

            el.addEventListener(eventType, callable);

            this._events.push({
                el: el,
                event: callable,
                eventType: eventType,
            });
        };

        // Go through all nodes which have events on them
        // eg. nodes which have any attribute starting with `@`
        for (let node of this.#findEventNodes()) {
            if (!this.belongsToController(node)) continue;
            this.debug({ msg: "Attaching event listeners", source: node });

            for (let attr of node.getAttributeNames()) {
                if (!attr.startsWith("@")) continue;

                let [event, ...modifiers] = attr.replace("@", "").split(".");

                bindEvent(node, event, modifiers);
            }
        }
    }

    /**
     * @method
     * @private
     * @name findEventNodes
     * @memberof! Controller
     * @description Generator returning nodes which have events on them
     * We do things a little differently depending on whether we are using the shadow DOM or not
     * If using the light DOM we use `document.evaluate` with an xpath expression
     * If using the shadow DOM we need to manually iterate through all nodes, which is slower
     */
    *#findEventNodes() {
        // TODO: Actually benchmark this to confirm if it's slower
        if (this.hasShadow) {
            const allNodes = this.root.querySelectorAll("*");
            for (let node of allNodes) {
                if (node.getAttributeNames().filter(attr => attr.startsWith("@")).length > 0) {
                    yield node;
                }
            }
        } else {
            const nodesWithEvents = document.evaluate('.//*[@*[starts-with(name(), "@")]]', this.root);
            let eventNode = nodesWithEvents.iterateNext();
            while (eventNode) {
                yield eventNode;
                eventNode = nodesWithEvents.iterateNext();
            }
        }
    }

    /**
     * @method
     * @private
     * @name getElementType
     * @memberof! Controller
     * @description Return the type of an element
     * @param {Element} el The DOM element to check
     * @returns {String} The element type, e.g. 'input|text'
     */
    #getElementType(el) {
        if (el.tagName.toLowerCase() === "input") {
            return [el.tagName, el.type].map(item => item.toLowerCase()).join("|");
        }
        return el.tagName.toLowerCase();
    }

    /**
     * @method
     * @private
     * @name belongsToController
     * @memberof! Controller
     * @description Return true if the given element belongs to this controller
     * @param {Element} el The controller root DOM element
     * @returns {Boolean} True if the element belongs to the controller
     */
    belongsToController(el) {
        // Controllers don't belong to themselves, go up a level to find their parent
        if (el.hasAttribute("data-controller")) el = el.parentElement;

        const closestController = el.closest("[data-controller]");
        return closestController === this;
    }

    /**
     * Helper debug function
     * Only enabled when
     *  - `window.__BINDER_DEBUG__` is set to `true`
     *  - `window.__BINDER_DEBUG__` is an array on this controllers `localName` is present
     * @param {Object} obj The data to log
     */
    debug(obj) {
        let shouldLog = false;

        if (window.__BINDER_DEBUG__ === true) shouldLog = true;
        if (Array.isArray(window.__BINDER_DEBUG__) && window.__BINDER_DEBUG__.includes(this.localName)) shouldLog = true;

        if (shouldLog) {
            obj.controller = this;
            console.debug(obj);
        }
    }
}

export { Controller };