UNPKG

watch-selector

Version:

Runs a function when a selector is added to dom

508 lines (507 loc) 21.9 kB
/// <reference lib="dom" /> /** * @module * * This module contains functions helpful functions for adding event listeners to the dom. An alternative to web components or selector-observer * * @example * import { watch } from "@doeixd/watch"; * * watch('.say-hello', ({on, state}) => { * state.count = 0 * on('click', () => { * alert(`Hello World${'!'.repeat(++state.count)}`) * }) * }) * */ /** * Creates a new MutationObserver instance and returns a function to observe an element. * @param {MutationCallback} fn - The callback function for the MutationObserver. * @param {MutationObserverInit} options - The options for the MutationObserver. * @returns {(element: Element | string) => void} A function that takes an element (or selector) and observes it with the provided options. */ import * as dntShim from "./_dnt.shims.js"; export function createObserver(fn, options) { const observer = new MutationObserver(fn); return function (element) { if (typeof element == 'string') element = document.querySelector(element) || ''; try { if (element instanceof Node) observer.observe(element, options); } catch (e) { } }; } /** * Ensures that a function runs after the DOM is loaded. * @param {Function} fn - The function to run after the DOM is loaded. */ export function ensureRunAfterDOM(fn) { let handleDOMLoaded = function () { fn(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', handleDOMLoaded); } else { handleDOMLoaded(); } } function isPlatformEventHandler(eventName, handler) { if (typeof handler == 'function' && isEventName(eventName)) return true; return false; } function isGenericEventHandler(name, handler) { if (typeof handler == 'function' && (!isPlatformEventHandler(name, handler) || !isEventName(name)) && !isTestString(name) && !isUnmountString(name)) return true; return false; } function isGenericHandler(name, handler) { if (!isEventName(name) && typeof handler == 'function') return true; return false; } const isAttributeString = (arg) => /^attr/gi.test(arg.trim()); const isTestString = (arg) => /^(?:text|textChange)/gi.test(arg.trim()); const isUnmountString = (arg) => /^(?:unmount|dispose|cleanup)/gi.test(arg.trim()); function isAttributeFilter(arg) { if (typeof arg == 'string' || arg instanceof RegExp) return true; return false; } // type ValidEventNames<T> = keyof EventMapFor<T>; // addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; // type CreateOnFunction<El extends Element = Element> = ( // el: El // ) => <E extends keyof EventMapFor<El>>( // eventName: E, // handler: ((this: El, ev: EventMapFor<El>[E]) => void) | AttributeFilter | GenericHandler<El> | GenericEventHandler<El>, // attrHandlerOrOptions?: EventHandlerOptions, // attrHandlerOptions?: EventHandlerOptions // ) => void; const eventNames = [ ...new Set([ ...Object.getOwnPropertyNames(document), ...Object.getOwnPropertyNames(Object.getPrototypeOf(Object.getPrototypeOf(document))), ...Object.getOwnPropertyNames(Object.getPrototypeOf(dntShim.dntGlobalThis)), ].filter((k) => k.startsWith('on') && (document[k] == null || typeof document[k] == 'function'))), ]; const eventDoesExists = (name = '') => { return eventNames.includes('on' + name.toLowerCase().trim().replace(/^on/i, '')); }; function isEventName(arg) { return eventDoesExists(arg); } function isEventListenerOption(arg) { if (typeof arg == 'object') return true; return false; } // } /** * Watches for DOM elements matching the given selector and calls the setup function when they are added or removed. * @param {string} selector - The CSS selector to watch for. * @param {SetupFn} setup_fn - The function to call when elements matching the selector are added or removed. * @param {WatchOptions} [options] - Options for the watch function. */ export function watch(selector, setup_fn, options = { parent: document, wrapper: ((fn) => (args) => fn(args)) }) { var parent = options?.parent; var wrapper = options?.wrapper; /** * A WeakMap to store state associated with DOM elements. * @type {WeakMap} */ //@ts-expect-error var states = globalThis?.states ?? new WeakMap(); /** * A WeakMap to store cleanup functions associated with DOM elements. * @type {WeakMap} */ //@ts-expect-error var remove = globalThis?.remove ?? new WeakMap(); /** * A WeakSet to track DOM elements that have been set up. * @type {WeakSet} */ //@ts-expect-error var setups = globalThis?.setups ?? new WeakSet(); const setup = (args) => { //@ts-expect-error globalThis.states ??= states; //@ts-expect-error globalThis.remove ??= remove; //@ts-expect-error globalThis.setups ??= setups; const cleanup = () => { if (remove.has(args.el)) { let fns = remove.get(args.el); if (fns) fns.forEach((fn) => fn?.(args)); } setups.delete(args.el); remove.delete(args.el); states.delete(args.el); }; if (args.el instanceof HTMLElement) { (args.style = applyInlineStylesToElement(args.el)); } args.cleanup = cleanup; if (setups.has(args.el)) return; wrapper(setup_fn)(args); setups.add(args.el); }; /** * Creates an "on" function for attaching event listeners to a specific element. * * This function supports: * - Standard DOM events (e.g., 'click', 'submit') * - Attribute change observation ('attr' or 'attribute') * - Text content changes ('text', 'textChanged', 'textChange') * - Unmount/cleanup handlers ('unmount', 'dispose', 'cleanup') * - Custom generic event handlers (functions not matching the above) * * @template El1 The type of the element this function is created for. * * @param el - The target element to attach events to. * * @returns A function (`on`) for adding event listeners. */ function createOnFunction(el) { /** * Attaches an event listener to the element associated with the `createOnFunction` call. * * @template El The base element type. * @template Type The type of event or action being handled. * * @param type - The type of event or action. Can be: * - A standard DOM event name (e.g., 'click', 'submit') * - 'attr' or 'attribute' to watch for attribute changes * - 'text', 'textChanged', or 'textChange' to watch for text content changes * - 'unmount', 'dispose', or 'cleanup' to register a cleanup handler * - A custom event name or any string to trigger a generic event handler * * @param handlerOrAttributeFilter - The event handler function or attribute filter: * - For DOM events: A function to be called when the event occurs. * - For attribute changes: A string (attribute name) or RegExp to filter attributes. * - For text changes: A generic handler function for character data changes. * - For unmount/cleanup: A generic handler function to be called on element removal. * - For custom or generic events: A function to handle the event. * * @param optionsOrAttributeHandler - (Optional) * - For DOM events: An `AddEventListenerOptions` object. * - For attribute changes: A generic handler function for attribute changes. * - For other types: Typically unused. */ return function on(type, handlerOrAttributeFilter, optionsOrAttributeHandler) { // eventName = eventName.trim().toLowerCase(); if (isEventName(type) && !isAttributeString(type) && typeof type === 'string') { if (isEventListenerOption(optionsOrAttributeHandler) && !isAttributeFilter(handlerOrAttributeFilter) && isPlatformEventHandler(type, handlerOrAttributeFilter) && typeof type === 'string') { el.addEventListener(type, handlerOrAttributeFilter, optionsOrAttributeHandler); return; } if (handlerOrAttributeFilter && !isAttributeFilter(handlerOrAttributeFilter) && isPlatformEventHandler(type, handlerOrAttributeFilter)) { el.addEventListener(type, handlerOrAttributeFilter); return; } return; } if (isAttributeString(type) && isAttributeFilter(handlerOrAttributeFilter)) { let attr = handlerOrAttributeFilter; createObserver((mutations) => { for (let mutation of mutations) { let shouldRun = false; if (typeof attr == 'string') { if (mutation.attributeName == attr) shouldRun = true; } if (attr instanceof RegExp) { if (attr.test(mutation?.attributeName || '')) shouldRun = true; } if (shouldRun && typeof optionsOrAttributeHandler == 'function') optionsOrAttributeHandler({ el, selector, record: mutation }); } }, { attributes: true, attributeOldValue: true, })(el); return; } // let t_event =eventName as AttributeString if (isTestString(type) && isGenericHandler(type, handlerOrAttributeFilter)) { Array.from(el.childNodes) .filter((e) => e.nodeType === Node.TEXT_NODE && e?.textContent?.trim?.()) .forEach((_el) => { createObserver((mutations) => { for (let mutation of mutations) { if (typeof handlerOrAttributeFilter == 'function') handlerOrAttributeFilter({ el: _el, selector, record: mutation }); } }, { subtree: true, characterData: true, characterDataOldValue: true, })(_el); }); return; } if (['unmount', 'remove', 'dispose', 'cleanup'].includes(type)) { if (!remove.get(el)) remove.set(el, []); let re = remove.get(el); if (re && typeof handlerOrAttributeFilter == 'function') re.push(handlerOrAttributeFilter); return; } if (!isAttributeString(type) && isEventName(type) && isGenericEventHandler(type, handlerOrAttributeFilter)) { if (isEventListenerOption(optionsOrAttributeHandler)) el?.addEventListener(type, handlerOrAttributeFilter, optionsOrAttributeHandler); return; } if (handlerOrAttributeFilter && isGenericEventHandler(type, handlerOrAttributeFilter)) el?.addEventListener(type, handlerOrAttributeFilter); }; } document.querySelectorAll(selector).forEach((el, idx, arr) => { let state = states.has(el) ? states.get(el) : {}; states.set(el, state); setup({ on: createOnFunction(el), state, el, idx, arr }); }); createObserver((mutations) => { for (let mutation of mutations) { if (!(mutation.target instanceof Element)) continue; if (mutation.target instanceof Element && mutation.target?.matches?.(selector)) { let el = mutation.target; if (states.has(el)) continue; let state = states.has(el) ? states.get(el) : {}; states.set(el, state); setup({ on: createOnFunction(el), state, record: mutation, arr: [el], idx: 0, el }); } if (mutation?.addedNodes?.length) { let idx = -1; for (let el of mutation.addedNodes) { idx += 1; if (!(mutation.target instanceof Element)) continue; if (el instanceof Element && el?.matches?.(selector)) { let state = states.has(el) ? states.get(el) : {}; states.set(el, state); setup({ on: createOnFunction(el), state, record: mutation, arr: mutation?.addedNodes ?? [], idx, el, }); } if (el instanceof Element) el?.querySelectorAll?.(selector).forEach((el, idx, arr) => { let state = states.has(el) ? states.get(el) : {}; states.set(el, state); setup({ on: createOnFunction(el), state, record: mutation, arr, idx, el }); }); } } if (mutation?.removedNodes?.length) { for (let el of mutation.removedNodes) { if (!mutation.target instanceof Element) continue; if (el instanceof Element) el?.querySelectorAll?.(selector).forEach((el, idx, arr) => { let state = states.has(el) ? states.get(el) : {}; states.set(el, state); setup({ on: createOnFunction(el), state, record: mutation, arr, idx, el }); if (remove.has(el)) { let r = remove.get(el); if (r) r.forEach((fn) => fn({ on: createOnFunction(el), state, record: mutation, arr, idx, el })); } }); } } } }, { subtree: true, childList: true, attributes: true, })(parent || document); } /** * Converts an object or value to a JSON string with indentation. * @param {Object|*} obj - The object or value to convert to JSON. * @returns {string} The JSON string representation of the input. */ export function JS(obj) { return JSON.stringify(obj, null, 2); } /** * Creates a deep copy of an object using JSON serialization and deserialization. * @param {Object} obj - The object to clone. * @returns {Object} The cloned object. */ export function clone(obj) { return JSON.parse(JSON.stringify(obj)); } /** * Applies inline styles to an element and returns a function to apply or revert the styles. * @param {HTMLElement} element - The element to apply styles to. * @returns {(styles: CSSStyleDeclaration) => { apply: (additionalStyles?: CSSStyleDeclaration) => void, revert: () => void }} A function that takes a {@link CSSStyleDeclaration} object and returns an object with `apply` and `revert` methods for applying and reverting inline styles to the element. */ export function applyInlineStylesToElement(element) { return function (styles) { const oldStyles = JSON.parse(JSON.stringify(element.style)); const applyStyles = (obj) => { Object.entries(obj).forEach(([key, value]) => { element.style[key] = value; }); }; return { apply: (additionalStyles) => { applyStyles({ ...styles, ...additionalStyles }); }, revert: () => { applyStyles(oldStyles); }, }; }; } /** * Creates an HTMLElement from an HTML string. * @param {string} html - The HTML string to convert. * @returns {unknown} The created HTMLElement. */ export function html(html) { var template = document.createElement('template'); html = html.trim(); template.innerHTML = html; return template.content.firstChild; } /** * Finds the first element that matches the given selector(s). * @param {string} selector - The CSS selector(s) to match. * @param {HTMLElement | ParentNode | null} [parent] - The parent element to search within. * @returns {HTMLElement | null} The first matching element, or null if none is found. */ export const find = (selector, parent) => (parent || document).querySelector(selector); /** * Finds all elements that match the given selector(s). * @param {string | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap} selectors - The CSS selector(s) to match. * @param {HTMLElement | ParentNode | null} [parent] - The parent element to search within. * @returns {NodeListOf<Element> | NodeListOf<HTMLElementTagNameMap[K]> | NodeListOf<SVGElementTagNameMap[K]>} A {@link NodeList} of matching elements. */ export const findAll = (selector, parent) => Array.from((parent || document).querySelectorAll(selector)); /** * Converts a color value to the specified color space. * @param {string} color - The color value to convert. * @param {string} toSpace - The color space to convert to (e.g., 'srgb', 'display-p3', 'a98-rgb', 'rec2020', 'prophoto-rgb'). * @returns {string} The converted color value in the specified color space. */ export function convertColor(color, toSpace) { let div = document.createElement('div'); div.style.color = `color-mix(in ${toSpace}, ${color} 100%, transparent)`; div.style.display = 'none'; document.body.appendChild(div); let result = globalThis.getComputedStyle(div).color; div.remove(); return result; } /** * Sets a query parameter in the URL. * @param {string} key - The key of the query parameter. * @param {string} value - The value of the query parameter. * @param {string} [type='soft'] - The type of URL update ('soft' for history state change, 'hard' for full page reload). */ export function setQueryParam(key, value, type = 'soft') { const url = new URL(globalThis.location.toString()); url.searchParams.set(key, value); if (type == 'hard') globalThis.location.search = url.href; if (type == 'soft') history.pushState(Object.fromEntries(url.searchParams.entries()), '', url.href); } /** * Gets the value of a query parameter from the URL. * @param {string} key - The key of the query parameter. * @returns {string|null} The value of the query parameter, or null if it doesn't exist. */ export function getQueryParam(key) { const urlParams = new URLSearchParams(globalThis.location.search); return urlParams.get(key); } /** * Gets all values of a query parameter from the URL. * @param {string} key - The key of the query parameter. * @returns {string[]} An array of all values for the query parameter. */ export function getAllQueryParam(key) { const urlParams = new URLSearchParams(globalThis.location.search); return urlParams.getAll(key); } /** * An object containing functions to interact with URL query parameters. * @type {Params} * @property {typeof getQueryParam} get - Gets the value of a query parameter. * @property {typeof setQueryParam} set - Sets a query parameter. * @property {typeof getAllQueryParam} getAll - Gets all values of a query parameter. */ export const Params = { get: getQueryParam, set: setQueryParam, getAll: getAllQueryParam, }; /** * Matches a regular expression against a value and returns the first match. * @param {RegExp} regex - The regular expression to match. * @param {string} value - The value to match against. * @returns {string|undefined} The first match, or null if no match is found. */ export function match(regex, value) { return String(value).match(regex)?.[0]; } /** * Waits for the specified amount of time and then resolves with the result of the provided function. * @param {number} ms - The number of milliseconds to wait. * @param {Function} fn - The function to execute after the wait. * @returns {Promise} A Promise that resolves with the result of the provided function. */ export function wait(ms, fn) { return new Promise((resolve) => { setTimeout(() => resolve(fn().then((l) => l)), ms); }); } /** * Creates a new `CustomEvent` object with optional data and initialization options. * * @param name - The name of the event. * @param data - (Optional) An object containing arbitrary data associated with the event. * @param options - (Optional) An object containing options for event initialization (e.g., bubbles, cancelable). * @returns A new `CustomEvent` object. */ export function createEvent(name, data, options) { if (typeof options === 'object') { return new CustomEvent(name, { detail: data, ...options, }); } return new CustomEvent(name, { detail: data, }); } /** * Triggers a custom event on all elements matching a given selector. * * @param selector - A CSS selector to identify the target elements. * @param event - A function that takes an element as input and returns the `Event` object to be dispatched. */ export function trigger(selector, event) { findAll(selector).forEach(el => el.dispatchEvent(event(el))); }