DOMUtils.js

/**
 * DOMUtils DOM Helper Utilities
 * Native DOM API wrappers to replace jQuery functionality
 */
const DOMUtils = {
    // Radio button groups storage (moved from global scope)
    RadioButtons: {
        groups: {}
    },

    // Track event handlers to ensure only one handler per event type per element
    _eventHandlers: new WeakMap(),

    /**
     * Internal helper to resolve element from string or element
     * @param {string|HTMLElement} elementOrId - Element or ID string
     * @returns {HTMLElement|null}
     */
    _resolveElement: (elementOrId) => {
        if (typeof elementOrId === 'string') {
            const element = DOMUtils.getElement(elementOrId);
            if (!element) {
                console.log(`DOMUtils: Element with id "${elementOrId}" not found`);
            }
            return element;
        }
        return elementOrId;
    },

    /**
     * Get element by ID (handles optional # prefix)
     * @param {string} id - Element ID (with or without #)
     * @returns {HTMLElement|null}
     */
    getElement: (id) => document.getElementById(id.replace(/^#/, '')),

    /**
     * Query single element
     * @param {string} selector - CSS selector
     * @param {Element} parent - Parent element (default: document)
     * @returns {Element|null}
     */
    query: (selector, parent = document) => parent.querySelector(selector),

    /**
     * Query all matching elements
     * @param {string} selector - CSS selector
     * @param {Element} parent - Parent element (default: document)
     * @returns {NodeList}
     */
    queryAll: (selector, parent = document) => parent.querySelectorAll(selector),

    /**
     * Show an element
     * @param {HTMLElement|string} el - Element or element ID
     */
    show: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.removeAttribute('hidden');
            // Restore original display if we stored it, otherwise clear inline style
            const originalDisplay = el.dataset.originalDisplay;
            if (originalDisplay !== undefined) {
                el.style.display = originalDisplay;
            } else {
                // Temporarily clear inline display to check what CSS would apply
                const currentInlineDisplay = el.style.display;
                el.style.display = '';

                // Get the computed style after clearing inline display
                const computedDisplay = getComputedStyle(el).display;

                // If CSS (not inline) is making it 'none', we need to override
                if (computedDisplay === 'none') {
                    // Try to determine the appropriate display value
                    // 1. Check if element has inline-block children (like radio buttons)
                    const hasInlineBlockChild = el.querySelector('[style*="inline-block"]') !== null;
                    // 2. Check for common inline elements
                    const isInlineElement = ['SPAN', 'A', 'LABEL', 'INPUT', 'BUTTON'].includes(el.tagName);

                    if (hasInlineBlockChild || isInlineElement) {
                        el.style.display = 'inline-block';
                    } else {
                        el.style.display = 'block';
                    }
                } else if (currentInlineDisplay && currentInlineDisplay !== 'none') {
                    // If there was a previous inline display value, restore it
                    el.style.display = currentInlineDisplay;
                }
                // Otherwise leave it cleared - CSS will handle it
            }
            el.style.visibility = 'visible';
        }
    },

    /**
     * Hide an element
     * @param {HTMLElement|string} el - Element or element ID
     */
    hide: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            // Store original display before hiding (only if currently visible)
            const computedDisplay = getComputedStyle(el).display;
            if (computedDisplay !== 'none') {
                el.dataset.originalDisplay = computedDisplay;
            }
            el.setAttribute('hidden', '');
            el.style.display = 'none';
            el.style.visibility = 'hidden';
        }
    },

    /**
     * Check if element is hidden
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {boolean}
     */
    isHidden: (el) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return true;
        return el.offsetParent === null ||
               getComputedStyle(el).display === 'none' ||
               getComputedStyle(el).visibility === 'hidden';
    },

    /**
     * Check if element is visible
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {boolean}
     */
    isVisible: (el) => !DOMUtils.isHidden(el),

    /**
     * Set CSS property
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} prop - CSS property name
     * @param {string} val - CSS value
     */
    css: (el, prop, val) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.style[prop] = val;
        }
    },

    /**
     * Get or set attribute
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} name - Attribute name
     * @param {string} [val] - Value to set (omit to get)
     * @returns {string|null|undefined}
     */
    attr: (el, name, val) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return undefined;
        if (val === undefined) {
            return el.getAttribute(name);
        }
        el.setAttribute(name, val);
    },

    /**
     * Remove attribute
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} name - Attribute name
     */
    removeAttr: (el, name) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.removeAttribute(name);
        }
    },

    /**
     * Get or set property
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} name - Property name
     * @param {*} [val] - Value to set (omit to get)
     * @returns {*}
     */
    prop: (el, name, val) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return undefined;
        if (val === undefined) {
            return el[name];
        }
        el[name] = val;
    },

    /**
     * Add event listener
     * Automatically removes any previous handler for the same event type to ensure only one handler at a time
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} event - Event type
     * @param {Function} handler - Event handler (pass null to remove handler without adding a new one)
     */
    on: (el, event, handler) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return;

        // Get or create the handlers map for this element
        let handlersMap = DOMUtils._eventHandlers.get(el);
        if (!handlersMap) {
            handlersMap = new Map();
            DOMUtils._eventHandlers.set(el, handlersMap);
        }

        // Remove previous handler for this event type if it exists
        const previousHandler = handlersMap.get(event);
        if (previousHandler) {
            el.removeEventListener(event, previousHandler);
            handlersMap.delete(event);
        }

        // Add new handler if provided
        if (handler) {
            el.addEventListener(event, handler);
            handlersMap.set(event, handler);
        }
    },

    /**
     * Remove event listener
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} event - Event type
     * @param {Function} [handler] - Event handler reference (optional - if not provided, removes tracked handler)
     */
    off: (el, event, handler) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return;

        // If no specific handler provided, remove the tracked handler
        if (!handler) {
            const handlersMap = DOMUtils._eventHandlers.get(el);
            if (handlersMap) {
                const trackedHandler = handlersMap.get(event);
                if (trackedHandler) {
                    el.removeEventListener(event, trackedHandler);
                    handlersMap.delete(event);
                }
            }
        } else {
            // Remove the specific handler
            el.removeEventListener(event, handler);
            // Also remove from tracking if it matches
            const handlersMap = DOMUtils._eventHandlers.get(el);
            if (handlersMap && handlersMap.get(event) === handler) {
                handlersMap.delete(event);
            }
        }
    },

    /**
     * Append HTML string to element
     * @param {HTMLElement|string} parent - Element or element ID
     * @param {string} html - HTML string
     */
    append: (parent, html) => {
        parent = DOMUtils._resolveElement(parent);
        if (parent) {
            parent.insertAdjacentHTML('beforeend', html);
        }
    },

    /**
     * Append child element
     * @param {HTMLElement|string} parent - Element or element ID
     * @param {HTMLElement} child
     */
    appendChild: (parent, child) => {
        parent = DOMUtils._resolveElement(parent);
        if (parent && child) {
            parent.appendChild(child);
        }
    },

    /**
     * Clear element contents
     * @param {HTMLElement|string} el - Element or element ID
     */
    empty: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.innerHTML = '';
        }
    },

    /**
     * Remove element from DOM
     * @param {HTMLElement|string} el - Element or element ID
     */
    remove: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.remove();
        }
    },

    /**
     * Create element with optional attributes
     * @param {string} tag - Tag name
     * @param {Object} [attrs] - Attributes to set
     * @returns {HTMLElement}
     */
    create: (tag, attrs = {}) => {
        const el = document.createElement(tag);
        Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
        return el;
    },

    /**
     * Get children as array
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {HTMLElement[]}
     */
    children: (el) => {
        el = DOMUtils._resolveElement(el);
        return el ? Array.from(el.children) : [];
    },

    /**
     * Find single descendant
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} selector
     * @returns {Element|null}
     */
    find: (el, selector) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.querySelector(selector) : null;
    },

    /**
     * Find all descendants
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} selector
     * @returns {NodeList}
     */
    findAll: (el, selector) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.querySelectorAll(selector) : [];
    },

    /**
     * Get next sibling element
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {Element|null}
     */
    next: (el) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.nextElementSibling : null;
    },

    /**
     * Get parent element
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {Element|null}
     */
    parent: (el) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.parentElement : null;
    },

    /**
     * Get closest ancestor matching selector
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} selector
     * @returns {Element|null}
     */
    closest: (el, selector) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.closest(selector) : null;
    },

    /**
     * Get element offset (position relative to document)
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {{top: number, left: number}}
     */
    offset: (el) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return { top: 0, left: 0 };
        const rect = el.getBoundingClientRect();
        return {
            top: rect.top + window.scrollY,
            left: rect.left + window.scrollX
        };
    },

    /**
     * Set element offset (absolute position)
     * Like jQuery's .offset() setter, this accounts for the element's offsetParent.
     * @param {HTMLElement|string} el - Element or element ID
     * @param {{top: number, left: number}} pos - Desired position relative to document
     */
    setOffset: (el, { top, left }) => {
        el = DOMUtils._resolveElement(el);
        if (!el) return;

        // Ensure element is positioned
        const currentPosition = getComputedStyle(el).position;
        if (currentPosition === 'static') {
            el.style.position = 'relative';
        }

        // Get current offset from document
        const curOffset = DOMUtils.offset(el);

        // Get current CSS top/left values (may be 'auto')
        const curCSSTop = parseFloat(getComputedStyle(el).top) || 0;
        const curCSSLeft = parseFloat(getComputedStyle(el).left) || 0;

        // Calculate the adjustment needed
        // The CSS top/left properties are relative to the offsetParent,
        // but offset() returns document-relative coordinates.
        // To set a document-relative position, we need to adjust for this difference.
        const props = {};
        if (top != null) {
            props.top = (top - curOffset.top) + curCSSTop;
        }
        if (left != null) {
            props.left = (left - curOffset.left) + curCSSLeft;
        }

        // Set the calculated values
        if (props.top !== undefined) {
            el.style.top = props.top + 'px';
        }
        if (props.left !== undefined) {
            el.style.left = props.left + 'px';
        }
    },

    /**
     * Get or set form element value
     * @param {HTMLElement|string} el - Element or element ID
     * @param {*} [value] - Value to set (omit to get)
     * @returns {*}
     */
    val: (el, value) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return undefined;
        if (value === undefined) {
            return el.value;
        }
        el.value = value;
    },

    /**
     * Iterate over collection
     * @param {NodeList|Array} collection
     * @param {Function} fn - Callback (element, index)
     */
    each: (collection, fn) => {
        Array.from(collection).forEach(fn);
    },

    /**
     * Add CSS class
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} cls - Class name
     */
    addClass: (el, cls) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.classList.add(cls);
        }
    },

    /**
     * Remove CSS class
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} cls - Class name
     */
    removeClass: (el, cls) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.classList.remove(cls);
        }
    },

    /**
     * Check if element has class
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} cls - Class name
     * @returns {boolean}
     */
    hasClass: (el, cls) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.classList.contains(cls) : false;
    },

    /**
     * Toggle CSS class
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} cls - Class name
     */
    toggleClass: (el, cls) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.classList.toggle(cls);
        }
    },

    /**
     * Get element width
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {number}
     */
    width: (el) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.offsetWidth : 0;
    },

    /**
     * Set element width
     * @param {HTMLElement|string} el - Element or element ID
     * @param {number|string} val
     */
    setWidth: (el, val) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.style.width = typeof val === 'number' ? val + 'px' : val;
        }
    },

    /**
     * Get element height
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {number}
     */
    height: (el) => {
        el = DOMUtils._resolveElement(el);
        return el ? el.offsetHeight : 0;
    },

    /**
     * Set element height
     * @param {HTMLElement|string} el - Element or element ID
     * @param {number|string} val
     */
    setHeight: (el, val) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.style.height = typeof val === 'number' ? val + 'px' : val;
        }
    },

    /**
     * Focus element
     * @param {HTMLElement|string} el - Element or element ID
     */
    focus: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.focus();
        }
    },

    /**
     * Blur element
     * @param {HTMLElement|string} el - Element or element ID
     */
    blur: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.blur();
        }
    },

    /**
     * Trigger click event
     * @param {HTMLElement|string} el - Element or element ID
     */
    click: (el) => {
        el = DOMUtils._resolveElement(el);
        if (el) {
            el.click();
        }
    },

    /**
     * Get/set text content
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} [text] - Text to set (omit to get)
     * @returns {string|undefined}
     */
    text: (el, text) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return undefined;
        if (text === undefined) {
            return el.textContent;
        }
        el.textContent = text;
    },

    /**
     * Get/set HTML content
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} [html] - HTML to set (omit to get)
     * @returns {string|undefined}
     */
    html: (el, html) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return undefined;
        if (html === undefined) {
            return el.innerHTML;
        }
        el.innerHTML = html;
    },

    /**
     * Replace element with new content
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string|HTMLElement} content - HTML string or element
     */
    replaceWith: (el, content) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return;
        if (typeof content === 'string') {
            el.outerHTML = content;
        } else {
            el.replaceWith(content);
        }
    },

    /**
     * Wrap element's inner content in a new element
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} wrapperTag - Tag name for wrapper
     * @returns {HTMLElement} The wrapper element
     */
    wrapInner: (el, wrapperTag = 'div') => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return null;
        const wrapper = document.createElement(wrapperTag);
        while (el.firstChild) {
            wrapper.appendChild(el.firstChild);
        }
        el.appendChild(wrapper);
        return wrapper;
    },

    /**
     * Get all attributes as object
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {Object}
     */
    getAllAttributes: (el) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return {};
        const ret = {};
        Array.from(el.attributes).forEach(attr => {
            ret[attr.name] = attr.value;
        });
        return ret;
    },

    /**
     * Get/set caret position in input/textarea
     * Converted from jQuery caret plugin
     * @param {HTMLElement|string} target - Element or element ID
     * @param {number|Array} [pos] - Position to set (omit to get)
     * @returns {number|{start: number, end: number}|undefined}
     */
    caret: (target, pos) => {
        target = DOMUtils._resolveElement(target);
        if (!target)
            return undefined;

        const isContentEditable = target.contentEditable === 'true';

        // Get caret position
        if (pos === undefined) {
            // Textarea/input
            if (target.selectionStart !== undefined) {
                return target.selectionStart;
            }
            // ContentEditable
            if (isContentEditable) {
                target.focus();
                const selection = window.getSelection();
                if (selection.rangeCount === 0) return 0;
                const range1 = selection.getRangeAt(0);
                const range2 = range1.cloneRange();
                range2.selectNodeContents(target);
                range2.setEnd(range1.endContainer, range1.endOffset);
                return range2.toString().length;
            }
            return undefined;
        }

        // Set caret position
        if (pos === -1) {
            pos = isContentEditable ? target.textContent.length : target.value.length;
        }

        // Handle array [start, end] for selection
        if (Array.isArray(pos)) {
            if (target.setSelectionRange) {
                target.setSelectionRange(pos[0], pos[1]);
            }
            return;
        }

        // Set single position
        if (target.setSelectionRange) {
            target.setSelectionRange(pos, pos);
        } else if (isContentEditable) {
            target.focus();
            const selection = window.getSelection();
            const range = document.createRange();

            // Find the text node and offset
            let charCount = 0;
            let found = false;

            const walker = document.createTreeWalker(
                target,
                NodeFilter.SHOW_TEXT,
                null,
                false
            );

            let node;
            while ((node = walker.nextNode()) && !found) {
                const nodeLength = node.textContent.length;
                if (charCount + nodeLength >= pos) {
                    range.setStart(node, pos - charCount);
                    range.setEnd(node, pos - charCount);
                    found = true;
                }
                charCount += nodeLength;
            }

            if (found) {
                selection.removeAllRanges();
                selection.addRange(range);
            }
        }
    },

    /**
     * Trigger an event on an element
     * Replacement for $('#el').trigger('eventName')
     * @param {HTMLElement|string} el - Element or element ID
     * @param {string} eventName - Name of the event (e.g., 'click', 'change')
     */
    trigger: (el, eventName) => {
        el = DOMUtils._resolveElement(el);
        if (!el)
            return;
        const event = new Event(eventName, {bubbles: true, cancelable: true});
        el.dispatchEvent(event);
    },

    /**
     * Execute callback when DOM is ready
     * Replacement for $(function() {}) pattern
     * @param {Function} callback - Function to execute when DOM is ready
     */
    documentReady: (callback) => {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', callback);
        } else {
            callback();
        }
    },

    /**
     * Get currently focused element
     * Replacement for $(':focus')
     * @returns {Element|null}
     */
    getFocusedElement: () => document.activeElement,

    /**
     * Fetch HTML content from URL
     * Replacement for $.get()
     * @param {string} url - URL to fetch
     * @param {Function} successCallback - Callback for success
     * @param {Function} [errorCallback] - Callback for error
     */
    fetchHTML: (url, successCallback, errorCallback) => {
        fetch(url)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                return response.text();
            })
            .then(data => {
                if (successCallback) {
                    successCallback(data);
                }
            })
            .catch(error => {
                if (errorCallback) {
                    errorCallback(error);
                } else {
                    console.error('Fetch failed:', error);
                }
            });
    },

    /**
     * Remove all event listeners from element
     * Replacement for $('#el').off() without parameters
     * @param {HTMLElement|string} el - Element or element ID
     * @returns {HTMLElement} The new cloned element
     */
    removeAllListeners: (el) => {
        el = DOMUtils._resolveElement(el);
        if (!el || !el.parentNode)
            return el;
        const newElement = el.cloneNode(true);
        el.parentNode.replaceChild(newElement, el);
        return newElement;
    },

    /**
     * Get element by ID and remove all its event listeners in one atomic operation.
     * This is useful for popup buttons that may have handlers attached multiple times.
     * @param {string} id - Element ID (with or without #)
     * @returns {HTMLElement|null} The new cloned element with listeners removed, or null if not found
     */
    getElementWithCleanListeners: (id) => {
        const el = DOMUtils.getElement(id);
        if (!el)
            return null;
        return DOMUtils.removeAllListeners(el);
    }
};