Source: html-elem.mjs

/**
 * @module kongUtilHtmlElem
 */
import utilHtmlElem from "./core.mjs";
import {
    $, $$,
    setAttributesInElement,
    isEventInElement,
    setTextInElement,
    setAriaInElement
} from "./dom.mjs";

export * from "./core.mjs";


/**
 * @func createElement
 * @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 createElement(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 => createElement(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;
}


/**
 * @func createButton
 * @desc Shortcut for `createElement(['button', ...])` with default type 'button'
 * @param {Object} attrs
 * @param {string | JsonML} content
 * @returns {HTMLButtonElement}
 */
export function createButton(attrs, content) {
    return createElement(
        ['button',
            {type: 'button', ...attrs},
            content
        ]
    );
}


/**
 * @func addStyleSheet
 * @desc Add a `<link rel="stylesheet" href="...">` before `</head>`.
 * @param {string | URL} href
 * @param {Object} [otherAttrs={}] - other attributes of `<link>`. `rel` could be overwritten if you want to add different things.
 * @returns {undefined}
 *
 * @example /// basic usage
 *  addStyleSheet('https://cdn.jsdelivr.net/npm/bootstrap/dist/css/bootstrap.css');
 *
 * @example /// works but not recommended
 *  addStyleSheet('apple-icon-114.png', {rel: 'apple-touch-icon', size: '114x114', type: 'image/png'});
 */
export function addStyleSheet(href, otherAttrs = {}) {
    document.head.append(createElement(
        ['link', {
            rel: 'stylesheet',
            ...otherAttrs,
            href: href.toString()
        }]
    ));
}


/**
 * @func createInputComplex
 * @desc Create a container wrapping an input, a label, and maybe a datalist; with auto-generated UUID for linking to each other.
 * @param {Object} inputAttrs - attributes of \<input>
 * @param {string | JsonML | HTMLlabelContentent} labelContent - content of \<label> or itself
 * @param {string} [wrapClassName=''] - className for wrapping \<div>
 * @param {string} [labelPosition='before'] - 'before' or 'after'
 * @param {Array.<string>} [datalist=null] - content of \<datalist>. Empty array still results in creating \<datalist>. Use null/false/undefined to disable that.
 * @returns {HTMLDivElement}
 *
 * @example /// basic usage
 *  createInputComplex({type: 'text'}, 'labelHere~');
 *
 * @example /// checked checkbox
 *  createInputComplex(
 *      {type: 'checkbox', checked: true},
 *      'here is a checkbox',
 *      '',
 *      'after'
 *  );
 *
 * @example /// file selector
 *  createInputComplex(
 *      {type: 'file', style: 'display: none;'},
 *      ['span', {style: 'border: 1px solid #444;'}, 'File Selector']
 *  );
 */
export function createInputComplex(
    inputAttrs,
    labelContent,
    wrapClassName = '',
    labelPosition = 'before',
    datalist = null
) {
    const inputId = inputAttrs.id = inputAttrs.id || crypto.randomUUID();

    let jsonML = ['input', inputAttrs];
    if (inputAttrs.type === 'textarea') {
        const newAttrMap = Object.assign({}, inputAttrs);
        delete newAttrMap.type;
        let text = '';
        if (Object.hasOwn(inputAttrs, 'value')) {
            text = inputAttrs.value || '';
            delete newAttrMap.value;
        }
        inputAttrs = newAttrMap;
        jsonML = ['textarea', newAttrMap, text];
    }
    const inputElem = createElement(jsonML);

    if (typeof labelContent === 'string')
        labelContent = createElement(['label', {for: inputId}, labelContent]);
    else if (Array.isArray(labelContent)) {
        if (labelContent[0] !== 'label')
            labelContent = createElement(['label', {for: inputId}, labelContent]);
        else {
            labelContent = createElement(labelContent);
            labelContent.setAttribute('for', inputId);
        }
    }
    else if (!(labelContent instanceof Element))
        throw new TypeError('Unknown type ' + typeof labelContent);

    const container = createElement(['div', {class: wrapClassName}]);
    switch (labelPosition) {
        case 'after': {
            container.append(inputElem, labelContent);
            break;
        }
        case 'before': {
            container.append(labelContent, inputElem);
            break;
        }
        default:
            throw new RangeError(`Unknown position ${labelPosition}`);
    }

    if (datalist) {
        const listId = crypto.randomUUID();
        inputElem.setAttribute('list', listId);
        container.append(createElement(
            ['datalist', {id: listId}, ...datalist.map(value => ['option', {value}])]
        ));
    }

    return container;
}


/**
 * @func createSelectElement
 * @desc Create \<select> and \<option>s inside.
 * @param {Object} attrs - attributes of \<select>
 * @param {Array.<string> | Map | Object} options - key-value pairs of \<option>s; or strings for \<option>s with same value and textContent.
 * @param {string | Array.<string>} - value(s) of selected \<option>s
 * @returns {HTMLSelectElement}
 *
 * @example /// basic usage
 *  createSelectElement({}, ['a', 'b', 'c'], 'b');
 *
 * @example /// use object as key-value pairs. note "key"s would be shown texts.
 *  createSelectElement(
 *      {multiple: true, style: 'height: 6em'},
 *      {text1: 'value1', text2: 'value2', text3: 'value3'},
 *      ['value2', 'value3']
 *  );
 *
 */
export function createSelectElement(attrs, options, selected = []) {
    let optionMLs = [];
    if (typeof selected === 'string') selected = [selected];

    if (Array.isArray(options))
        optionMLs = options.map(value => ['option', {value}, value]);
    else if (options instanceof Map)
        options.forEach((value, key) => optionMLs.push(['option', {value}, key]));
    else for (const key in options)
        optionMLs.push(['option', {value: options[key]}, key]);

    optionMLs.forEach(jsonml => {
        if (selected.includes(jsonml[1].value)) jsonml[1].selected = true;
    });
    return createElement(['select', attrs, ...optionMLs]);
}


/**
 * @func createTable
 * @desc Shortcut to create a simple HTML table.
 *      For more complicated setting (ex. cell styling; resorting; transpose; filter), you should do it by yourself.
 * @param {Array.<Array | Object>} records - each element is either an array, or an object maps names to data
 * @param {Array.<string | JsonML> | Object} [columns] - each element is either a JsonML, or an object maps names to attributes of `<th>`
 * @param {Object} [tableAttrs={}] - attributes of `<table>`
 * @returns {HTMLTableElement}
 *
 * @example /// basic usage
 *  createTable([[1, 2, 3], [4, 5], ['', '', 6]]);
 *
 * @example /// with column names
 *  createTable(
 *      [
 *          [1, 2, 3],
 *          {a: 4, b: 5},
 *          {c: 6}
 *      ],
 *      ['a', 'b', 'c']
 *  );
 */
export function createTable(
    records,
    columns,
    tableAttrs = {}
) {
    const jsonml = ['table', tableAttrs];

    let columnNames = [];
    if (columns) {
        let ths = [];
        if (Array.isArray(columns)) {
            columnNames = columns;
            ths = columns.map(col => ['th', {scope: 'col'}, col]);
        }
        else for (const name in columns) {
            columnNames.push(name);
            const colInfo = columns[name];
            if (typeof colInfo === 'string' || Array.isArray(colInfo))
                ths.push(['th', {scope: 'col', title: name}, colInfo]);
            else {
                const content = colInfo.content || name;
                delete colInfo.content;
                ths.push(['th', {scope: 'col', title: name, ...colInfo}, content]);
            }
        }
        jsonml.push(['thead', ['tr', ...ths]]);
    }

    const trs = records.map(fields => {
        if (Array.isArray(fields)) return ['tr', ...fields.map(cellContent => ['td', cellContent])];
        return ['tr', ...columnNames.map(name => ['td', {title: name}, fields[name] || ''])];
    });
    jsonml.push(['tbody', ...trs]);

    return createElement(jsonml);
}


/**
 * @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) ? createElement(node) : node;
            });
            return origin.apply(this, nodes);
        };
    });
};


Object.assign(utilHtmlElem, {
    addStyleSheet,
    createElement,
    createElementFromTemplate,
    createButton,
    createInputComplex,
    createSelectElement,
    createTable
});

export default utilHtmlElem;