/*
* This file is part of AUX.
*
* AUX is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* AUX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General
* Public License along with this program; if not, write to the
* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301 USA
*/
/**
* Helper functions for manipulating the DOM
*
* @module utils/dom
*/
import { warn } from './log.js';
/**
* Returns true if the node has the given class.
* @param {HTMLElement|SVGElement} node - The DOM node.
* @param {string} name - The class name.
* @returns {boolean}
* @function hasClass
*/
export function hasClass(e, cls) {
return e.classList.contains(cls);
}
/**
* Adds a CSS class to a DOM node.
*
* @param {HTMLElement|SVGElement} node - The DOM node.
* @param {...*} names - The class names.
* @function addClass
*/
export function addClass(e) {
let i;
e = e.classList;
for (i = 1; i < arguments.length; i++) {
e.add(...splitClassNames(arguments[i]));
}
}
/**
* Removes a CSS class from a DOM node.
* @param {HTMLElement|SVGElement} node - The DOM node.
* @param {...*} names - The class names.
* @function removeClass
*/
export function removeClass(e) {
let i;
e = e.classList;
for (i = 1; i < arguments.length; i++) {
e.remove(...splitClassNames(arguments[i]));
}
}
/**
* Toggles a CSS class from a DOM node.
* @param {HTMLElement|SVGElement} node - The DOM node.
* @param {string} name - The class name.
* @function toggleClass
*/
export function toggleClass(e, cls, cond) {
/* The second argument to toggle is not implemented in IE,
* so we never use it */
if (arguments.length >= 3) {
if (cond) {
addClass(e, cls);
} else {
removeClass(e, cls);
}
} else e.classList.toggle(cls);
}
/**
* Returns the computed style of a node.
*
* @param {HTMLElement|SVGElement} node - The DOM node.
* @param {string} property - The CSS property name.
* @returns {string}
*
* @function getStyle
*/
export function getStyle(e, style) {
return window.getComputedStyle(e).getPropertyValue(style);
}
const class_regex = /[^A-Za-z0-9_-]/;
/**
* Returns true if a string could be a class name.
* @param {string} str - The string to test
* @function isClassName
* @returns {boolean}
*/
export function isClassName(str) {
return !class_regex.test(str);
}
/**
* Returns true if the given string could be a CSS custom property
* name (i.e. if it starts with `--` and does not contain any illegal
* characters).
* @param {string} str
* @returns {boolean}
*/
export function isCSSVariableName(str) {
return str.startsWith('--') && isClassName(str);
}
/**
* Returns the maximum value (float) of a comma separated string. It is used
* to find the longest CSS animation in a set of multiple animations.
* @param {string} string - The comma separated string.
* @function getMaxTime
* @returns {number}
* @example
* getMaxTime(getStyle(DOMNode, "animation-duration"));
*/
export function getMaxTime(string) {
let ret = 0,
i,
tmp,
s = string;
if (typeof s === 'string') {
s = s.split(',');
for (i = 0; i < s.length; i++) {
tmp = parseFloat(s[i]);
if (tmp > 0) {
if (-1 === s[i].search('ms')) tmp *= 1000;
if (tmp > ret) ret = tmp;
}
}
}
return ret | 0;
}
/**
* Returns the longest animation duration of CSS animations and transitions.
* @param {HTMLElement} element - The element to evalute the animation duration for.
* @function getDuration
* @returns {number}
*/
export function getDuration(element) {
return Math.max(
getMaxTime(getStyle(element, 'animation-duration')) +
getMaxTime(getStyle(element, 'animation-delay')),
getMaxTime(getStyle(element, 'transition-duration')) +
getMaxTime(getStyle(element, 'transition-delay'))
);
}
/**
* Returns the DOM node with the given ID. Shorthand for document.getElementById.
* @param {string} id - The ID to search for
* @function getId
* @returns {HTMLElement}
*/
export function getId(id) {
return document.getElementById(id);
}
/**
* Returns all elements as NodeList of a given class name. Optionally limit the list
* to all children of a specific DOM node. Shorthand for element.getElementsByClassName.
* @param {string} class - The name of the class
* @param {DOMNode} element - Limit search to child nodes of this element. Optional.
* @returns {NodeList}
* @function getClass
*/
export function getClass(cls, element) {
return (element ? element : document).getElementsByClassName(cls);
}
/**
* Returns all elements as NodeList of a given tag name. Optionally limit the list
* to all children of a specific DOM node. Shorthand for element.getElementsByTagName.
* @param {string} tag - The name of the tag
* @param {DOMNode} element - Limit search to child nodes of this element. Optional.
* @returns {NodeList}
* @function getTag
*/
export function getTag(tag, element) {
return (element ? element : document).getElementsByTagName(tag);
}
/**
* Returns a newly created HTMLElement.
* @param {string} tag - The type of the element
* @param {...object} attributes - Optional mapping of attributes for the new node
* @param {...string} class - Optional class name for the new node
* @returns HTMLElement
* @function element
*/
export function element(tag) {
const n = document.createElement(tag);
let i, v;
for (i = 1; i < arguments.length; i++) {
v = arguments[i];
if (typeof v === 'object') {
for (const key in v) {
if (Object.prototype.hasOwnProperty.call(v, key))
n.setAttribute(key, v[key]);
}
} else if (typeof v === 'string') {
addClass(n, v);
} else throw new Error('unsupported argument to element');
}
return n;
}
/**
* Removes all child nodes from an HTMLElement.
* @param {HTMLElement} element - The element to clean up
* @function empty
*/
export function empty(element) {
while (element.lastChild) element.removeChild(element.lastChild);
}
/**
* Sets a string as new exclusive text node of an HTMLElement.
* @param {HTMLElement} element - The element to clean up
* @param {string} text - The string to set as text content
* @function setText
*/
export function setText(element, text) {
if (
element.childNodes.length === 1 &&
typeof element.childNodes[0].data === 'string'
)
element.childNodes[0].data = text;
else element.textContent = text;
}
/**
* Returns a documentFragment containing the result of a string parsed as HTML.
* @param {string} html - A string to parse as HTML
* @returns {HTMLFragment}
* @function HTML
*/
export function HTML(string) {
/* NOTE: setting innerHTML on a document fragment is not supported */
const e = document.createElement('div');
const f = document.createDocumentFragment();
e.innerHTML = string;
while (e.firstChild) f.appendChild(e.firstChild);
return f;
}
/**
* Sets the (exclusive) content of an HTMLElement.
* @param {HTMLElement} element - The element receiving the content
* @param{string|HTMLElement|DocumentFragment} content
* A string, HTMLElement or DocumentFragment to set as content. Strings are
* set as textContent. HTMLElements and DocumentFragments are added as children.
* Note that DocumentFragments are cloned.
* @function setContent
*/
export function setContent(element, content) {
if (isDocumentFragment(content)) {
empty(element);
element.appendChild(content.cloneNode(true));
} else if (isDomNode(content)) {
empty(element);
if (content.parentNode) {
warn('setContent: possible reuse of a DOM node. cloning\n');
content = content.cloneNode(true);
}
element.appendChild(content);
} else {
setText(element, content + '');
}
}
/**
* Inserts one HTMLELement after another in the DOM tree.
* @param {HTMLElement} newnode - The new node to insert into the DOM tree
* @param {HTMLElement} refnode - The reference element to add the new element after
* @function insertAfter
*/
export function insertAfter(newnode, refnode) {
if (refnode.parentNode)
refnode.parentNode.insertBefore(newnode, refnode.nextSibling);
}
/**
* Inserts one HTMLELement before another in the DOM tree.
* @param {HTMLElement} newnode - The new node to insert into the DOM tree
* @param {HTMLElement} refnode - The reference element to add the new element before
* @function insertBefore
*/
export function insertBefore(newnode, refnode) {
if (refnode.parentNode) refnode.parentNode.insertBefore(newnode, refnode);
}
/**
* Returns the width of the viewport.
* @returns {number}
* @function width
*/
export function width() {
return Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0,
document.body.clientWidth || 0
);
}
/**
* Returns the height of the viewport.
* @returns {number}
* @function height
*/
export function height() {
return Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0,
document.body.clientHeight || 0
);
}
/**
* Returns the amount of CSS pixels the document or an optional element is scrolled from top.
* @param {HTMLElement} element - The element to evaluate. Optional.
* @returns {number}
* @function scrollTop
*/
export function scrollTop(element) {
if (element) return element.scrollTop;
return Math.max(
document.documentElement.scrollTop || 0,
window.pageYOffset || 0,
document.body.scrollTop || 0
);
}
/**
* Returns the amount of CSS pixels the document or an optional element is scrolled from left.
* @param {HTMLElement} element - The element to evaluate. Optional.
* @returns {number}
* @function scrollLeft
*/
export function scrollLeft(element) {
if (element) return element.scrollLeft;
return Math.max(
document.documentElement.scrollLeft,
window.pageXOffset || 0,
document.body.scrollLeft || 0
);
}
/**
* Returns the sum of CSS pixels an element and all of its parents are scrolled from top.
* @param {HTMLElement} element - The element to evaluate
* @returns {number}
* @function scrollAllTop
*/
export function scrollAllTop(element) {
let v = 0;
while ((element = element.parentNode)) v += element.scrollTop || 0;
return v;
}
/**
* Returns the sum of CSS pixels an element and all of its parents are scrolled from left.
* @param {HTMLElement} element - The element to evaluate
* @returns {number}
* @function scrollAllLeft
*/
export function scrollAllLeft(element) {
let v = 0;
while ((element = element.parentNode)) v += element.scrollLeft || 0;
return v;
}
/**
* Returns the position from top of an element in relation to the document
* or an optional HTMLElement. Scrolling of the parent is taken into account.
* @param {HTMLElement} element - The element to evaluate
* @param {HTMLElement} relation - The element to use as reference. Optional.
* @returns {number}
* @function positionTop
*/
export function positionTop(e, rel) {
const top = parseInt(e.getBoundingClientRect().top);
const f = fixed(e) ? 0 : scrollTop();
return top + f - (rel ? positionTop(rel) : 0);
}
/**
* Returns the position from the left of an element in relation to the document
* or an optional HTMLElement. Scrolling of the parent is taken into account.
* @param {HTMLElement} element - The element to evaluate
* @param {HTMLElement} relation - The element to use as reference. Optional.
* @returns {number}
* @function positionLeft
*/
export function positionLeft(e, rel) {
const left = parseInt(e.getBoundingClientRect().left);
const f = fixed(e) ? 0 : scrollLeft();
return left + f - (rel ? positionLeft(rel) : 0);
}
/**
* Returns if an element is positioned fixed to the viewport
* @param {HTMLElement} element - the element to evaluate
* @returns {boolean}
* @function fixed
*/
export function fixed(e) {
return getComputedStyle(e).getPropertyValue('position') === 'fixed';
}
/**
* Gets or sets the outer width of an element as CSS pixels. The box sizing
* method is taken into account.
* @param {HTMLElement} element - the element to evaluate / manipulate
* @param {boolean} margin - Determine if margin is included
* @param {number} width - If defined the elements outer width is set to this value
* @param {boolean} notransform - Don't take transformations into account.
* @returns {number}
* @function outerWidth
*/
export function outerWidth(element, margin, width, notransform) {
let m = 0;
if (margin) {
const cs = getComputedStyle(element);
m += parseFloat(cs.getPropertyValue('margin-left'));
m += parseFloat(cs.getPropertyValue('margin-right'));
}
if (width !== void 0) {
if (boxSizing(element) === 'content-box') {
const css = CSSSpace(element, 'padding', 'border');
width -= css.left + css.right;
}
width -= m;
// TODO: fixme
if (width < 0) return 0;
element.style.width = width + 'px';
return width;
} else {
let w;
if (notransform) w = element.offsetWidth;
else w = element.getBoundingClientRect().width;
return w + m;
}
}
/**
* Gets or sets the outer height of an element as CSS pixels. The box sizing
* method is taken into account.
* @param {HTMLElement} element - the element to evaluate / manipulate
* @param {boolean} margin - Determine if margin is included
* @param {number} height - If defined the elements outer height is set to this value
* @param {boolean} notransform - Don't take transformations into account.
* @returns {number}
* @function outerHeight
*/
export function outerHeight(element, margin, height, notransform) {
let m = 0;
if (margin) {
const cs = getComputedStyle(element, null);
m += parseFloat(cs.getPropertyValue('margin-top'));
m += parseFloat(cs.getPropertyValue('margin-bottom'));
}
if (height !== void 0) {
if (boxSizing(element) === 'content-box') {
const css = CSSSpace(element, 'padding', 'border');
height -= css.top + css.bottom;
}
height -= m;
// TODO: fixme
if (height < 0) return 0;
element.style.height = height + 'px';
return height;
} else {
let h;
if (notransform) h = element.offsetHeight;
else h = element.getBoundingClientRect().height;
return h + m;
}
}
/**
* Gets or sets the inner width of an element as CSS pixels. The box sizing
* method is taken into account.
* @param {HTMLElement} element - the element to evaluate / manipulate
* @param {number} width - If defined the elements inner width is set to this value
* @param {boolean} notransform - Don't take transformations into account.
* @returns {number}
* @function innerWidth
*/
export function innerWidth(element, width, notransform) {
const css = CSSSpace(element, 'padding', 'border');
const x = css.left + css.right;
if (width !== void 0) {
if (boxSizing(element) === 'border-box') width += x;
// TODO: fixme
if (width < 0) return 0;
element.style.width = width + 'px';
return width;
} else {
let w;
if (notransform) w = element.offsetWidth;
else w = element.getBoundingClientRect().width;
return w - x;
}
}
/**
* Gets or sets the inner height of an element as CSS pixels. The box sizing
* method is taken into account.
* @param {HTMLElement} element - the element to evaluate / manipulate
* @param {number} height - If defined the elements outer height is set to this value
* @param {boolean} notransform - Don't take transformations into account.
* @returns {number}
* @function innerHeight
*/
export function innerHeight(element, height, notransform) {
const css = CSSSpace(element, 'padding', 'border');
const y = css.top + css.bottom;
if (height !== void 0) {
if (boxSizing(element) === 'border-box') height += y;
// TODO: fixme
if (height < 0) return 0;
element.style.height = height + 'px';
return height;
} else {
let h;
if (notransform) h = element.offsetHeight;
else h = element.getBoundingClientRect().height;
return h - y;
}
}
/**
* Returns the box-sizing method of an HTMLElement.
* @param {HTMLElement} element - The element to evaluate
* @returns {string}
* @function boxSizing
*/
export function boxSizing(element) {
const cs = getComputedStyle(element, null);
if (cs.getPropertyValue('box-sizing'))
return cs.getPropertyValue('box-sizing');
if (cs.getPropertyValue('-moz-box-sizing'))
return cs.getPropertyValue('-moz-box-sizing');
if (cs.getPropertyValue('-webkit-box-sizing'))
return cs.getPropertyValue('-webkit-box-sizing');
if (cs.getPropertyValue('-ms-box-sizing'))
return cs.getPropertyValue('-ms-box-sizing');
if (cs.getPropertyValue('-khtml-box-sizing'))
return cs.getPropertyValue('-khtml-box-sizing');
}
/**
* Returns the overall spacing around an HTMLElement of all given attributes.
* @param {HTMLElement} element - The element to evaluate
* @param{...string} The CSS attributes to take into account
* @returns {object} An object with the members "top", "bottom", "lfet", "right"
* @function CSSSpace
* @example
* CSSSpace(element, "padding", "border");
*/
export function CSSSpace(element) {
const cs = getComputedStyle(element, null);
const o = { top: 0, right: 0, bottom: 0, left: 0 };
let a;
let s;
for (let i = 1; i < arguments.length; i++) {
a = arguments[i];
for (const p in o) {
if (Object.prototype.hasOwnProperty.call(o, p)) {
s = a + '-' + p;
if (a === 'border') s += '-width';
o[p] += parseFloat(cs.getPropertyValue(s));
}
}
}
return o;
}
/**
* Set multiple CSS styles onto an HTMLElement.
* @param {HTMLElement} element - the element to add the styles to
* @param {object} styles - A mapping containing all styles to add
* @function setStyles
* @example
* setStyles(element, {"width":"100px", "height":"100px"});
*/
export function setStyles(elem, styles) {
let key, v;
const s = elem.style;
for (key in styles)
if (Object.prototype.hasOwnProperty.call(styles, key)) {
v = styles[key];
if (typeof v !== 'number' && !v) {
delete s[key];
} else {
s[key] = v;
}
}
}
/**
* Sets a single CSS style onto an HTMLElement. It is used to autimatically
* add "px" to numbers and trim them to 3 digits at max. DEPRECATED!
* @param {HTMLElement} element - The element to set the style to
* @param {string} style - The CSS attribute to set
* @param {string|number} value - The value to set the CSS attribute to
* @function setStyle
*/
export function setStyle(e, style, value) {
if (typeof value === 'number') {
/* By default, numbers are transformed to px. I believe this is a very _dangerous_ default
* behavior, because it breaks other number like properties _without_ warning.
* this is now deprecated. */
warn(
'setStyle: use of implicit px conversion is _deprecated_ and will be removed in the future.'
);
value = value.toFixed(3) + 'px';
}
e.style[style] = value;
}
let _id_cnt = 0;
/**
* Generate a unique ID string.
* @returns {string}
* @function uniqueId
*/
export function uniqueId() {
let id;
do {
id = 'tk-' + _id_cnt++;
} while (document.getElementById(id));
return id;
}
/**
* Check if an object is a DOMNode
* @returns {boolean}
* @function isDomNode
*/
export function isDomNode(o) {
/* this is broken for SVG */
return typeof o === 'object' && o instanceof Node;
}
/**
* Check if an object is a DocumentFragment.
* @returns {boolean}
* @function isDocumentFragment
*/
export function isDocumentFragment(o) {
return typeof o === 'object' && o instanceof DocumentFragment;
}
/**
* True if the current browser supports CSS transforms.
*/
export const supports_transform =
'transform' in document.createElement('div').style;
/**
* Check if a device is touch-enabled.
* @returns {boolean}
* @function isTouch
*/
export function isTouch() {
return (
'ontouchstart' in window || 'onmsgesturechange' in window // works on most browsers
); // works on ie10
}
/**
* Create a unique ID string.
* @returns {string}
* @function createID
*/
export function createID(prefix) {
let id;
while (!id || document.getElementById(id))
id = prefix + Math.random().toString(16).substring(2, 10);
return id;
}
/**
* Get all child elements which can be focused.
* @returns {array}
* @param {HTMLElement} element - The parent element. If omitted, document.body is used.
* @function getFocusableELements
*/
export function getFocusableElements(element) {
element = element || document.body;
const E = element.querySelectorAll(
'[tabindex]:not([tabindex="-1"]), ' +
'a[href]:not([disabled]), ' +
'button:not([disabled]), ' +
'textarea:not([disabled]), ' +
'input[type="text"]:not([disabled]), ' +
'input[type="radio"]:not([disabled]), ' +
'input[type="checkbox"]:not([disabled]), ' +
'select:not([disabled])'
);
return E;
}
/**
* Observe part of the DOM for changes. The callback is called if nodes
* are added or removed from the DOM structire (including subtrees).
*
* @returns {MutationObserver}
* @param {HTMLNode} element - The parent element. If omitted, document.body is used.
* @param {function} callback - The callback function.
* @param {object} options - An object containing options. See
* https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
* Default is `{childList: true, subtree: true}`.
* @function observeDOM
*/
export function observeDOM(element, callback, options) {
element = element || document.body;
options = options || { childList: true, subtree: true };
if (element.nodeType !== 1) return;
const mo = new MutationObserver(callback);
mo.observe(element, options);
return mo;
}
/**
* Set focus to an element after a short timeout. This can be used to
* prevent browsers grabbing focus because of bubbling or collecting.
*
* @param {HTMLElement} element - The element to set focus on.
* @param {Number} [timeout=50] - The timeout to use in milliseconds.
*
* @function setDelayedFocus
*/
export function setDelayedFocus(element, timeout) {
setTimeout((v) => {
element.focus();
}, timeout || 50);
}
/**
* If the given attributeValue is either null nor undefined, the given
* attributeName is removed using removeAttribute. Otherwise, it is
* set using setAttribute.
*/
export function applyAttribute(element, attributeName, attributeValue) {
if (attributeValue !== void 0 && attributeValue !== null) {
element.setAttribute(attributeName, attributeValue);
} else {
element.removeAttribute(attributeName);
}
}
/* Takes false-ish, strings or arrays as input and generates
* an array of class names. Strings will be split on spaces.
*/
export function splitClassNames(input) {
if (!input) return [];
if (Array.isArray(input)) return input;
input = input.toString();
if (!input.includes(' ')) return [input];
return input.split(/\ +/g).filter((tmp) => tmp.length > 0);
}