Source: dom.mjs

/**
 * @module kongUtilDom
 */
import utilDom from "./core.mjs";
import {camelize, kebabize} from "./string.mjs";

export * from "./core.mjs";


/**
 * @typedef {Array} JsonML
 * @desc JSON Markup Language structure
 * @see {@link http://www.jsonml.org/ }
 * @property {string} 0 - HTML tag to create
 * @property {Object} 1 - attributes those to be set to the HTML element
 * @property {JsonML | string} [2] - first child of the element created
 * @property {JsonML | string} [3] - second child of the element created
 */

/**
 * @typedef {(undefined|null|false)} NullLike
 */

/**
 * @private
 * @func isLikeNull
 * @param {*} variable
 * @returns {boolean}
 */
function isLikeNull(variable) {
    return variable === undefined || variable === null || variable === false;
}


/**
 * @func $
 * @desc Shortcut to `querySelector`, but different if not given a string.
 *
 * @param {string | Array.<string> | Object | Element} s
 *  - `string`: leads this function works exactly as `querySelector`. The result would be `Element | null`.
 *  - `Array.<string>`: Each selector string in the array would be corresponding to the result `Array.<Element|null>`.
 *  - `Object.<string|Object>`: Each property would be corresponding to the result object, recursively.
 *  - `Element`: just returns itself.
 *
 * @param {Element | Document} [b = document]
 *  If not having `querySelector` method, then `document` is used.
 *  This is useful if you wanna pass this function as the argument in `Array.map`.
 *
 * @returns {null | Element} if `s` is a string or Element
 * @returns {Array.<null|Element>} if `s` is an Array
 * @returns {Object.<null|Element|Object>} if `s` is an Object
 *
 * @example /// get the first button by a string
    $("button, [type=button], [type=submit]");
 *
 * @example /// assign elements by respective selectors
    const [myForm, myTable, myTextArea] = $(["#myForm", ".myTable", "textarea"]);
 *
 * @example /// safe to use in `Array.map()`
    const [myForm, myTable, myTextArea] = ["#myForm", ".myTable", "textarea"].map($);
 *
 * @example /// nested object
    $({form: 'form', inputs: {text: '[type=text]', password: '[type=password]'}});
 *
 */
export function $(s, b = document) {
    if (! b?.querySelector) b = document;
    if (s instanceof Element) return s;
    if (typeof s === "string") return b.querySelector(s);
    if (s instanceof Array) return s.map(ss => $(ss, b));
    const r = {};
    for (let name in s) r[name] = $(s[name], b);
    return r;
}


/**
 * @func $$
 * @desc
 *  Shortcut to `querySelectorAll` but returns an array instead of `NodeList`.
 *  Different if not given a string, like `$` differs from `querySelector`;
 *  however, for selectors have no matches, empty array is returned instead of null.
 * @param {string | Array.<string> | Object} s - one or more CSS selector string
 * @param {Element | Document} [base = document] - if not an object having `querySelector` method, then it's ignored and `document` is used.
 * @returns {Array | Object}
 *
 * @example /// returns all trimmed values of <input>'s in `.myForm`.
    $$(".myForm input").map(input => input.value.trim());
 *
 */
export function $$(s, b = document) {
    if (! b?.querySelectorAll) b = document;
    if (typeof s === "string") return [...b.querySelectorAll(s)];
    else if (s instanceof Array) return s.map(ss => $$(ss, b));
    const r = {};
    for (let name in s) r[name] = $$(s[name], b);
    return r;
}


/**
 * @func parseHTML
 * @desc Shortcut to `DOMParser.parseFromString` but returns the first element in `HTMLBodyElement` by default.
 * @param {string} html
 * @param {*} [selectors = body > *] -
 *  If given but not a string or array of strings, the whole `HTMLDocument` is returned.
 *  Otherwise, the first element in the DOM tree matching `selectors` is returned; if no such elements, `null` is returned.
 *  Defaults to return the first element in `document.body`.
 * @returns {HTMLDocument | Element | null}
 *
 *  Incomplete HTML string may lead to unexpected result.
 *  Browsers may unexpectedly add essential tags such as `<html>`, `<head>`, and `<body>`,
 *  even given `html` string contains no such tags.
 *
 *  It may also omit tags if the structure is not complete.
 *  For example, `<tr>` as the root node of the `html` string
 *  may cause browsers not creating elements but only the text nodes within them.
 *
 * @example
    /// returns an `Element` whose `tagName` is "em" and has string "hi!" as its text content.
    parseHTML("<EM>hi!</em>");

 * @example
    /// returns an `HTMLDocument` which could be represented by "<html><head></head><body><em>HI</em></body></html>".
    parseHTML("<EM>hi!</em>", null);

 * @example
    /// returns an `HTMLDocument` which could be represented by "<html><head><title>title</title></head><body><p>paragraph</p></body></html>".
    parseHTML("<title>title</title><p>paragraph</p>", {});

 * @example
    /// returns null because `<title>` is automatically inserted into `<head>` and thus nothing in `<body>`.
    parseHTML("<title>my title</title>");

 * @example
    /// returns null because `<tr>` is not allowed to exist outsied `<table>` and thus there is only a text node (which isn't an `Element`) in `<body>`.
    parseHTML("<tr><td>QQ</td></tr>");
 *
 */
export function parseHTML(html, selectors = "body > *") {
    if (typeof DOMParser === "undefined") throw ReferenceError("DOMParser is not defined");
    if (! parser) parser = new DOMParser();
    const doc = parser.parseFromString(html, "text/html");
    return (typeof selectors === "string") ? $(selectors, doc) : doc;
}
let parser;


/**
 * @func getNodes
 * @desc Get nodes within the specified node.
 * @param {string | string[] | Function} [accept = ()=>true] - fit nodes are included
 * @param {string | string[] | Function} [reject = ()=>false] - fit nodes are excluded
 * @param {Element | Document} [base = document] - root of the DOM tree to travere
 * @returns {Node[]} nodes which fit `accept` but not within those fit `reject`
 */
export function getNodes(
    accept = () => true,
    reject = () => false,
    base = document
) {
    /**
     * If none of selector is function, then only elements are considered and text nodes are ignored.
     * In this case, use `Element.querySelectorAll()`; otherwise, `TreeWalker` is the option.
     * `NodeIterator` is not considered here since it does not support `FILTER_REJECT`.
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter }
     * @see {@link https://www.w3.org/TR/DOM-Level-2-Traversal-Range/traversal.html }
     */
    if (typeof accept !== "function" && typeof reject !== "function") {
        if (accept instanceof Array) accept = accept.join(",");
        if (reject instanceof Array) reject = reject.join(",");
        return $$(accept, base).filter(elem => !$$(reject, base).includes(elem));
    }
    accept = createNodeSelector(accept, base);
    reject = createNodeSelector(reject, base);
    const filter = {
        acceptNode: node => {
            if (reject(node)) return NodeFilter.FILTER_REJECT;
            if (accept(node)) return NodeFilter.FILTER_ACCEPT;
            return NodeFilter.FILTER_SKIP;
        }
    };
    const walker = document.createTreeWalker(base, NodeFilter.SHOW_ALL, filter);

    let node, result = [];
    while(node = walker.nextNode()) result.push(node);
    return result;
}

/**
 * @private
 * @func createNodeSelector
 * @desc used in `getNodes`
 * @param {string | string[] | Function} filterRule
 * @param {Element | Document} base
 * @returns {Function} tests a node and returns a boolean
 */
function createNodeSelector(filterRule, base) {
    if (typeof filterRule === "function") return filterRule;
    if (typeof filterRule === "string") {
        const elements = $$(filterRule, base);
        return node => elements.includes(node);
    }
    if (filterRule instanceof Array) {
        filterRule = filterRule.map(tag => tag.toUpperCase());
        return node => filterRule.includes(node.tagName);
    }
    throw new TypeError("selector shall be a function, a CSS selector string, or an array of strings representing HTML tags.");
}


/**
 * @func createElementFromJsonML
 * @desc Use JsonML to create an HTML element. For attributes setting, see `setAttributes`.
 * @see {@link setAttributes}
 * @param {JsonML} jsonml
 * @param {string} [namespace] - set this to use `createElementNS()`
 * @returns {Element | TextNode}
 */
export function createElementFromJsonML(jsonml, namespace) {
    if (typeof namespace !== 'string') namespace = null; // make this function safe for functions such as `Array.map()`.

    if (jsonml instanceof Node)
        return jsonml.cloneNode(true);

    if (jsonml === null || jsonml === undefined || typeof jsonml === 'boolean')
        return document.createTextNode('');

    if (! (jsonml instanceof Array))
        return document.createTextNode(jsonml);

    let [tag, attributes, ...children] = jsonml;
    if (jsonml[1] === null || typeof jsonml[1] !== 'object' || jsonml[1] instanceof Array) {
        attributes = {};
        [tag, ...children] = jsonml;
    }

    if (tag === 'svg') namespace = 'http://www.w3.org/2000/svg';
    const ns = attributes.namespace ?? namespace;
    const elem = ns ? document.createElementNS(ns, tag) : document.createElement(tag);

    delete attributes.namespace;
    setAttributesInElement(attributes, elem);

    children = children
        .reduce((acc, cur) => { // filter out empty node and merge text nodes
            if (cur === null || cur === undefined || typeof cur === 'boolean' || cur === '') return acc;
            if (acc.length) {
                const last = acc.length - 1;
                if (typeof acc[last] === 'string' && typeof cur === 'string') {
                    acc[last] += cur;
                    return acc;
                }
            }
            acc.push(cur);
            return acc;
        }, [])
        .map(c => createElementFromJsonML(c, ns))
    ;

    elem.append(...children);
    return elem;
}


/**
 * @func createElementFromTemplate
 * @desc Use `HTMLTemplateElement` to create an HTML element.
 * @see {@link https://developer.mozilla.org/zh-TW/docs/Web/HTML/Element/template }
 *
 * @param {string | Node | HTMLTemplateElement} template
 *  - string: selector of the template element
 *  - Node: the node to be cloned
 *  - HTMLTemplateElement: the template where the first element is to be cloned
 *
 * @returns {Node}
 */
export function createElementFromTemplate(template) {
    if (typeof template === 'string') template = document.querySelector(template);
    if (! (template instanceof Node)) throw new TypeError('`template` shall be a `Node` or a string selector to an `Element`.');

    let clone;
    if (template instanceof HTMLTemplateElement) {
        if (template.content.childElementCount !== 1)
            console.warn('only the first element is cloned');
        clone = document.importNode(template.content, true).firstElementChild;
    }
    else clone = template.cloneNode(true);

    $$('[id]', clone).forEach(elem => elem.removeAttribute('id'));
    return clone;
}


/**
 * @deprecated
 * @func createElement
 * @desc my old JSON format to represent DOM; shall be replaced by `createElementFromJsonML`.
 * @see {@link https://github.com/kong0107/jsml }
 */
export function createElement() {
    console.error('`kongUtilDom.createElement()` has been removed. Use `kongUtilDom.createElementFromJsonML` instead.');
}


/**
 * @deprecated
 * @func clearElement
 * @desc call `Element.replaceChildren()` without any argument specified.
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/replaceChildren#emptying_a_node }
 * @param {Element} [elem=this]
 * @returns {void}
 */
export function clearElement(elem = this) {
    console.warn('`kongUtilDom.clearElement()` has been deprecated. Use `Element.replaceChildren()` instead.');
    elem.replaceChildren();
}


/**
 * @func isEventInElement
 * @desc
 *  Check wheather a mouse event happens inside an element, even its target is not the element.
 *  Could be assign to `Element.prototype`.
 * @param {MouseEvent} event
 * @param {Element|string} [elem=this] the Element or the query selector to it
 * @returns {boolean}
 */
export function isEventInElement(event, elem = this) {
    const {clientX: x, clientY: y} = event;
    if (typeof elem === 'string') elem = $(elem);
    return [...elem.getClientRects()].some(r =>
        x >= r.left && x <= r.right && y >= r.top && y <= r.bottom
    );
}


/**
 * @func downloadURL
 * @desc Make the borowser download the given URL to the local filesystem.
 * @param {URL | string} href
 * @param {string} filename
 * @returns {undefined}
 */
export function downloadURL(href, filename) {
    createElementFromJsonML(
        ['a', {href, download: filename}]
    ).click();
}


/**
 * @func downloadData
 * @desc Download the given data.
 * @param {string | ArrayBuffer | TypedArray | DataView | Blob} data
 * @param {string} filename
 * @param {string} [mimeType='']
 * @returns {string}
 */
export function downloadData(data, filename, mimeType = '') {
    const blob = new Blob([data], {type: mimeType});
    const url = URL.createObjectURL(blob);
    downloadURL(url, filename);
    return url;
}


/**
 * @private
 * @func setTextInElement
 * @desc Set `textContent` of the Element. Skip arguments to remove children.
 * @param {string} [text='']
 * @param {Element} [elem=this]
 * @returns {undefined}
 */
function setTextInElement(text = '', elem = this) {
    if (text) elem.textContent = text;
    else elem.replaceChildren();
}

/**
 * @func setText
 * @desc Set `textContent` of the first Element, if found any. Useful if you are not sure about its existence.
 * @param {string | Element} s CSS selector string, or an Element
 * @param {string} [text='']
 * @returns {null | Element} the first found Element, or null if not found.
 */
export function setText(s, text) {
    const elem = $(s);
    if (elem === null) return null;
    setTextInElement(text, elem);
    return elem;
}


/**
 * @private
 * @func setAriaInElement
 * @desc Set one of ARIA attribute
 * @param {string} attr
 * @param {NullLike | true | string} [value=null]
 * @param {Element} [elem=this]
 * @returns {undefined}
 */
function setAriaInElement(attr, value = null, elem = this) {
    if (attr != 'role' && ! attr.startsWith('aria-')) attr = `aria-${attr}`;
    if (isLikeNull(value)) elem.removeAttribute(attr);
    else elem.setAttribute(attr, (value === true) ? '' : value);
}

/**
 * @func setAria
 * @desc Set ARIA attribute of the first Element, if found any.
 * @param {string | Element} s CSS selector string, or an Element
 * @param {string} attr ARIA attribute name, `aria-` is auto-prefixed.
 * @param {NullLike | true | string} [value=null] NullLike value removes the attribute; true set it as empty string; others treated as string value.
 * @returns {null | Element} the first found Element, or null.
 */
export function setAria(s, attr, value = null) {
    const elem = $(s);
    if (elem === null) return null;
    setAriaInElement(attr, value, elem);
    return elem;
}


/**
 * @private
 * @func setAttributesInElement
 * @param {Object} attributes
 * @param {Element} [elem=this]
 * @returns {undefined}
 */
function setAttributesInElement(attributes, elem = this) {
    if (isLikeNull(attributes)) return;

    const nameSpaces = {};
    for (let name in attributes) {
        if (name.startsWith('xmlns:')) {
            nameSpaces[name.slice(6)] = attributes[name];
            elem.setAttribute(name, attributes[name]);
        }
    }

    if ('aria' in attributes) {
        const value = attributes.aria;
        attributes = Object.assign({}, attributes); // copy to avoid modification of origin argument
        if (isLikeNull(value)) { // remove all ARIA attributes
            [...elem.attributes].forEach(a => {
                if (a === 'role' || a.startsWith('aria-')) elem.removeAttribute(a);
            });
        }
        else for (let aa in value) { // treat them as normal attributes later
            const name = (aa === 'role') ? 'role' : `aria-${aa.toLowerCase()}`;
            attributes[name] = value[aa];
        }
        delete attributes.aria;
    }

    for (let name in attributes) {
        if (name.startsWith('xmlns:')) continue;
        const value = attributes[name];

        const pos = name.indexOf(':');
        if (pos !== -1) {
            const prefix = name.slice(0, pos);
            const ns = nameSpaces[prefix] ?? null;
            if (isLikeNull(value)) elem.removeAttributeNS(ns, name);
            else if (value === true) elem.setAttributeNS(ns, name, '');
            else elem.setAttributeNS(ns, name, value);
            continue;
        }

        name = name.toLowerCase();
        if (name.startsWith('on')) {
            listenMulti(elem, name.slice(2), value);
            continue;
        }
        switch (name) {
            case 'class': // for class name: either undefined, null, empty array, empty string leads to no class attribute.
            case 'classname': {
                if (value?.length) {// this handles either null, array, string
                    elem.setAttribute('class',
                        (typeof value === 'string') ? value : value.join(' ')
                    );
                }
                else elem.removeAttribute('class');
                break;
            }
            case 'css': // for style: undefined, null, string, and Object.<string, (string|null)> are acceptable.
            case 'style': {
                if (! value) elem.removeAttribute('style');
                else if (typeof value === 'string') elem.style.cssText = value;
                else for (let sp in value) {
                    /**
                     * Empty value is different from property without setting.
                     * So we might use `removeProperty()`, which uses kekbab-case.
                     */
                    const s = elem.style;
                    const prop = kebabize(sp);
                    if (isLikeNull(value[sp])) s.removeProperty(prop);
                    else if (value[sp].endsWith('!important'))
                        s.setProperty(prop, value[sp].slice(0, -10).trim(), 'important');
                    else s.setProperty(prop, value[sp]);
                }
                break;
            }
            case 'data': // for dataset: undefined, null and Object.<string, (string|null|undefined)> are acceptable.
            case 'dataset': {
                const d = elem.dataset;
                if (isLikeNull(value)) // this delete the whole dataset
                    for (let key in d) delete d[key];
                else for (let ds in value) {
                    const key = camelize(ds);
                    if (isLikeNull(value[ds])) delete d[key];
                    else if (value[ds] === true) d[key] = '';
                    else d[key] = value[ds];
                }
                break;
            }
            case 'text': {
                setTextInElement(value, elem);
                break;
            }
            // case 'aria': break; // shall has been delete before this for-loop
            // case 'namespace': break; // shall has been deleted within createElementFromJsonML
            default: {
                if (isLikeNull(value)) elem.removeAttribute(name);
                else if (value === true) elem.setAttribute(name, '');
                else elem.setAttribute(name, value);
            }
        }
    }
}


/**
 * @func setAttributes
 * @desc Set attributes of an Element by an object as a map.
 *
 * Attributes with namespace prefix are also supported.
 *
 * Attributes with undefined, null, or false value in the first argument would be removed in the Element, except event listeners.
 *
 * Event listeners are added by "on*"" such as "onclick" (case-insensitive).
 *
 * CSS class could be assigned by string or `Array.<string>`.
 *
 * Inline style could be assigned by string or `Object.<string>`.
 *
 * Set `text` property of the first argument would assign the element's `textContent`, though it's not an attribute.
 *
 * @param {string | Element} s CSS selector string, or an Element
 * @param {Object} attributes
 * @param {Function} [attributes.onClick]
 * @param {NullLike | string | Array.<string>} [attributes.class]
 * @param {NullLike | string | Object.<string, (string|NullLike)>} [attributes.style]
 * @param {NullLike | Object.<string, (string|NullLike)>} [attributes.data]
 * @param {NullLike | Object.<string, (string|NullLike)>} [attributes.aria]
 * @returns {null | Element} the first found Element, or null if not found.
 *
 * @example /// set body background to red
    setAttributes('#some-input', {
        type: 'text',
        value: 'my string'
    });
 *
 * @example /// set body background to red and text to white
    setAttributes('body', {style: 'background-color: red; color: white'});
 *
 * @example /// set body background to red
    setAttributes('body', {style: {
        backgroundColor: 'red',
        color: 'white'
    }});
 *
 * @example /// set body class
    setAttributes('body', {class: 'my-class your-class'});
 *
 * @example /// set body class
    setAttributes('body', {class: ['my-class', 'your-class']});
 *
 */
export function setAttributes(s, attributes) {
    const elem = $(s);
    if (elem === null) return null;
    setAttributesInElement(attributes, elem);
    return elem;
}


/**
 * @func extendElementPrototype
 * @desc Add some methods to `Element` class.
 */
export const extendElementPrototype = () => {
    const p = Element.prototype;
    Object.assign(p, {
        clear: p.replaceChildren,
        hasEventIn: isEventInElement,
        setText: setTextInElement,
        setAria: setAriaInElement,
        set: setAttributesInElement
    });
    [
        'after',
        'append',
        'before',
        'prepend',
        'replaceChildren',
        'replaceWith'
    ].forEach(method => {
        const origin = p[method];
        p[method] = function () {
            const nodes = [...arguments].map(node => {
                return (node instanceof Array) ? createElementFromJsonML(node) : node;
            });
            return origin.apply(this, nodes);
        };
    });
};


Object.assign(utilDom, {
    $, $$, parseHTML, getNodes,
    createElementFromTemplate,
    createElementFromJsonML,
    createElement,
    clearElement,
    isEventInElement,
    downloadURL, downloadData,
    setText,
    setAria,
    setAttributes,
    extendElementPrototype
});

export default utilDom;