tiny-essentials
Version:
Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.
267 lines (266 loc) • 9.12 kB
JavaScript
/**
* Callback type used for element mutation detectors.
*
* @callback ElementDetectorsFn
* @param {MutationRecord} mutation - Single mutation record being processed.
* @param {number} index - Index of the current mutation in the batch.
* @param {MutationRecord[]} mutations - Full list of mutation records from the observer callback.
* @returns {void}
*/
/**
* TinyElementObserver
*
* A utility class for tracking DOM element mutations.
* It leverages the native MutationObserver API, providing a higher-level abstraction
* with a system of configurable detectors that can dispatch custom events or run custom logic.
*/
class TinyElementObserver {
/** @type {Element|undefined} */
#el;
/**
* Get the current element being observed.
* @returns {Element|undefined} The DOM element being tracked, or `undefined` if none is set.
*/
get el() {
return this.#el;
}
/**
* Set the target element to be observed.
* Can only be set once.
*
* @param {Element|undefined} el - The DOM element to observe.
* @throws {Error} If the element is already defined.
* @throws {TypeError} If the provided value is not an Element.
*/
set el(el) {
if (this.#el)
throw new Error('The observed element has already been set and cannot be reassigned.');
if (typeof el !== 'undefined' && !(el instanceof Element))
throw new TypeError('The observed element must be a valid DOM Element.');
this.#el = el;
}
/**
* Configuration settings for the MutationObserver instance.
*
* @type {MutationObserverInit}
*/
#settings = {};
/**
* Get the observer settings.
* @returns {MutationObserverInit}
*/
get settings() {
return this.#settings;
}
/**
* Set the observer settings.
* @param {MutationObserverInit} settings
*/
set settings(settings) {
if (typeof settings !== 'object' || settings === null)
throw new TypeError('settings must be a non-null object.');
this.#settings = settings;
}
/**
* Internal MutationObserver instance that tracks DOM attribute changes.
* @type {MutationObserver|null}
*/
#observer = null;
/**
* Get the current MutationObserver instance.
* @returns {MutationObserver|null}
*/
get observer() {
return this.#observer;
}
/**
* List of detectors executed on observed mutations.
* Each detector is a tuple:
* - name: string identifier
* - handler: function processing MutationRecords
*
* @type {Array<[string, ElementDetectorsFn]>}
*/
#detectors = [];
/**
* Get the element detectors.
* @returns {Array<[string, ElementDetectorsFn]>}
*/
get detectors() {
return this.#detectors.map((item) => [item[0], item[1]]);
}
/**
* Set the element detectors.
* @param {Array<[string, ElementDetectorsFn]>} detectors
*/
set detectors(detectors) {
if (!Array.isArray(detectors))
throw new TypeError('detectors must be an array.');
/** @type {Array<[string, ElementDetectorsFn]>} */
const values = [];
for (const [name, fn] of detectors) {
if (typeof name !== 'string')
throw new TypeError('Detector name must be a string.');
if (typeof fn !== 'function')
throw new TypeError(`Detector handler for "${name}" must be a function.`);
values.push([name, fn]);
}
this.#detectors = values;
}
/**
* Returns true if a MutationObserver is currently active.
* @returns {boolean}
*/
get isActive() {
return !!this.#observer;
}
/**
* Get the number of registered detectors.
*
* @returns {number} Total count of detectors.
*/
get size() {
return this.#detectors.length;
}
/**
* Create a new TinyElementObserver instance.
*
* @param {Object} [settings={}] - Configuration object.
* @param {Element} [settings.el] - Optional DOM element to observe from the start.
* @param {Array<[string, ElementDetectorsFn]>} [settings.initDetectors=[]] - Optional initial detectors to register.
* @param {MutationObserverInit} [settings.initCfg] - Optional MutationObserver configuration.
*/
constructor({ el, initDetectors = [], initCfg = {} } = {}) {
this.el = el;
if (initDetectors.length)
this.detectors = initDetectors;
if (initCfg)
this.settings = initCfg;
}
/**
* Remove all registered detectors.
* After calling this, no mutation events will be processed
* until new detectors are added again.
*/
clear() {
this.#detectors = [];
}
/**
* Start tracking DOM mutations on the defined element.
*
* @throws {Error} If no element has been set to observe.
*/
start() {
if (!this.#el)
throw new Error('Cannot start observation: no target element has been set.');
if (this.#observer)
return;
this.#observer = new MutationObserver((mutations) => {
mutations.forEach((value, index, array) => this.#detectors.forEach((item) => item[1](value, index, array)));
});
this.#observer.observe(this.#el, this.#settings);
}
/**
* Stop tracking changes.
*/
stop() {
if (!this.#observer)
return;
this.#observer.disconnect();
this.#observer = null;
}
// ================= Detectors Editor =================
/**
* Add a detector to the end of the array.
* @param {string} name
* @param {ElementDetectorsFn} handler
*/
add(name, handler) {
this.#validateDetector(name, handler);
this.#detectors.push([name, handler]);
}
/**
* Add a detector to the start of the array.
* @param {string} name
* @param {ElementDetectorsFn} handler
*/
insertAtStart(name, handler) {
this.#validateDetector(name, handler);
this.#detectors.unshift([name, handler]);
}
/**
* Insert a detector at a specific index.
* @param {number} index
* @param {string} name
* @param {ElementDetectorsFn} handler
* @param {'before'|'after'} position - Position relative to the index
*/
insertAt(index, name, handler, position = 'after') {
this.#validateDetector(name, handler);
if (typeof index !== 'number' || index < 0 || index >= this.#detectors.length)
throw new RangeError('Invalid index for insertDetectorAt.');
const insertIndex = position === 'before' ? index : index + 1;
this.#detectors.splice(insertIndex, 0, [name, handler]);
}
/**
* Remove a detector at a specific index.
* @param {number} index
*/
removeAt(index) {
if (typeof index !== 'number' || index < 0 || index >= this.#detectors.length)
throw new RangeError('Invalid index for removeDetectorAt.');
this.#detectors.splice(index, 1);
}
/**
* Remove detectors relative to a specific index.
* @param {number} index - Reference index
* @param {number} before - Number of items before the index to remove
* @param {number} after - Number of items after the index to remove
*/
removeAround(index, before = 0, after = 0) {
if (typeof index !== 'number' || index < 0 || index >= this.#detectors.length)
throw new RangeError('Invalid index for removeDetectorsAround.');
const start = Math.max(0, index - before);
const deleteCount = before + 1 + after;
this.#detectors.splice(start, deleteCount);
}
/**
* Check if a detector exists at a specific index.
* @param {number} index
* @returns {boolean}
*/
isIndexUsed(index) {
return index >= 0 && index < this.#detectors.length;
}
/**
* Check if a handler function already exists in the array.
* @param {ElementDetectorsFn} handler
* @returns {boolean}
*/
hasHandler(handler) {
if (typeof handler !== 'function')
throw new TypeError('Handler must be a function.');
return this.#detectors.some(([_, fn]) => fn === handler);
}
/**
* Internal validation for detector entries.
* @param {string} name
* @param {ElementDetectorsFn} handler
*/
#validateDetector(name, handler) {
if (typeof name !== 'string' || !name.trim())
throw new TypeError('Detector name must be a non-empty string.');
if (typeof handler !== 'function')
throw new TypeError(`Detector handler for "${name}" must be a function.`);
}
/**
* Completely destroy this observer instance.
* Stops the MutationObserver (if active) and clears all detectors,
* leaving the instance unusable until reconfigured.
*/
destroy() {
this.stop();
this.clear();
}
}
export default TinyElementObserver;