DOMUtils.js

/** @module DOMUtils */
import { isObject } from './Validators';
/**
 * @function appendChildren
 * @description Append children element to parent element
 * 
 * @param {HTMLElement} parentEl - parent element
 * @param  {...HTMLElement} children - elements
 * @returns {HTMLElement} parent element
*/
export const appendChildren = (parentEl, ...children) => {
    children.forEach(child => {

        const appendChild = child => {
            if (child instanceof HTMLElement)
                parentEl.appendChild(child);
            else
                throw new TypeError(`${child}, isn't an instance of HTMLElement`);
        }

        if (Array.isArray(child))
            child.forEach(childArrayEl => {
                appendChild(childArrayEl);
            })
        else
            appendChild(child)
    })
    return parentEl;
};

/**
 * @function addListeners
 * @description Even though for simplicity sake you can pass a single string as event
 * I wouldn't recommend doing it as it adds unnecessary overhead
 * @param {HTMLElement|Window} el element to be attached with events needs to implement addEventListener
 * @param {Function} cb event handler function
 * @param {String|String[]} evts array of qualified event names
 * @param {Boolean} bubble 
 *
 * @return {HTMLElement|Window} el
**/
export const addListeners = (el, cb, evts, bubble = false) => {
    if (el && typeof el.addEventListener == 'function')
        if (typeof cb == 'function') {
            if (Array.isArray(evts)) {
                for (let e of evts) {
                    if (typeof e == 'string') {
                        el.addEventListener(e, cb, bubble)
                    }
                }
                return el
            } else if (typeof evts == 'string') {
                el.addEventListener(evts, cb, bubble)
                return el
            } else
                throw new TypeError('Param 3 "evts", is not an array');
        } else
            throw new TypeError('Param 2 "cb", is not a function');
    else
        throw new TypeError('Param 1 "el", is not an instance of HTMLElement');
};

/**
 * @function removeListeners
 * @description Even though for simplicity sake you can pass a single string as event
 * I wouldn't recommend doing it as it adds unnecessary overhead
 * 
 * @param {HTMLElement|Window} el element
 * @param {Function} cb handler reference
 * @param {String|String[]} evts array of events
 * @param {Boolean} bubble
 *
 * @return {HTMLElement|Window} void
**/
export const removeListeners = (el, cb, evts, bubble = false) => {
    bubble = !!bubble
    if (el && typeof el.removeEventListener == 'function')
        if (typeof cb == 'function') {
            if (Array.isArray(evts)) {
                for (let e of evts) {
                    if (typeof e == 'string') {
                        el.removeEventListener(e, cb, bubble);
                    }
                }
                return el
            } else if (typeof evts == 'string') {
                el.removeEventListener(evts, cb, bubble);
                return el
            } else
                throw new TypeError('Param 3 "evts", is not an array')
        } else
            throw new TypeError('Param 2 "cb", is not a function')
    else
        throw new TypeError('Param 1 "el", is not an instance of HTMLElement');
};
/**
 * @typedef {Object} createElementOptions
 * @property {String} [tag=div] - type of the element to create, defaults to div
 * @property {Object.<string, any>} [attributes] - html attributes definition
 * @property {Object.<string, any>} [styles] - inline style object
 * @property {String} [text] - element inner text
 * @property {String} [html] - element inner html
 * @property {String|String[]} [classes] - html element classes
*/
/**
 * @function createElement
 * @description Create a new HTMLElement
 * @param {...createElementOptions} options - descriptor the newly created element
 * @return {HTMLElement} instance of HTMLElement
**/
export const createElement = ({ html, text, classes, attributes, styles, tag } = {}) => {
    // let { tag } = rest;
    if (!tag || typeof tag != 'string')
        tag = 'div';

    const elem = document.createElement(tag);

    if (typeof attributes == 'string' || isObject(attributes)) {
        setAttributes(elem, attributes);
    }
    if (typeof styles == 'string' || isObject(styles)) {
        setStyles(elem, styles);
    }
    if (typeof classes == 'string' || Array.isArray(classes)) {
        setClasses(elem, classes);
    }

    if (typeof text != 'undefined')
        elem.innerText = text;
    if (typeof html != 'undefined')
        elem.innerHTML = html;

    return elem
};
/**
 * @function getBoundEvents
 * @description Returns every event properties bound to an handler
 * 
 * @param {HTMLElement} el element
 *
 * @return {Object.<string, Function} every event bound to el through ".onEvent" properties
**/
export const getBoundEvents = el => {
    if (el instanceof HTMLElement || el == window.constructor) {
        const obj = {};
        for (let prop in el) {
            if (prop.substring(0, 2) == 'on' && typeof el[prop] == 'function')
                obj[prop] = el[prop];
        }
        return obj
    }
};
/**
 * @function isChildrenOfn
 * @deprecated better directly using native method HTMLElement.contains
 * @param {HTMLElement} childNode 
 * @param {HTMLElement} parentNode 
*/
export const isChildrenOf = (childNode, parentNode) => {
    return parentNode.contains(childNode);
};
/**
 * @function moveNode
 * @description Change element parent
 * 
 * @param {HTMLElement} el element to move
 * @param {HTMLElement} newParent new parent element
 * 
 * @returns {HTMLElement} element
*/
export const moveNode = (el, newParent) => {
    if (el instanceof HTMLElement) {
        if (newParent instanceof HTMLElement) {
            if (!newParent.contains(el)) {
                if (el.parentNode) {
                    el.parentNode.removeChild(el);
                }
                newParent.appendChild(el);
            }
            return el
        } else
            throw new TypeError('Param 2 "newParent", is not an instance of HTMLElement');
    } else
        throw new TypeError('Param 1 "el", is not an instance of HTMLElement');
};

/**
 * @function setClasses
 * @description Adds an array of classes to an element
 * 
 * @param {HTMLElement} el element
 * @param {String[]} classes classes to be set
 *
 * @return {HTMLElement} element
**/
export const setClasses = (el, classes) => {
    if (el && el.classList instanceof DOMTokenList) {
        if (Array.isArray(classes)) {
            if (el.classList.length == 0) {
                el.classList = classes.join(' ');
            }
            else {
                for (let c of classes) {
                    if (typeof c == 'string')
                        el.classList.add(c);
                }
            }
        } else if (typeof classes == 'string') {
            el.classList += `${el.classList.length > 0 ? ' ' : ''}${classes}`;
        }
        return el;
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement classList as DOMTokenList');
};

/**
 * @function removeClasses
 * @description Remove classes from element.classList
 * 
 * @param {HTMLElement} el element
 * @param {String[]|"all"} classes classes to be unset
 * 
 * @returns {HTMLElement} element
*/
export const removeClasses = (el, classes) => {
    if (el && el.classList instanceof DOMTokenList) {
        if (Array.isArray(classes)) {
            for (let s in classes) {
                el.classList.remove(s);
            }
        } else if (classes = 'all') {
            el.classList = '';
        }
        return el;
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement classList as DOMTokenList');
};
/**
 * @function setAttributes
 * @description Set element attributes
 * 
 * @param {HTMLElement} el - element
 * @param {Object.<string, any>} attrs - attributes to set
 *
 * @return {HTMLElement} element
**/
export const setAttributes = (el, attrs) => {
    if (el && el.attributes instanceof NamedNodeMap) {
        for (let attr in attrs) {
            if (attr.substring(0, 4) != 'data')
                el.setAttribute(attr, attrs[attr]);
            else
                el.dataset[attr[4].toLowerCase() + attr.substring(5)] = attrs[attr];
        }
        return el;
    } else
        throw new Error('Param 1 "el", doesn\'t implement attributes as NamedNodeMap');
};

/** 
 * @function getAttributes
 * @description Get attributes of an element
 *  
 * @param {HTMLElement} el
 * @param {String[]} attrs attributes to get
 * 
 * @returns {Object.<string, any>} attribute/value object
**/
export const getAttributes = (el, attrs) => {
    if (el && el.attributes instanceof NamedNodeMap) {
        const obj = {};
        for (let attr of attrs) {
            obj[attr] = el.attributes[attr];
        }
        return obj;
    } else
        throw new Error('Param 1 "el", doesn\'t implement attributes as NamedNodeMap');
};

/**
 * @function removeAttributes
 * @description Remove attributes from an element
 * 
 * @param {HTMLElement} el element
 * @param {String[]|"all"} attrs attributes to be unset
 *
 * @return {HTMLElement} element
**/
export const removeAttributes = (el, attrs) => {
    if (el && el.attributes instanceof NamedNodeMap) {
        if (Array.isArray(attrs)) {
            for (const attr of attrs) {
                if (typeof attr == 'string')
                    el.removeAttribute(attr)
            }
            return el
        }
        else if (attrs === 'all') {
            for (const attr of el.attributes) {
                if (attr.name != 'class' && attr.name != 'style') {
                    el.removeAttribute(attr.name);
                }
            }
            return el
        } else
            throw new TypeError('Param 2 "attrs", is not an array or "all"')
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement attributes as NamedNodeMap')
};

/**
 * @function setDataAttributes
 * @description Set data- attributes of an element
 * 
 * @param {HTMLElement} el element
 * @param {Object.<string, string>} attrs data- attributes to be set
 *
 * @return {HTMLElement} element
**/
export const setDataAttributes = (el, attrs) => {
    if (el && el.dataset instanceof DOMStringMap) {
        for (let attr in attrs) {
            el.dataset[attr] = attrs[attr];
        }
        return el
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement dataset as DOMStringMap');
};

/**
 * @function removeDataAttributes
 * @description Remove data- attributes from an element
 * 
 * @param {HTMLElement} el element
 * @param {Object.<string, string>} attrs data- attributes to be removed
 *
 * @return {HTMLElement} element
**/
export const removeDataAttributes = (el, attrs) => {
    typeof attrs == 'string' ? attrs = [attrs] : attrs;
    if (el && el.dataset instanceof DOMStringMap) {
        if (Array.isArray(attrs)) {
            for (let attr of attrs) {
                el.removeAttribute(`data-${attr}`)
            }
            return el
        } else
            throw new TypeError('Param 2 "attrs", is not an array');
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement dataset as DOMStringMap');
};

/**
 * @typedef {Object} CSSPropertyObject
 * @property {String|Number} value property value
 * @property {"important"|""} priority css property priority
*/
/**
 * @function setProperties
 * @description Set custom properties of an element
 * 
 * @param {HTMLElement} el element
 * @param {Object.<string, (String|Number)>|Object.<string, CSSPropertyObject>} props custom properties to be set
 * 
 * @returns {HTMLElement} element
*/
export const setProperties = (el, props) => {
    if (el && el.style instanceof CSSStyleDeclaration) {
        for (let prop in props) {
            if (typeof props[prop] == 'object')
                el.style.setProperty(`--${prop}`, props[prop].value, props[prop].priority)
            else
                el.style.setProperty(`--${prop}`, props[prop])
        }
        return el
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement style as CSSStyleDeclaration');
};

/**
 * @function removeProperties
 * @description Remove custom properties of an element
 *  
 * @param {HTMLElement} el element
 * @param {String|String[]} props array of custom properties to unset
 * 
 * @returns {HTMLElement} element
*/
export const removeProperties = (el, props) => {
    if (el && el.style instanceof CSSStyleDeclaration) {
        if (Array.isArray(props)) {
            for (let prop of props) {
                if (typeof prop == 'string')
                    el.style.removeProperty(`--${prop}`);
            }
        } else if (typeof props == 'string') {
            el.style.removeProperty(`--${props}`);
        }
        return el
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement style as CSSStyleDeclaration');
};

/**
 * @function setStyles
 * @description Set inline styles of an element
 * 
 * @param {HTMLElement} el element 
 * @param {Object.<string, (String|Number)>} styles inline styles to apply
 *
 * @return {HTMLElement} element
**/
export const setStyles = (el, styles) => {
    if (el && el.style instanceof CSSStyleDeclaration) {
        for (let s in styles) {
            if (el.style[s] != undefined) {
                el.style[s] = styles[s];
            }
        }
        return el
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement style as CSSStyleDeclaration');
};

/**
 * @function removeStyles
 * @description Unset inline styles of an element
 * 
 * @param {HTMLElement} el element
 * @param {String[]|"all"} styles inline styles to be removed
 *
 * @return {HTMLElement} element
**/
export const removeStyles = (el, styles) => {
    if (el && el.style instanceof CSSStyleDeclaration) {
        if (Array.isArray(styles)) {
            for (let s of styles) {
                if (el.style[s] != undefined) {
                    el.style[s] = null;
                }
            }
        } else if (styles === 'all') {
            Object.keys(el.style).filter(s => {
                if (el.style[s] && isNaN(Number(s)))
                    el.style[s] = null;
            })
        }
        return el
    } else
        throw new TypeError('Param 1 "el", doesn\'t implement style as CSSStyleDeclaration');
};

/**
 * @function wrapNode
 * @description Wrap a node in a created element, returns wrapper
 * 
 * @param {HTMLElement} el element to be wrapped
 * @param {...createElementOptions} options definitions for the created wrapper element
 * 
 * @returns {HTMLElement} newly created wrapper element with element as children
*/
export const wrapNode = (el, options) => {
    if (el instanceof HTMLElement) {
        const wrap = createElement(options);
        el.parentNode.replaceChild(wrap, el);
        wrap.appendChild(el);
        return wrap;
    }
    else
        throw new TypeError('TypeError: Param 1 "el", is not an instance of HTMLElement');
};

/**
 * @function findElements
 * @description Find elements matching predicate object properties
 * Needs at least one of id, tag, classes, attributes prop to be valid
 * 
 * @param {Object} predicate object
 * @param {HTMLElement|Document} [predicate.root=Document]
 * @param {String} predicate.id matches #id
 * @param {String} predicate.tag matches <tag />
 * @param {String[]} predicate.classes matches .classes
 * @param {String[]} predicate.attributes matches [attr]
 * 
 * @returns {Element[]} elements matching predicate
*/
export const findElements = ({ root, id, tag, classes, attributes,  }) => {
    try {
        if(!root || typeof root.querySelectorAll != 'function')
            root = document
        if(typeof tag != 'string')
            tag = ''
        if(Array.isArray(classes))
            classes = `.${classes.join('.')}`
        else
            classes = ''
        if(Array.isArray(attributes))
            attributes = attributes.map(attr => `[${attr}]`).join('')
        else
            attributes = ''
        if(typeof id == 'string')
            id = '#' + id
        else
            id = ''
        return Array.from(root.querySelectorAll(`${tag}${id}${classes}${attributes}`))
    } catch(err) {
        throw SyntaxError('predicate needs to have at least one valid field')
    }
};

/**
 * @function hasClasses
 * @description Returns true if element matches all classes in array
 * 
 * @param {HTMLElement} targetEl element
 * @param {String|String[]} classes classes to be tested
 * 
 * @returns {Boolean} true if element has classes
 */
export const hasClasses = (targetEl, classes) => {
    if (Array.isArray(classes))
        return classes.reduce((acc, val) => acc && targetEl.classlist.contains(val), true);

    else if (typeof classes == 'string')
        return targetEl.classlist.contains(classes);
};

/**
 * @function hasAttributes
 * @description Returns true if element matches all attributes in array
 * 
 * @param {HTMLElement} targetEl element
 * @param {String|String[]} attributes attributes to be tested
 * 
 * @returns {Boolean} true if element has attributes
*/
export const hasAttributes = (targetEl, attributes) => {

    if (Array.isArray(attributes))
        return attributes.reduce((acc, val) => acc && targetEl.hasAttribute(val), true);

    else if (typeof attributes == 'string')
        return targetEl.hasAttribute(attributes);

};

/**
 * @function addChildrenListener
 * @description bind event listener to a parent 
 * 
 * @param {HTMLElement|Document|Window} parentEl 
 * @param {String} childrenSelector 
 * @param {String[]} events 
 * @param {Function} handler 
 * @param {Boolean} bubble 
 */
export const addChildrenListener = (parentEl, childrenSelector, events, handler, bubble) => {
    if (parentEl instanceof HTMLElement) {


        // const { id, classes, attributes, tag, } = typeof childrenSelector == 'object' ? childrenSelector : {};


        const handlerWrapper = eventObject => {

            const { target } = eventObject

            const desiredTarget = target.closest(childrenSelector)
            // if(typeof childrenSelector == 'string' && )
            // return

            const wantedEvent = new Proxy(eventObject, {
                get: function (e, prop) {
                    if (prop == 'target')
                        return desiredTarget;
                    else
                        return e[prop];
                }
            })

            if (desiredTarget) {

                typeof handler == 'function' && handler(wantedEvent);

            }
        }

        addListeners(parentEl, handlerWrapper, events, true);

    }
    else
        throw new TypeError('Param 1 "parentEl", is not an instance of HTMLElement');
};

/** 
 * @function attachEvts
 * @description alias for DOMUtils.addListeners function
 * @see {@link DOMUtils.addListeners}
*/
export const attachEvts = addListeners

/** 
 * @function detachEvts
 * @description alias for DOMUtils.removeListeners function
 * @see {@link DOMUtils.removeListeners}
*/
export const detachEvts = removeListeners

export default {
    appendChildren,
    attachEvts: addListeners,
    addListeners,
    detachEvts: removeListeners,
    removeListeners,
    createElement,
    getBoundEvents,
    isChildrenOf,
    moveNode,
    setClasses,
    removeClasses,
    setAttributes,
    getAttributes,
    removeAttributes,
    setDataAttributes,
    removeDataAttributes,
    setProperties,
    removeProperties,
    setStyles,
    removeStyles,
    wrapNode,
    findElements,
    hasClasses,
    hasAttributes,
    addChildrenListener,
}