Source: lib/core/events/bindEvents.js

/**
 * Checks if a given DOM element belongs to the component defined by root.
 * Deals with nested component boundaries and transcluded slots.
 * @param {Element} element
 * @param {Element} root
 * @returns {boolean}
 */
function belongsToComponent(element, root) {
    let current = element;
    let isTranscluded = false;
    while (current && current !== root) {
        if (current.nodeType === 1) {
            if (current.nodeName === 'SLOT' && current.hasAttribute && current.hasAttribute('data-avenx-transcluded')) {
                isTranscluded = true;
            } else if (current.hasAttribute && current.hasAttribute('data-avenx-comp')) {
                if (isTranscluded) {
                    isTranscluded = false;
                } else {
                    return false;
                }
            }
        }
        current = current.parentNode;
    }
    return !isTranscluded;
}

/**
 * Responsible for binding event listeners to DOM elements based on attributes.
 * Uses event delegation on the root element.
 */
export class EventBinder {
    /**
     * Stores bound events and handlers.
     * @type {WeakMap<Element, Map<string, Function>>}
     * @private
     */
    #boundEvents = new WeakMap();

    /**
     * Binds event listeners to all elements under the root that have attributes starting with '@'.
     * Uses event delegation on Element roots, falls back to direct binding on DocumentFragments.
     * @param {Element|DocumentFragment} root - The root element to bind events on.
     * @param {Object} dispatcher - The object responsible for executing the event handler.
     * @param {function(string, Event): void} dispatcher.execute - Method to execute the event.
     */
    bind(root, dispatcher) {
        if (!root) return;
        if (root.nodeType === 11) {
            this.#bindDirect(root, dispatcher);
        } else {
            this.#bindDelegated(root, dispatcher);
        }
    }

    /**
     * Removes all event listeners for the given root.
     * @param {Element|DocumentFragment} root
     */
    unbind(root) {
        if (!root) return;
        if (root.nodeType === 11) {
            this.#unbindDirect(root);
        } else {
            this.#unbindDelegated(root);
        }
    }

    #bindDelegated(root, dispatcher) {
        // Collect all unique event names from elements belonging to this component
        const eventNames = new Set();
        const traverse = (node) => {
            if (node.nodeType !== 1) return;
            
            if (node.attributes) {
                Array.from(node.attributes).forEach(attr => {
                    if (attr.name.startsWith('@')) {
                        eventNames.add(attr.name.substring(1));
                    }
                });
            }

            if (node.nodeName === 'SLOT' && node.hasAttribute && node.hasAttribute('data-avenx-transcluded')) {
                return;
            }
            if (node !== root && node.hasAttribute && node.hasAttribute('data-avenx-comp')) {
                // If it's a child component root, we might want its parent-defined event handlers,
                // but we do NOT traverse its children.
                return;
            }

            const children = node.childNodes || node.children;
            if (children) {
                for (let i = 0; i < children.length; i++) {
                    traverse(children[i]);
                }
            }
        };
        traverse(root);

        eventNames.forEach(eventName => {
            const existing = this.#boundEvents.get(root) || new Map();
            if (!existing.has(eventName)) {
                const handler = event => {
                    let current = (event && event.target) || root;
                    while (current) {
                        if (belongsToComponent(current, root)) {
                            let handlerExpression = null;
                            if (typeof current.getAttribute === 'function') {
                                handlerExpression = current.getAttribute('@' + eventName);
                            } else if (current.attributes) {
                                const matchedAttr = Array.from(current.attributes)
                                    .find(a => a.name === '@' + eventName);
                                handlerExpression = matchedAttr ? matchedAttr.value : null;
                            }
                            if (handlerExpression) {
                                dispatcher.execute(handlerExpression, event);
                            }
                        }
                        if (current === root) {
                            break;
                        }
                        current = current.parentNode;
                        if (event.cancelBubble) {
                            break;
                        }
                    }
                };

                root.addEventListener(eventName, handler);
                existing.set(eventName, handler);
                this.#boundEvents.set(root, existing);
            }
        });
    }

    #unbindDelegated(root) {
        const existing = this.#boundEvents.get(root);
        if (!existing) return;
        existing.forEach((handler, eventName) => {
            root.removeEventListener(eventName, handler);
        });
        this.#boundEvents.delete(root);
    }

    #bindDirect(root, dispatcher) {
        const elements = [];
        const traverse = (node) => {
            if (node.nodeType !== 1 && node.nodeType !== 11) return;
            if (node.nodeType === 1) {
                elements.push(node);
            }
            if (node.nodeName === 'SLOT' && node.hasAttribute && node.hasAttribute('data-avenx-transcluded')) {
                return;
            }
            const children = node.childNodes || node.children;
            if (children) {
                for (let i = 0; i < children.length; i++) {
                    traverse(children[i]);
                }
            }
        };
        traverse(root);

        elements.forEach(el => {
            if (el.nodeType !== 1) return;
            if (!el.attributes) return;
            Array.from(el.attributes).forEach(attr => {
                if (attr.name.startsWith('@')) {
                    const eventName = attr.name.substring(1);
                    const existing = this.#boundEvents.get(el) || new Map();

                    if (!existing.has(eventName)) {
                        const handler = event => {
                            let handlerExpression = null;
                            if (typeof el.getAttribute === 'function') {
                                handlerExpression = el.getAttribute('@' + eventName);
                            } else if (el.attributes) {
                                const matchedAttr = Array.from(el.attributes)
                                    .find(a => a.name === '@' + eventName);
                                handlerExpression = matchedAttr ? matchedAttr.value : null;
                            }
                            if (handlerExpression) {
                                dispatcher.execute(handlerExpression, event);
                            }
                        };
                        el.addEventListener(eventName, handler);
                        existing.set(eventName, handler);
                        this.#boundEvents.set(el, existing);
                    }
                }
            });
        });
    }

    #unbindDirect(root) {
        const elements = [];
        const traverse = (node) => {
            if (node.nodeType !== 1 && node.nodeType !== 11) return;
            if (node.nodeType === 1) {
                elements.push(node);
            }
            if (node.nodeName === 'SLOT' && node.hasAttribute && node.hasAttribute('data-avenx-transcluded')) {
                return;
            }
            const children = node.childNodes || node.children;
            if (children) {
                for (let i = 0; i < children.length; i++) {
                    traverse(children[i]);
                }
            }
        };
        traverse(root);

        elements.forEach(el => {
            const existing = this.#boundEvents.get(el);
            if (!existing) return;
            existing.forEach((handler, eventName) => {
                el.removeEventListener(eventName, handler);
            });
            this.#boundEvents.delete(el);
        });
    }
}