UNPKG

enketo-core

Version:

Extensible Enketo form engine

517 lines (462 loc) 14.6 kB
/** * @module dom-utils */ /** * Gets siblings that match selector and self _in DOM order_. * * @static * @param {Node} element - Target element. * @param {string} [selector] - A CSS selector for siblings (not for self). * @return {Array<Node>} Array of sibling nodes plus target element. */ function getSiblingElementsAndSelf(element, selector) { return _getSiblingElements(element, selector, true); } /** * Gets siblings that match selector _in DOM order_. * * @static * @param {Node} element - Target element. * @param {string} [selector] - A CSS selector. * @return {Array<Node>} Array of sibling nodes. */ function getSiblingElements(element, selector) { return _getSiblingElements(element, selector); } /** * Returns first sibling element (in DOM order) that optionally matches the provided selector. * * @param {Node} element - Target element. * @param {string} [selector] - A CSS selector. * @return {Node} First sibling element in DOM order */ function getSiblingElement(element, selector = '*') { let found; let current = element.parentElement.firstElementChild; while (current && !found) { if (current !== element && current.matches(selector)) { found = current; } current = current.nextElementSibling; } return found; } /** * Gets siblings that match selector _in DOM order_. * * @param {Node} element - Target element. * @param {string} [selector] - A CSS selector. * @param {boolean} [includeSelf] - Whether to include self. * @return {Array<Node>} Array of sibling nodes. */ function _getSiblingElements(element, selector = '*', includeSelf = false) { const results = []; let current = element.parentElement.firstElementChild; while (current) { if ( (current === element && includeSelf) || (current !== element && current.matches(selector)) ) { results.push(current); } current = current.nextElementSibling; } return results; } /** * Gets ancestors that match selector _in DOM order_. * * @static * @param {Node} element - Target element. * @param {string} [filterSelector] - A CSS selector. * @param {string} [endSelector] - A CSS selector indicating where to stop. It will include this element if matched by the filter. * @return {Array<Node>} Array of ancestors. */ function getAncestors(element, filterSelector = '*', endSelector = null) { const ancestors = []; let parent = element.parentElement; while (parent) { if (parent.matches(filterSelector)) { // document order ancestors.unshift(parent); } parent = endSelector && parent.matches(endSelector) ? null : parent.parentElement; } return ancestors; } /** * Gets closest ancestor that match selector until the end selector. * * @static * @param {Node} element - Target element. * @param {string} filterSelector - A CSS selector. * @param {string} [endSelector] - A CSS selector indicating where to stop. It will include this element if matched by the filter. * @return {Node} Closest ancestor. */ function closestAncestorUntil( element, filterSelector = '*', endSelector = null ) { let parent = element.parentElement; let found = null; while (parent && !found) { if (parent.matches(filterSelector)) { found = parent; } parent = endSelector && parent.matches(endSelector) ? null : parent.parentElement; } return found; } /** * Gets child elements, that (optionally) match a selector. * * @param {Node} element - Target element. * @param {string} selector - A CSS selector. * @return {Array<Node>} Array of child elements. */ function getChildren(element, selector = '*') { return [...element.children].filter((el) => el.matches(selector)); } /** * Gets first child element, that (optionally) matches a selector. * * @param {Node} element - Target element. * @param {string} selector - A CSS selector. * @return {Node} - First child element. */ function getChild(element, selector = '*') { return [...element.children].find((el) => el.matches(selector)); } /** * Removes all children elements. * * @static * @param {Node} element - Target element. * @return {undefined} */ function empty(element) { [...element.children].forEach((el) => el.remove()); } /** * @param {Element} el - Target node * @return {boolean} Whether previous sibling has the same node name */ function hasPreviousSiblingElementSameName(el) { let found = false; const { nodeName } = el; el = el.previousSibling; while (el) { // Ignore any sibling text and comment nodes (e.g. whitespace with a newline character) // also deal with repeats that have non-repeat siblings in between them, event though that would be a bug. if (el.nodeName && el.nodeName === nodeName) { found = true; break; } el = el.previousSibling; } return found; } /** * @param {Element} el - Target node * @return {boolean} Whether next sibling has the same node name */ function hasNextSiblingElementSameName(el) { let found = false; const { nodeName } = el; el = el.nextSibling; while (el) { // Ignore any sibling text and comment nodes (e.g. whitespace with a newline character) // also deal with repeats that have non-repeat siblings in between them, event though that would be a bug. if (el.nodeName && el.nodeName === nodeName) { found = true; break; } el = el.nextSibling; } return found; } /** * @param {Element} el - Target node * @return {boolean} Whether a sibling has the same node name */ function hasSiblingElementSameName(el) { return ( hasNextSiblingElementSameName(el) || hasPreviousSiblingElementSameName(el) ); } /** * @param {Element} node - Target node * @param {string} content - Text content to look for * @return {boolean} Whether previous comment sibling has given text content */ function hasPreviousCommentSiblingWithContent(node, content) { let found = false; node = node.previousSibling; while (node) { if ( node.nodeType === Node.COMMENT_NODE && node.textContent === content ) { found = true; break; } node = node.previousSibling; } return found; } /** * Creates an XPath from a node * * @param {Element} node - XML node * @param {string} [rootNodeName] - Defaults to #document * @param {boolean} [includePosition] - Whether or not to include the positions `/path/to/repeat[2]/node` * @return {string} XPath */ function getXPath(node, rootNodeName = '#document', includePosition = false) { let index; const steps = []; let position = ''; if (!node || node.nodeType !== 1) { return null; } const { nodeName } = node; let parent = node.parentElement; let parentName = parent ? parent.nodeName : null; if (includePosition) { index = getRepeatIndex(node); if (index > 0) { position = `[${index + 1}]`; } } steps.push(nodeName + position); while ( parent && parentName !== rootNodeName && parentName !== '#document' ) { if (includePosition) { index = getRepeatIndex(parent); position = hasSiblingElementSameName(parent) ? `[${index + 1}]` : ''; } steps.push(parentName + position); parent = parent.parentElement; parentName = parent ? parent.nodeName : null; } return `/${steps.reverse().join('/')}`; } /** * Obtains the index of a repeat instance within its own series. * * @param {Element} node - XML node * @return {number} index */ function getRepeatIndex(node) { let index = 0; const { nodeName } = node; let prevSibling = node.previousSibling; while (prevSibling) { // ignore any sibling text and comment nodes (e.g. whitespace with a newline character) if (prevSibling.nodeName && prevSibling.nodeName === nodeName) { index++; } prevSibling = prevSibling.previousSibling; } return index; } /** * Adapted from https://stackoverflow.com/a/46522991/3071529 * * A storage solution aimed at replacing jQuerys data function. * Implementation Note: Elements are stored in a (WeakMap)[https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap]. * This makes sure the data is garbage collected when the node is removed. * * @namespace */ const elementDataStore = { /** * @type {WeakMap} */ _storage: new WeakMap(), /** * Adds object to element storage. Ensures that element storage exist. * * @param {Node} element - target element * @param {string} key - name of the stored data * @param {object} obj - stored data */ put(element, key, obj) { if (!this._storage.has(element)) { this._storage.set(element, new Map()); } this._storage.get(element).set(key, obj); }, /** * Return object from element storage. * * @param {Node} element - target element * @param {string} key - name of the stored data * @return {object} stored data object */ get(element, key) { const item = this._storage.get(element); return item ? item.get(key) : item; }, /** * Checkes whether element has given storage item. * * @param {Node} element - target element * @param {string} key - name of the stored data * @return {boolean} whether data is present */ has(element, key) { const item = this._storage.get(element); return item && item.has(key); }, /** * Removes item from element storage. Removes element storage if empty. * * @param {Node} element - target element * @param {string} key - name of the stored data * @return {object} removed data object */ remove(element, key) { const ret = this._storage.get(element).delete(key); if (!this._storage.get(key).size === 0) { this._storage.delete(element); } return ret; }, }; class MutationsTracker { constructor(el = document.documentElement) { let currentMutations = 0; let previousMutations = currentMutations; this.classChanges = new WeakMap(); this.quiet = true; const mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { currentMutations++; if ( mutation.type === 'attributes' && mutation.attributeName === 'class' ) { const trackedClasses = this.classChanges.get(mutation.target) || []; trackedClasses.forEach((obj) => { if (mutation.target.classList.contains(obj.className)) { obj.completed = true; this.classChanges.set( mutation.target, trackedClasses ); } }); } }); }); mutationObserver.observe(el, { attributes: true, characterData: true, childList: true, subtree: true, attributeOldValue: true, characterDataOldValue: true, }); const checkInterval = setInterval(() => { if (previousMutations === currentMutations) { this.quiet = true; mutationObserver.disconnect(); clearInterval(checkInterval); } else { this.quiet = false; previousMutations = currentMutations; } }, 100); } _resolveWhenTrue(fn) { if (typeof fn !== 'function') { return Promise.reject(); } return new Promise((resolve) => { const checkInterval = setInterval(() => { if (fn.call(this)) { clearInterval(checkInterval); resolve(); } }, 10); }); } waitForClassChange(element, className) { const trackedClasses = this.classChanges.get(element) || []; if (!trackedClasses.some((obj) => obj.className === className)) { trackedClasses.push({ className }); this.classChanges.set(element, trackedClasses); } return this._resolveWhenTrue( () => this.classChanges .get(element) .find((obj) => obj.className === className).completed ); } waitForQuietness() { return this._resolveWhenTrue(() => this.quiet); } } /** @type {HTMLElement | null} */ let scrollIntoViewTarget = null; const intersectionObserver = new IntersectionObserver((records) => { for (const { target, isIntersecting } of records) { if (target === scrollIntoViewTarget && !isIntersecting) { target.scrollIntoView({ block: 'nearest', inline: 'nearest', }); } intersectionObserver.unobserve(target); } }); /** * Roughly equivalent to the non-standard * {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded | Element.scrollIntoViewIfNeeded}, * but scrolls to the nearest edges of the viewport. * * @param {HTMLElement} element */ const scrollIntoViewIfNeeded = (element) => { scrollIntoViewTarget = element; intersectionObserver.observe(element); }; export { /** * @static * @see {@link module:dom-utils~elementDataStore|elementDataStore} */ elementDataStore, getSiblingElementsAndSelf, getSiblingElements, getSiblingElement, getAncestors, getChildren, getChild, getRepeatIndex, getXPath, hasPreviousCommentSiblingWithContent, hasPreviousSiblingElementSameName, hasNextSiblingElementSameName, hasSiblingElementSameName, closestAncestorUntil, empty, MutationsTracker, scrollIntoViewIfNeeded, };