Source: elements.js

/*!
 * Module Elements
 */

/**
 * @namespace Elements
 */

const MODULE_NAME = 'Elements';



//###[ IMPORTS ]########################################################################################################

import {
	orDefault,
	isString,
	isFunction,
	isPlainObject,
	isSelector,
	isElement,
	hasValue,
	assert,
	size,
	Deferred
} from './basic.js';
import {randomUuid} from './random.js';
import {clone} from './objects.js';
import {onDomReady} from './events.js';
import {applyStyles} from './css.js';



//###[ DATA ]###########################################################################################################

const NOT_AN_HTMLELEMENT_ERROR = 'given node/target is not an HTMLElement';
let BROWSER_HAS_CSS_SCOPE_SUPPORT;
try {
	document.querySelector(':scope *');
} catch(ex){
	BROWSER_HAS_CSS_SCOPE_SUPPORT = false;
}



//###[ EXPORTS ]########################################################################################################

/**
 * @namespace Elements:createNode
 */

/**
 * Creates an element on the fly programmatically, based on provided name, attributes and content or markup,
 * without inserting it into the DOM.
 *
 * If you provide markup as "tag", make sure that there is one single root element, this method returns exactly one
 * element, not a NodeList. Also be sure to _not_ just pass HTML source from an unsecure source, since this
 * method does not deal with potential security risks.
 *
 * One thing about dynamically creating script tags with this: if you want the script is javascript and you want to
 * actually execute the script upon adding it to the dom, you cannot provide the complete tag as a source string,
 * since scripts created with innerHTML will not execute.
 * (see: https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML#security_considerations)
 * Instead, just provide the tag name and define attributes and source via the parameters instead.
 *
 * @param {?String} [tag='span'] - tag of the element to create or markup for root element
 * @param {?Object} [attributes=null] - tag attributes as key/value-pairs, will also be added to provided markup
 * @param {?String} [content=null] - content to insert into the element as textContent, be aware, that this will replace other content in provided markup
 * @returns {HTMLElement} the created DOM-node
 *
 * @memberof Elements:createNode
 * @alias createNode
 * @example
 * document.body.appendChild(
 *   createNode('div', {id : 'content', style : 'display:none;'}, 'loading...')
 * );
 * document.body.appendChild(
 *   createNode('<div id="content" style="display:none;">loading...</div>')
 * );
 * document.body.appendChild(
 *   createNode('script', {type : 'text/javascript'}, 'alert("Hello World");');
 * );
 */
export function createNode(tag, attributes=null, content=null){
	tag = orDefault(tag, 'span', 'str').trim();
	attributes = isPlainObject(attributes) ? attributes : null;
	content = orDefault(content, null, 'str');

	// using anything more generic like template results in non-standard nodes like
	// <script type="text/json"> not being creatable
	const outerNode = document.createElement('div');

	if(
		/^<[^\/][^<>]*>/.test(tag)
		&& /<\/[^<>\/]+>$/.test(tag)
	){
		// using DOMParser results in non-standard nodes like
		// <script type="text/json"> not being creatable
		outerNode.innerHTML = tag;
	} else {
		outerNode.appendChild(document.createElement(tag));
	}

	const node = outerNode.firstChild;
	outerNode.removeChild(node);

	if( hasValue(attributes) ){
		for( let attribute in attributes ){
			node.setAttribute(attribute, `${attributes[attribute]}`);
		}
	}

	if( hasValue(content) ){
		node.textContent = content;
	}

	return node;
}



/**
 * @namespace Elements:insertNode
 */

/**
 * Inserts a node into the DOM in relation to a target element.
 *
 * If the node is not an element, the parameter is treated as source and a node is created
 * automatically based on that.
 *
 * The position can be determined with the same values as in "insertAdjacentElement"
 * (see: https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement),
 * but we also added the more intuitive jQuery aliases for positions:
 *
 * - "beforebegin" can also be described as "before"
 * - "afterbegin" can also be described as "prepend"
 * - "beforeend" can also be described as "append"
 * - "afterend" can also be descrived as "after"
 *
 * @param {HTMLElement} target - the element to which the node will be inserted in relation to
 * @param {HTMLElement|String} node - the node to insert, either as element or source string
 * @param {?String} [position='beforeend'] - the position to insert the node in relation to target, the default value appends the node as the last child in target
 * @throws error if target is not an HTMLElement
 * @returns {HTMLElement} the inserted DOM-node
 *
 * @memberof Elements:insertNode
 * @alias insertNode
 * @example
 * insertNode(document.querySelector('.list-container'), listItemElement);
 * insertNode(document.querySelector('.list-container'), '<li>Item 42</li>', 'prepend');
 */
export function insertNode(target, node, position='beforeend'){
	const __methodName__ = 'insertNode';

	assert(isElement(target), `${MODULE_NAME}.${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	if( !isElement(node) ){
		node = createNode(`${node}`);
	}

	switch( position ){
		case 'beforebegin':
		case 'before':
			position = 'beforebegin';
		break;
		case 'afterend':
		case 'after':
			position = 'afterend';
		break;
		case 'afterbegin':
		case 'prepend':
			position = 'afterbegin';
		break;
		case 'beforeend':
		case 'append':
			position = 'beforeend';
		break;
		default:
			position = 'beforeend';
		break;
	}

	target.insertAdjacentElement(position, node);

	return node;
}



/**
 * @namespace Elements:replaceNode
 */

/**
 * Replaces a node with another one.
 *
 * If the node is not an element, the parameter is treated as source and a node is created
 * automatically based on that.
 *
 * The target node needs a parent node for this function to work.
 *
 * @param {HTMLElement} target - the element to replace
 * @param {HTMLElement|String} node - the node to replace the target with
 * @throws error if target is not an HTMLElement or does not have a parent
 * @returns {HTMLElement} the replacement node
 *
 * @memberof Elements:replaceNode
 * @alias replaceNode
 * @example
 * replaceNode(document.querySelector('.hint'), newHintElement);
 * replaceNode(document.querySelector('.hint'), '<p class="hint">Sale tomorrow!</p>');
 */
export function replaceNode(target, node){
	const __methodName__ = 'replaceNode';

	assert(isElement(target), `${MODULE_NAME}.${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	if( !isElement(node) ){
		node = createNode(`${node}`);
	}

	assert(isElement(target.parentNode), `${MODULE_NAME}.${__methodName__} | given target does not have a parent)`);

	insertNode(target, node, 'after');
	target.parentNode.removeChild(target);

	return node;
}



/**
 * @namespace Elements:defineNode
 */

/**
 * Creates element attributes for an (existing) element, providing the possibility to declare another
 * element as a boilerplate element to inherit attributes from.
 *
 * The basic premise is this: Define all attributes in a plain object, where the key is the attribute name and the
 * value is the attribute value. `class` may be provided as an array of class definitions and `style` may be provided
 * as a plain object (defining styles as key/value pairs, just as is `applyStyles`). You may even define `style` as an
 * array of plain objects. In both arrays, the last entries have precedence. Everything else has to be a string.
 *
 * You can declare a `boilerplateNode` to use another element as source to inherit attributes from. To inherit an
 * attribute (if it exists), declare the attribute with a value of "<-".
 *
 * Normally all attributes of the target element will be overwritten with provided/inherited values, but you may declare
 * attributes with a preceding "+" to just add to anything already there. `class` as `+class` and `style` as `+style`
 * for example. In these cases, classes and styles would be added to anything already declared. Normal string values
 * would simply be concatenated.
 *
 * If you want to inherit all data-attributes or on-handlers from an element you may declare the fields `data*` and
 * `on*` as "<-" to inherit all attributes starting with data- or on. This would even work as `+data*` and `+on*`.
 *
 * Keep in mind that explicit definitions always have precedence over inherited values, so `data*` being "<-" and an
 * additionally declared data value will result in the element getting the declared value on the field and not having
 * the inherited value.
 *
 * @param {HTMLElement} node - the pre-existing node to (re)define
 * @param {Object} definition - a plain object declaring attributes to set or inherit from another object of the form {attributeName : attributeValue|true}
 * @param {?HTMLElement} [boilerplateNode=null] - a node to take the definition from, all values set to true in definition are inherited from here
 * @throws error if target is not an HTMLElement
 * @throws error if definition is not a plain object
 * @returns {HTMLElement} the (re)defined node
 *
 * @memberof Elements:defineNode
 * @alias defineNode
 * @see applyStyles
 * @example
 * defineNode(existingElement, {'id' : 'kitten', '+class' : ['cute', 'fluffy'], '+style' : ['display:none;', {position : 'absolute'}]});
 * defineNode(existingElement, {'+class' : '<-', 'data*' : '<-', 'data-foo' : 'this will have precedence over data*'}, anotherElement);
 */
export function defineNode(node, definition, boilerplateNode=null){
	const __methodName__ = 'defineNode';

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
	assert(isPlainObject(definition), `${MODULE_NAME}:${__methodName__} | definitions is not a plain object`);

	const inheritValue = '<-';

	if( isElement(boilerplateNode) ){
		Array.from(boilerplateNode.attributes).forEach(attribute => {
			if(
				(definition[attribute.name] === inheritValue)
				|| (
					!hasValue(definition[attribute.name])
					&& (
						((definition['data*'] === inheritValue) && attribute.name.startsWith('data-'))
						|| ((definition['on*'] === inheritValue) && attribute.name.startsWith('on'))
					)
				)
			){
				definition[attribute.name] = attribute.value;
			}

			if(
				(definition[`+${attribute.name}`] === inheritValue)
				|| (
					!hasValue(definition[`+${attribute.name}`])
					&& (
						((definition['+data*'] === inheritValue) && attribute.name.startsWith('data-'))
						|| ((definition['+on*'] === inheritValue) && attribute.name.startsWith('on'))
					)
				)
			){
				if(
					!hasValue(definition[`+${attribute.name}`])
					|| (definition[`+${attribute.name}`] === inheritValue)
				){
					definition[`+${attribute.name}`] = '';
				}

				definition[`+${attribute.name}`] += attribute.value;
			}
		});
	}

	delete definition['data*'];
	delete definition['+data*'];
	delete definition['on*'];
	delete definition['+on*'];
	Object.keys(definition).forEach(name => {
		if( definition[name] === inheritValue ){
			delete definition[name];
		}
	});

	Object.keys(definition).sort().reverse().forEach(name => {
		const
			value = definition[name],
			addValue = name.startsWith('+')
		;

		if( addValue ){
			name = name.slice(1);
		}

		if( name.endsWith('*') ){
			name = name.slice(0, -1);
		}

		if( (name === 'class') ){
			if( !addValue ){
				node.setAttribute('class', '');
			}

			[].concat(value).forEach(value => {
				`${value}`.split(' ').forEach(value => {
					node.classList.add(`${value.trim()}`);
				});
			});
		} else if( (name === 'style') ){
			if( !addValue ){
				node.setAttribute('style', '');
			}

			[].concat(value).forEach(value => {
				if( !isPlainObject(value) ){
					const
						rules = `${value}`.split(';'),
						valueObj = {}
					;

					rules.forEach(rule => {
						let [key, prop] = rule.split(':');
						key = key.trim();

						if( hasValue(prop) ){
							prop = prop.trim();
							prop = prop.endsWith(';') ? prop.slice(0, -1) : prop;
							valueObj[key] = prop;
						}
					});

					value = valueObj;
				}

				if( hasValue(value) ){
					applyStyles(node, value);
				}
			});
		} else {
			if( !addValue ){
				node.setAttribute(name, `${value}`);
			} else {
				node.setAttribute(name, `${node.getAttribute(name) ?? ''}${value}`);
			}
		}
	});

	return node;
}



/**
 * @namespace Elements:getTextContent
 */

/**
 * Return the de-nodified text content of a node-ridden string or a DOM-node.
 * Returns the raw text content, with all markup cleanly removed.
 * Can also be used to return only the concatenated child text nodes.
 *
 * @param {(String|Node)} target - the node-ridden string or DOM-node to "clean"
 * @param {?Boolean} [onlyFirstLevel=false] - true if only the text of direct child text nodes is to be returned
 * @throws error if target is neither markup nor node
 * @returns {String} the text content of the provided markup or node
 *
 * @memberof Elements:getTextContent
 * @alias getTextContent
 * @example
 * someElement.textContent = getTextContent('<p onlick="destroyWorld();">red button <a>meow<span>woof</span></a></p>');
 */
export function getTextContent(target, onlyFirstLevel=false){
	const __methodName__ = 'getTextContent';

	onlyFirstLevel = orDefault(onlyFirstLevel, false, 'bool');

	if( isString(target) ){
		target = createNode(target);
	}

	assert(isElement(target), `${MODULE_NAME}:${__methodName__} | target is neither node nor markup`);

	if( onlyFirstLevel ){
		let textContent = '';

		target.childNodes.forEach(node => {
			if( node.nodeType === 3 ){
				textContent += node.textContent;
			}
		});

		return textContent;
	} else {
		return target.textContent;
	}
}



/**
 * @namespace Elements:isInDom
 */

/**
 * Returns if an element is currently part of the DOM or in a detached state.
 *
 * @param {HTMLElement} node - the element to check, whether it is currently in the dom or detached
 * @throws error if node is not a usable HTML element
 * @returns {Boolean} true if the element is part of the DOM at the moment
 *
 * @memberof Elements:isInDom
 * @alias isInDom
 * @example
 * if( !isInDom(el) ){
 *     elementMetaInformation.delete(el);
 * }
 */
export function isInDom(node){
	const __methodName__ = 'isInDom';

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	return isFunction(document.contains) ? document.contains(node) : document.body.contains(node);
}



/**
 * @namespace Elements:getData
 */

/**
 * Returns the element's currently set data attribute value(s).
 *
 * This method has two major differences from the standard browser dataset-implementations:
 * 1. Property names are _not_ handled camel-cased in any way.
 *    The data attribute `data-my-naughty-dog` property does _not_ magically become `myNaughtyDog` on access,
 *    but keeps the original notation, just losing the prefix, so access it, by using `my-naughty-dog`
 * 2. All property values are treated as JSON first and foremost, falling back to string values, if the
 *    value is not parsable. This means, that `{"foo" : "bar"}` becomes an object, `[1, 2, 3]` becomes an array,
 *    `42` becomes a number, `true` becomes a boolean and `null` becomes a null-value. But `foobar` actually becomes
 *    the string "foobar". JSON-style double quotes are removed, when handling a single string.
 *
 * Keep in mind that things like `new Date()` will not work out of the box, since this is not included in the JSON
 * standard, but has to be serialized/deserialized.
 *
 * @param {HTMLElement} node - the element to read data from
 * @param {?String|Array<String>} [properties=null] - if set, returns value(s) of that specific property/properties (single value for exactly one property, dictionary for multiple), if left out, all properties are returned as a dictionary object
 * @throws error if node is not a usable HTML element
 * @returns {*|Object|null} JSON-parsed attribute value(s) with string fallback; either a single value for exactly one property, a dictionary of values for multiple or a call without properties (meaning all) or null, in case no data was found
 *
 * @memberof Elements:getData
 * @alias getData
 * @see setData
 * @see removeData
 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
 * @example
 * getData(createNode('<div data-my-naughty-dog="42"></div>'), 'my-naughty-dog')
 * => 42
 * getData(createNode('<div data-my-naughty-dog='{"foo" : [1, "two", true]}'></div>'), 'my-naughty-dog')
 * => {"foo" : [1, "two", true]}
 * getData(createNode('<div data-my-naughty-dog='1, "two", true'></div>'), 'my-naughty-dog')
 * => '1, "two", true'
 * getData(createNode('<div data-my-naughty-dog="42" data-foo="true" data-bar="test"></div>'), ['foo', 'bar'])
 * => {"foo" : true, "bar" : "test"}
 * getData(createNode('<div data-my-naughty-dog="42" data-foo="true" data-bar="test"></div>'))
 * => {"my-naughty-dog" : 42,"foo" : true, "bar" : "test"}
 */
export function getData(node, properties=null){
	const __methodName__ = 'getData';

	properties = orDefault(properties, null, 'arr');

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	let data = {};

	if( hasValue(properties) ){
		properties.forEach(property => {
			let attributeValue = node.getAttribute(`data-${property}`);
			if( hasValue(attributeValue) ){
				try {
					data[property] = JSON.parse(attributeValue);
				} catch(ex){
					data[property] = attributeValue;
				}
			}
		});
	} else {
		Array.from(node.attributes).forEach(attribute => {
			if( attribute.name.startsWith('data-') ){
				const property = attribute.name.replace(/^data-/, '');
				try {
					data[property] = JSON.parse(attribute.value);
				} catch(ex){
					data[property] = attribute.value;
				}
			}
		});
	}

	if( size(data) === 0 ){
		data = null;
	} else if( (properties?.length === 1) ){
		data = data[properties[0]] ?? null;
	}

	return data;
}



/**
 * @namespace Elements:setData
 */

/**
 * Writes data to an element, by setting data-attributes.
 *
 * Setting a value of `undefined` or an empty string removes the attribute.
 *
 * This method has two major differences from the standard browser dataset-implementations:
 * 1. Property names are _not_ handled camel-cased in any way.
 *    The data attribute `my-naughty-dog` property is _not_ magically created from `myNaughtyDog`,
 *    but the original notation will be kept, just adding the prefix, so set `data-my-naughty-dog`
 *    by using `my-naughty-dog`
 * 2. All property values are treated as JSON first and foremost, falling back to basic string values, if the
 *    value is not stringifiable as JSON. If the top-level value ends up to be a simple JSON string like '"foo"'
 *    or "'foo'", the double quotes are removed before writing the value.
 *
 * Keep in mind that things like `new Date()` will not work out of the box, since this is not included in the JSON
 * standard, but has to be serialized/deserialized.
 *
 * @param {HTMLElement} node - the element to write data to
 * @param {Object<String,*>|String} dataSet - the data to write to the element, properties have to be exact data-attribute names without the data-prefix, values are stringified (first with JSON.stringify and then as-is as a fallback), if value is a function it gets executed and the return value will be used from there on; if this is a string, this defines a single property to set, with the singleValue being the value to set
 * @param {?*} [singleValue=null] - if you only want to set exactly one property, you may set dataSet to the property name as a string and provide the value via this parameter instead
 * @throws error if node is not a usable HTML element or if dataSet is not a plain object if no single value has been given
 * @returns {Object<String,*>|*|null} the value(s) actually written to the element's data-attributes as they would be returned by getData (removed attributes are marked with `undefined`); null will be returned if nothing was changed; if only a single value was set, only that value will be returned
 *
 * @memberof Elements:setData
 * @alias setData
 * @see getData
 * @see removeData
 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
 * @example
 * setData(element, {foobar : 'hello kittens!'});
 * => {foobar : 'hello kittens!'}
 * setData(element, 'foobar', 'hello kittens!');
 * => 'hello kittens!'
 * setData(element, {foobar : {a : 'foo', b : [1, 2, 3], c : {d : true}}});
 * => {foobar : {a : 'foo', b : [1, 2, 3], c : {d : true}}}
 * setData(element, 'foobar', {a : 'foo', b : [1, 2, 3], c : {d : true}});
 * => {a : 'foo', b : [1, 2, 3], c : {d : true}}
 * setData(element, {foobar : () => { return 'hello kittens!'; }});
 * => {foobar : 'hello kittens!'}
 * setData(element, {foobar : undefined});
 * => {foobar : undefined}
 * setData(element, boofar, '');
 * => undefined
 */
export function setData(node, dataSet, singleValue=null){
	const __methodName__ = 'setData';

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	let singleKey = null;
	if( hasValue(singleValue) ){
		singleKey = `${dataSet}`;
		dataSet = {
			[singleKey] : singleValue
		};
	}

	assert(isPlainObject(dataSet), `${MODULE_NAME}:${__methodName__} | dataSet is not a plain object`);

	const appliedValues = {};

	Object.entries(dataSet).forEach(([property, value]) => {
		if( isFunction(value) ){
			value = value();
		}

		if( value !== undefined ){
			let stringifiedValue, getValue;
			try {
				stringifiedValue = JSON.stringify(value);
				getValue = JSON.parse(stringifiedValue);
			} catch(ex){
				stringifiedValue = `${value}`;
				getValue = stringifiedValue;
			}
			stringifiedValue = stringifiedValue.replace(/^['"]/, '').replace(/['"]$/, '').trim();

			if( stringifiedValue !== '' ){
				appliedValues[property] = getValue;
				node.setAttribute(`data-${property}`, stringifiedValue);
			} else if( node.hasAttribute(`data-${property}`) ){
				appliedValues[property] = undefined;
				node.removeAttribute(`data-${property}`);
			}
		} else if( node.hasAttribute(`data-${property}`) ){
			appliedValues[property] = undefined;
			node.removeAttribute(`data-${property}`);
		}
	});

	if( hasValue(singleKey) ){
		return (singleKey in appliedValues) ?  appliedValues[singleKey] : null;
	} else {
		return (size(appliedValues) > 0) ? appliedValues : null;
	}
}



/**
 * @namespace Elements:removeData
 */

/**
 * Removes data from an element, by removing corresponding data-attributes.
 *
 * This method has a major difference from the standard browser dataset-implementations:
 * Property names are _not_ handled camel-cased in any way.
 * The data attribute's `my-naughty-dog` property is _not_ magically created from `myNaughtyDog`,
 * but the original notation will be kept, just adding the prefix,
 * so use `my-naughty-dog` to remove `data-my-naughty-dog`
 *
 * @param {HTMLElement} node - the element to remove data from
 * @param {?String|Array<String>} [properties=null] - if set, removes specified property/properties, if left out, all data properties are removed
 * @throws error if node is not a usable HTML element
 * @returns {*|Object<String,*>|null} the removed data values as they would be returned from getData (single value for one property, dictionaries for multiple or all) or null if nothing was removed
 *
 * @memberof Elements:removeData
 * @alias removeData
 * @see getData
 * @see setData
 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
 * @example
 * const testNode = createNode(`<span data-foobar="test" data-boofar="null" data-baz='{"a" : ["1", 2, 3.3], "b" : true}'></span>`)
 * removeData(testNode, 'foobar')
 * => 'test' (testNode.outerHTML === `<span data-boofar="null" data-baz='{"a" : ["1", 2, 3.3], "b" : true}'></span>`)
 * removeData(testNode, ['foobar', 'baz', 'test'])
 * => {foobar : 'test', baz : {"a" : ["1", 2, 3.3], "b" : true}} (testNode.outerHTML === `<span data-boofar="null"></span>`)
 * removeData(testNode)
 * => {foobar : 'test', boofar : null, baz : {"a" : ["1", 2, 3.3], "b" : true}} (testNode.outerHTML === `<span></span>`)
 * removeData(testNode, 'test')
 * => null
 */
export function removeData(node, properties=null){
	const __methodName__ = 'removeData';

	properties = orDefault(properties, null, 'arr');

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	let data = getData(node, properties);
	if( hasValue(data) ){
		if( properties?.length === 1 ){
			setData(node, {[properties[0]] : undefined});
		} else {
			setData(node, Object.keys(data).reduce((removalDataSet, property) => {
				removalDataSet[property] = undefined;
				return removalDataSet;
			}, {}));
		}
	} else {
		data = null;
	}

	return data;
}



/**
 * @namespace Elements:find
 */

/**
 * Searches for and returns descendant nodes of a given node matching a CSS selector, just as querySelector(All).
 *
 * The main difference to querySelector(All) is, that this method automatically scopes the query, making sure, that the
 * given selector is actually fulfilled _inside_ the scope of the base element and not always regarding the whole
 * document. So, basically this implementation always automatically adds `:scope` to the beginning of the selector
 * if no scope has been defined (as soon as a scope is defined anywhere in the selector, no auto-handling will be done).
 * The function always takes care of handling browsers, that do no support `:scope` yet, by using a randomized query
 * attribute approach.
 *
 * The second (minor) difference is, that this function actually returns an array and does not return a NodeList. The
 * reason being quite simple: Arrays have far better support for basic list operations than NodeList. An example:
 * Getting the first found node is straightforward in both cases (item(0) vs. at(0)), but getting the last node becomes
 * hairy pretty quickly since, item() does not accept negative indices, whereas at() does. So, with an array, we can get
 * the last node simple by using at(-1). Arrays simply have the better API nowadays and since the NodeList would be
 * static here anyway ...
 *
 * The last little difference is, that the base node for this function may not be the document itself, since
 * attribute-based scoping fallback does not work on the document, since we cannot define attributes on the document
 * itself. Just use document.body instead.
 *
 * @param {HTMLElement} node - the element to search in
 * @param {?String} [selector='*'] - the element query selector to apply to node, to find fitting elements
 * @param {?Boolean} [onlyOne=false] - if true, uses querySelector instead of querySelectorAll and therefore returns a single node or null instead of an array
 * @throws error if node is not a usable HTML element
 * @return {Array<Node>|Node|null} descendant nodes matching the selector, a single node or null if onlyOne is true
 *
 * @memberof Elements:find
 * @alias find
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/querySelectorAll#user_notes
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array
 * @example
 * find(document.body, 'section ul > li a[href*="#"]');
 * find(element, '> aside img[src]');
 * find(element, '> aside img[src]', true);
 * find(element, 'aside > :scope figcaption');
 * find(element, '*');
 * find(element, '[data-test="foobar"] ~ li a[href]'));
 * find(element, 'a[href]').at(-1);
 */
export function find(node, selector='*', onlyOne=false){
	const
		__methodName__ = 'find',
		scopeRex = /:scope(?![\w-])/gi
	;

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	selector = orDefault(selector, '*', 'str').trim();
	if( !(scopeRex.test(selector)) ){
		selector = `:scope ${selector}`;
	}
	onlyOne = orDefault(onlyOne, false, 'bool');

	if( BROWSER_HAS_CSS_SCOPE_SUPPORT ){
		return onlyOne ? node.querySelector(selector) : Array.from(node.querySelectorAll(selector));
	} else {
		const fallbackScopeAttribute = `find-scope-${randomUuid()}`;
		selector = selector.replace(scopeRex, `[${fallbackScopeAttribute}]`);

		node.setAttribute(fallbackScopeAttribute, '');
		const found = onlyOne ? node.querySelector(selector) : Array.from(node.querySelectorAll(selector));
		node.removeAttribute(fallbackScopeAttribute);

		return found;
	}
}



/**
 * @namespace Elements:findOne
 */

/**
 * Searches for and returns one descendant node of a given node matching a CSS selector, just as querySelector.
 *
 * The main difference to querySelector is, that this method automatically scopes the query, making sure, that the
 * given selector is actually fulfilled _inside_ the scope of the base element and not always regarding the whole
 * document. So, basically this implementation always automatically adds `:scope` to the beginning of the selector
 * if no scope has been defined (as soon as a scope is defined anywhere in the selector, no auto-handling will be done).
 * The function always takes care of handling browsers, that do no support `:scope` yet, by using a randomized query
 * attribute approach.
 *
 * The function is a shorthand for `find()` with `onlyOne` being true. The main reason this method existing, is, that
 * querySelector has a 2:1 performance advantage over querySelectorAll and nullish coalescing is easier using a
 * possible null result.
 *
 * @param {HTMLElement} node - the element to search in
 * @param {?String} [selector='*'] - the element query selector to apply to node, to find fitting element
 * @throws error if node is not a usable HTML element
 * @return {Node|null} descendant nodes matching the selector
 *
 * @memberof Elements:findOne
 * @alias findOne
 * @see find
 * @example
 * findOne(document.body, 'section ul > li a[href*="#"]');
 * findOne(element, '> aside img[src]');
 * findOne(element, 'aside > :scope figcaption');
 * findOne(element, '*');
 * findOne(element, '[data-test="foobar"] ~ li a[href]'));
 */
export function findOne(node, selector='*'){
	return find(node, selector, true);
}



/**
 * @namespace Elements:findTextNodes
 */

/**
 * Extracts all pure text nodes from an Element, starting in the element itself.
 *
 * Think of this function as a sort of find() where the result are not nodes, that query selectors can find, but pure
 * text nodes. So you'll get a set of recursively discovered text nodes without tags, representing the pure text content
 * of an element.
 *
 * If you define to set onlyFirstLevel, you'll be able to retrieve all text on the first level of an element _not_
 * included in any tag (paragraph contents without special formats as b/i/em/strong for example).
 *
 * @param {HTMLElement} node - the element to search for text nodes inside
 * @param {?Function} [filter=null] - a filter function to restrict the returned set, gets called with the textNode (you can access the parent via .parentNode)
 * @param {?Boolean} [onlyFirstLevel=false] - defines if the function should only return text nodes from the very first level of children
 * @throws error if node is not a usable HTML element
 * @return {Array<Node>} a list of text nodes
 *
 * @memberof Elements:findTextNodes
 * @alias findTextNodes
 * @example
 * const styledSentence = createElement('<div>arigatou <p>gozaimasu <span>deshita</span></p> mr. roboto<p>!<span>!!</span></p></div>');
 * findTextNodes(styledSentence).length;
 * => 6
 * findTextNodes(styledSentence, null, true).length;
 * => 2
 * findTextNodes(styledSentence, textNode => textNode.textContent.length < 9).length;
 * => 3
 * findTextNodes(styledSentence).map(node => node.textContent).join('');
 * 	=> 'arigatou gozaimasu deshita mr. roboto!!!';
 */
export function findTextNodes(node, filter=null, onlyFirstLevel=false){
	const __methodName__ = 'findTextNodes';

	filter = isFunction(filter) ? filter : () => true;
	onlyFirstLevel = orDefault(onlyFirstLevel, false, 'bool');

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);

	const
		textNodeType = 3,
		isValidTextNode = node => {
			return (node.nodeType === textNodeType)
				&& (node.textContent.trim() !== '')
				&& !!filter(node)
			;
		},
		extractTextNodes = node => {
			if( isValidTextNode(node) ){
				return [].concat(node);
			} else {
				return Array.from(node.childNodes).reduce((textNodes, childNode) => {
					return isValidTextNode(childNode)
						? textNodes.concat(childNode)
						: (
							!!onlyFirstLevel
							? textNodes
							: textNodes.concat(extractTextNodes(childNode))
						)
					;
				}, []);
			}
		}
	;

	return extractTextNodes(node);
}



/**
 * @namespace Elements:prime
 */

/**
 * Offers an execution frame for element preparation like setting handlers and transforming dom.
 * Takes a function including the initialization code of an element and wraps it with
 * a check if this initialization was already executed (via data-attribute) as well
 * as a document ready handler to make sure no initializations are executed with a half-ready dom.
 *
 * If the initialization function returns a Promise or Deferred, the returned Deferred will resolve or reject
 * accordingly with the same result value or rejection. If `init` does not return a Promise or Deferred, the returned
 * Deferred will resolve as soon as document ready was reached. So, to be able to work with applied changes
 * on the element after calling `prime` you have three options:
 * 1. Either make sure document ready occurred before prime(), so `init` gets called synchronously right away.
 * 2. Wrap the code after prime a DOM-ready-check as well, to establish a synchronous event order.
 * 3. Use the returned Deferred's `.then()`.
 *
 * During priming, the node receives three data-attributes, stating the current step. The attribute name is built
 * according to the value of `markerAttributeNames` like this:
 * 1. data-${markerAttributeNames}="true" => as soon as prime is executed on the node
 * 2. data-${markerAttributeNames}-ready="true" => as soon as the init function has been executed and the dom is ready
 * 2. data-${markerAttributeNames}-resolved="true" => as soon as the returned Deferred has resolved
 *
 * @param {HTMLElement} node - the element to prime
 * @param {Function} init - the function containing all initialization code for the node, the return value may either also be a Promise or Deferred (probably resolving to a value), the return value will be used to resolve the returned Deferred of the function; if you need to detect repeated prime calls on the same element, it is a good idea to let init return something other than "undefined", since that value also signifies a repeated call
 * @param {?Object<String,String|Array<String>>} [classChanges=null] - if set, may contain the keys "add" and/or "remove" holding standard class strings or arrays of standard class strings, defining which classes to add and/or remove once priming is done, helpful to automatically remove visual cloaking or set ready markers; adding has precedence over removing
 * @param {?String} [markerAttributesName='primed'] - this function uses data-attributes to mark priming status, this is the name used to construct these attributes (default: data-primed*="...")
 * @throws error if node is not a usable HTML element
 * @throws error if init is not a function
 * @returns {Basic.Deferred} resolves to the return value of init function or to `undefined` if element was already primed
 *
 * @memberof Elements:prime
 * @alias prime
 * @example
 * prime(widget, () => { return Promise.resolve(); });
 * prime(anotherWidget, () => { return somethingLongRunningAsync(); })).then(function(){ ... })
 * prime(yetAnotherWidget, node => { magicallyTransform(node); }, {remove : 'cloaked'});
 */
export function prime(node, init, classChanges=null, markerAttributesName='primed'){
	const __methodName__ = 'prime';

	classChanges = orDefault(classChanges, {});
	markerAttributesName = orDefault(markerAttributesName, 'primed', 'str');

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
	assert(isFunction(init), `${MODULE_NAME}:${__methodName__} | init is not a function`);

	const deferred = new Deferred();

	if( getData(node, markerAttributesName) !== true ){
		setData(node, markerAttributesName, true);

		onDomReady(() => {
			const initResult = init(node);

			if(
				hasValue(initResult)
				&& isFunction(initResult.then)
				&& isFunction(initResult.catch)
			){
				initResult
					.then(resolution => { deferred.resolve(resolution); })
					.catch(error => { deferred.reject(error); })
				;
			} else {
				deferred.resolve(initResult);
			}

			setData(node, `${markerAttributesName}-ready`, true);
		});
	} else {
		deferred.resolve(undefined);
	}

	deferred.then(() => {
		if( hasValue(classChanges.remove) ){
			[].concat(classChanges.remove).forEach(removeClass => {
				`${removeClass}`.split(' ').forEach(removeClass => {
					node.classList.remove(removeClass.trim());
				});
			});
		}

		if( hasValue(classChanges.add) ){
			[].concat(classChanges.add).forEach(addClass => {
				`${addClass}`.split(' ').forEach(addClass => {
					node.classList.add(addClass.trim());
				});
			});
		}

		setData(node, `${markerAttributesName}-resolved`, true);
	});

	return deferred;
}



/**
 * @namespace Elements:measureHiddenDimensions
 */

/**
 * Measures hidden elements by using a sandbox div. In some layout situations you may not be able to measure hidden
 * or especially detached elements correctly, sometimes simply because they are not rendered, other times because
 * they are rendered in a context where the browser does not keep correct styling information due to optimizations
 * considering visibility of the element.
 *
 * This method works by cloning a node and inserting it in a well hidden sandbox element for the time of the measurement,
 * after which the sandbox is immediately removed again. This method allows you to measure "hidden" elements inside the
 * DOM without the need to actually move elements around or show them visibly.
 *
 * Keep in mind, that only measurements inherent to the element itself are measurable if sandbox is inserted into the
 * body. Layout information from surrounding containers is, of course, not available. You can remedy this by setting the
 * context correctly. Keep in mind, that direct child selectors may not work in the context since the sandbox itself
 * constitutes a new level between context and element. In these cases you might have to adapt you selectors.
 *
 * @param {HTMLElement} node - the element to measure
 * @param {?String} [method='outer'] - the kind of measurement to take, allowed values are "outer"/"offset", "inner"/"client" or "scroll"
 * @param {?String} [selector=null] - selector to apply to element to find target
 * @param {?HTMLElement} [context=document.body] - context to use as container for measurement
 * @throws error if node is not a usable HTML element
 * @returns {Object<String,Number>} a plain object holding width and height measured according to the defined method
 *
 * @memberof Elements:measureHiddenDimensions
 * @alias measureHiddenDimensions
 * @see https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model/Determining_the_dimensions_of_elements#how_big_is_the_content
 * @example
 * measureHiddenDimensions(document.body.querySelector('div.hidden:first'), 'inner');
 * measureHiddenDimensions(document.body, 'outer, 'div.hidden:first', document.body.querySelector('main'));
 */
export function measureHiddenDimensions(node, method='outer', selector=null, context=null){
	const __methodName__ = 'measureHidden';

	const methods = {
		offset : {width : 'offsetWidth', height : 'offsetHeight'},
		outer : {width : 'offsetWidth', height : 'offsetHeight'},
		client : {width : 'clientWidth', height : 'clientHeight'},
		inner : {width : 'clientWidth', height : 'clientHeight'},
		scroll : {width : 'scrollWidth', height : 'scrollHeight'}
	};
	method = methods[orDefault(method, 'outer', 'str')] ?? methods.outer;
	// document.body not in function default to prevent errors on import in document-less contexts
	context = orDefault(context, document.body);

	assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
	assert(isElement(context), `${MODULE_NAME}:${__methodName__} | context is no an htmlelement`);

	const
		sandbox = createNode('div', {
			'id' : `sandbox-${randomUuid()}`,
			'class' : 'sandbox',
			'style' : 'display:block; opacity:0; visibility:hidden; pointer-events:none; position:absolute; top:-10000px; left:-10000px;'
		}),
		measureClone = clone(node)
	;

	context.appendChild(sandbox);
	sandbox.appendChild(measureClone);

	const
		target = isSelector(selector) ? measureClone.querySelector(selector) : measureClone,
		width = target?.[method.width] ?? 0,
		height = target?.[method.height] ?? 0,
		dimensions = {
			width,
			height,
			toString(){ return `${width}x${height}`; }
		}
	;

	context.removeChild(sandbox);

	return dimensions;
}