diff --git a/src/customElement.js b/src/customElement.js index c34b911..33eb0ee 100644 --- a/src/customElement.js +++ b/src/customElement.js @@ -1,6 +1,15 @@ import { keyLTE, evc, evd, eva } from "./dom-common.js"; import { scope } from "./dom.js"; import { c_ch_o } from "./events-observer.js"; + +/** + * Renders content into a custom element or shadow root + * + * @param {Element|ShadowRoot} target - The custom element or shadow root to render into + * @param {Function} render - The render function that returns content + * @param {Function|Object} [props=observedAttributes] - Props to pass to the render function + * @returns {Node} The rendered content + */ export function customElementRender(target, render, props= observedAttributes){ const custom_element= target.host || target; scope.push({ @@ -17,6 +26,13 @@ export function customElementRender(target, render, props= observedAttributes){ scope.pop(); return target.append(out); } + +/** + * Transforms custom element lifecycle callbacks into events + * + * @param {Function|Object} class_declaration - Custom element class or instance + * @returns {Function|Object} The modified class or instance + */ export function lifecyclesToEvents(class_declaration){ wrapMethod(class_declaration.prototype, "connectedCallback", function(target, thisArg, detail){ target.apply(thisArg, detail); @@ -38,12 +54,30 @@ export function lifecyclesToEvents(class_declaration){ class_declaration.prototype[keyLTE]= true; return class_declaration; } + +/** Public API */ export { lifecyclesToEvents as customElementWithDDE }; + +/** + * Wraps a method with a proxy to intercept calls + * + * @param {Object} obj - Object containing the method + * @param {string} method - Method name to wrap + * @param {Function} apply - Function to execute when method is called + * @private + */ function wrapMethod(obj, method, apply){ obj[method]= new Proxy(obj[method] || (()=> {}), { apply }); } import { observedAttributes as oA } from "./helpers.js"; + +/** + * Gets observed attributes for a custom element + * + * @param {Element} instance - Custom element instance + * @returns {Object} Object mapping camelCase attribute names to their values + */ export function observedAttributes(instance){ return oA(instance, (i, n)=> i.getAttribute(n)); } diff --git a/src/dom-common.js b/src/dom-common.js index 01eb83f..d4debe6 100644 --- a/src/dom-common.js +++ b/src/dom-common.js @@ -1,3 +1,15 @@ +/** + * Environment configuration and globals for the library + * @typedef {Object} Environment + * @property {typeof setDeleteAttr} setDeleteAttr - Function to safely set or delete attributes + * @property {string} ssr - Server-side rendering flag + * @property {Document} D - Document global + * @property {typeof DocumentFragment} F - DocumentFragment constructor + * @property {typeof HTMLElement} H - HTMLElement constructor + * @property {typeof SVGElement} S - SVGElement constructor + * @property {typeof MutationObserver} M - MutationObserver constructor + * @property {Function} q - Promise wrapper for Promse queue feature + */ export const enviroment= { setDeleteAttr, ssr: "", @@ -9,16 +21,25 @@ export const enviroment= { q: p=> p || Promise.resolve(), }; import { isUndef } from './helpers.js'; + +/** + * Handles attribute setting with special undefined handling + * + * @param {Object} obj - The object to set the property on + * @param {string} prop - The property name + * @param {any} val - The value to set + * @returns {void} + * + * Issue: + * For some native attrs you can unset only to set empty string. + * This can be confusing as it is seen in inspector `<… id=""`. + * Options: + * 1. Leave it, as it is native behaviour + * 2. Sets as empty string and removes the corresponding attribute when also has empty string + * 3. (*) Sets as undefined and removes the corresponding attribute when "undefined" string discovered + * 4. Point 2. with checks for coincidence (e.g. use special string) + */ function setDeleteAttr(obj, prop, val){ - /* Issue - For some native attrs you can unset only to set empty string. - This can be confusing as it is seen in inspector `<… id=""`. - Options: - 1. Leave it, as it is native behaviour - 2. Sets as empty string and removes the corresponding attribute when also has empty string - 3. (*) Sets as undefined and removes the corresponding attribute when "undefined" string discovered - 4. Point 2. with checks for coincidence (e.g. use special string) - */ Reflect.set(obj, prop, val); if(!isUndef(val)) return; Reflect.deleteProperty(obj, prop); @@ -27,7 +48,15 @@ function setDeleteAttr(obj, prop, val){ if(Reflect.get(obj, prop)==="undefined") return Reflect.set(obj, prop, ""); } + +/** Property key for tracking lifecycle events */ export const keyLTE= "__dde_lifecyclesToEvents"; //boolean + +/** Event name for connected lifecycle event */ export const evc= "dde:connected"; + +/** Event name for disconnected lifecycle event */ export const evd= "dde:disconnected"; + +/** Event name for attribute changed lifecycle event */ export const eva= "dde:attributeChanged"; diff --git a/src/dom.js b/src/dom.js index fd4e3a1..472e7d4 100644 --- a/src/dom.js +++ b/src/dom.js @@ -1,38 +1,103 @@ import { signals } from "./signals-lib/common.js"; import { enviroment as env } from './dom-common.js'; -//TODO: add type, docs ≡ make it public +/** + * Queues a promise, this is helpful for crossplatform components (on server side we can wait for all registered + * promises to be resolved before rendering). + * @param {Promise} promise - Promise to process + * @returns {Promise} Processed promise + */ export function queue(promise){ return env.q(promise); } -/** @type {{ scope: object, prevent: boolean, host: function }[]} */ + +/** + * Array of scope contexts for tracking component hierarchies + * @type {{ scope: object, prevent: boolean, host: function }[]} + */ const scopes= [ { get scope(){ return env.D.body; }, host: c=> c ? c(env.D.body) : env.D.body, prevent: true, } ]; +/** + * Scope management utility for tracking component hierarchies + */ export const scope= { + /** + * Gets the current scope + * @returns {Object} Current scope context + */ get current(){ return scopes[scopes.length-1]; }, + + /** + * Gets the host element of the current scope + * @returns {Function} Host accessor function + */ get host(){ return this.current.host; }, + /** + * Prevents default behavior in the current scope + * @returns {Object} Current scope context + */ preventDefault(){ const { current }= this; current.prevent= true; return current; }, + /** + * Gets a copy of the current scope stack + * @returns {Array} Copy of scope stack + */ get state(){ return [ ...scopes ]; }, + + /** + * Pushes a new scope to the stack + * @param {Object} [s={}] - Scope object to push + * @returns {number} New length of the scope stack + */ push(s= {}){ return scopes.push(Object.assign({}, this.current, { prevent: false }, s)); }, + + /** + * Pushes the root scope to the stack + * @returns {number} New length of the scope stack + */ pushRoot(){ return scopes.push(scopes[0]); }, + + /** + * Pops the current scope from the stack + * @returns {Object|undefined} Popped scope or undefined if only one scope remains + */ pop(){ if(scopes.length===1) return; return scopes.pop(); }, }; -//NOTE: following chainableAppend implementation is OK as the ElementPrototype.append description already is { writable: true, enumerable: true, configurable: true } // editorconfig-checker-disable-line +/** + * Chainable append function for elements + * @private + */ function append(...els){ this.appendOriginal(...els); return this; } + +/** + * Makes an element's append method chainable. NOTE: following chainableAppend implementation is OK as the + * ElementPrototype.append description already is { writable: true, enumerable: true, configurable: true } + * @param {Element} el - Element to modify + * @returns {Element} Modified element + */ export function chainableAppend(el){ if(el.append===append) return el; el.appendOriginal= el.append; el.append= append; return el; } +/** Current namespace for element creation */ let namespace; + +/** + * Creates a DOM element with specified tag, attributes and addons + * + * @param {string|Function} tag - Element tag name or component function + * @param {Object|string|number} [attributes] - Element attributes + * @param {...Function} addons - Functions to call with the created element + * @returns {Element|DocumentFragment} Created element + */ export function createElement(tag, attributes, ...addons){ /* jshint maxcomplexity: 15 */ const s= signals(this); @@ -70,10 +135,15 @@ export function createElement(tag, attributes, ...addons){ scoped= 2; return el; } + /** - * @param { { type: "component", name: string, host: "this" | "parentElement" } | { type: "reactive" | "later" } } attrs - * @param {boolean} [is_open=false] - * */ + * Creates a marker comment for elements + * + * @param {{ type: "component"|"reactive"|"later", name?: string, host?: "this"|"parentElement" }} attrs - Marker + * attributes + * @param {boolean} [is_open=false] - Whether the marker is open-ended + * @returns {Comment} Comment node marker + */ createElement.mark= function(attrs, is_open= false){ attrs= Object.entries(attrs).map(([ n, v ])=> n+`="${v}"`).join(" "); const end= is_open ? "" : "/"; @@ -81,8 +151,16 @@ createElement.mark= function(attrs, is_open= false){ if(is_open) out.end= env.D.createComment(""); return out; }; +/** Alias for createElement */ export { createElement as el }; + //TODO?: const namespaceHelper= ns=> ns==="svg" ? "http://www.w3.org/2000/svg" : ns; +/** + * Creates a namespaced element creation function + * + * @param {string} ns - Namespace URI + * @returns {Function} Element creation function for the namespace + */ export function createElementNS(ns){ const _this= this; return function createElementNSCurried(...rest){ @@ -92,9 +170,17 @@ export function createElementNS(ns){ return el; }; } + +/** Alias for createElementNS */ export { createElementNS as elNS }; -/** @param {HTMLElement} element @param {HTMLElement} [root] */ +/** + * Simulates slot functionality for elements + * + * @param {HTMLElement} element - Parent element + * @param {HTMLElement} [root=element] - Root element containing slots + * @returns {HTMLElement} The root element + */ export function simulateSlots(element, root= element){ const mark_e= "¹⁰", mark_s= "✓"; //NOTE: Markers to identify slots processed by this function. Also “prevents” native behavior as it is unlikely to use these in names. // editorconfig-checker-disable-line const slots= Object.fromEntries( @@ -128,8 +214,17 @@ export function simulateSlots(element, root= element){ return root; } +/** Store for element assignment contexts */ const assign_context= new WeakMap(); const { setDeleteAttr }= env; + +/** + * Assigns attributes to an element + * + * @param {Element} element - Element to assign attributes to + * @param {...Object} attributes - Attribute objects to assign + * @returns {Element} The element with attributes assigned + */ export function assign(element, ...attributes){ if(!attributes.length) return element; assign_context.set(element, assignContext(element, this)); @@ -139,6 +234,14 @@ export function assign(element, ...attributes){ assign_context.delete(element); return element; } +/** + * Assigns a single attribute to an element + * + * @param {Element} element - Element to assign attribute to + * @param {string} key - Attribute name + * @param {any} value - Attribute value + * @returns {any} Result of the attribute assignment + */ export function assignAttribute(element, key, value){ const { setRemoveAttr, s }= assignContext(element, this); const _this= this; @@ -170,6 +273,14 @@ export function assignAttribute(element, key, value){ } return isPropSetter(element, key) ? setDeleteAttr(element, key, value) : setRemoveAttr(key, value); } +/** + * Gets or creates assignment context for an element + * + * @param {Element} element - Element to get context for + * @param {Object} _this - Context object + * @returns {Object} Assignment context + * @private + */ function assignContext(element, _this){ if(assign_context.has(element)) return assign_context.get(element); const is_svg= element instanceof env.S; @@ -177,6 +288,13 @@ function assignContext(element, _this){ const s= signals(_this); return { setRemoveAttr, s }; } +/** + * Applies a declarative classList object to an element + * + * @param {Element} element - Element to apply classes to + * @param {Object} toggle - Object with class names as keys and boolean values + * @returns {Element} The element with classes applied + */ export function classListDeclarative(element, toggle){ const s= signals(this); forEachEntries(s, "classList", element, toggle, @@ -184,18 +302,46 @@ export function classListDeclarative(element, toggle){ element.classList.toggle(class_name, val===-1 ? undefined : Boolean(val)) ); return element; } + +/** + * Generic element attribute manipulation + * + * @param {Element} element - Element to manipulate + * @param {string} op - Operation ("set" or "remove") + * @param {string} key - Attribute name + * @param {any} [value] - Attribute value + * @returns {void} + */ export function elementAttribute(element, op, key, value){ if(element instanceof env.H) return element[op+"Attribute"](key, value); return element[op+"AttributeNS"](null, key, value); } import { isUndef } from "./helpers.js"; + //TODO: add cache? `(Map/Set)` +/** + * Checks if a property can be set on an element + * + * @param {Element} el - Element to check + * @param {string} key - Property name + * @returns {boolean} Whether the property can be set + * @private + */ function isPropSetter(el, key){ if(!(key in el)) return false; const des= getPropDescriptor(el, key); return !isUndef(des.set); } + +/** + * Gets a property descriptor from a prototype chain + * + * @param {Object} p - Prototype object + * @param {string} key - Property name + * @returns {PropertyDescriptor} Property descriptor + * @private + */ function getPropDescriptor(p, key){ p= Object.getPrototypeOf(p); if(!p) return {}; @@ -224,9 +370,44 @@ function forEachEntries(s, target, element, obj, cb){ }); } +/** + * Sets or removes an attribute based on value + * + * @param {Element} obj - Element to modify + * @param {string} prop - Property suffix ("Attribute") + * @param {string} key - Attribute name + * @param {any} val - Attribute value + * @returns {void} + * @private + */ function setRemove(obj, prop, key, val){ - return obj[ (isUndef(val) ? "remove" : "set") + prop ](key, val); } + return obj[ (isUndef(val) ? "remove" : "set") + prop ](key, val); +} + +/** + * Sets or removes a namespaced attribute based on value + * + * @param {Element} obj - Element to modify + * @param {string} prop - Property suffix ("Attribute") + * @param {string} key - Attribute name + * @param {any} val - Attribute value + * @param {string|null} [ns=null] - Namespace URI + * @returns {void} + * @private + */ function setRemoveNS(obj, prop, key, val, ns= null){ - return obj[ (isUndef(val) ? "remove" : "set") + prop + "NS" ](ns, key, val); } + return obj[ (isUndef(val) ? "remove" : "set") + prop + "NS" ](ns, key, val); +} + +/** + * Sets or deletes a property based on value + * + * @param {Object} obj - Object to modify + * @param {string} key - Property name + * @param {any} val - Property value + * @returns {void} + * @private + */ function setDelete(obj, key, val){ - Reflect.set(obj, key, val); if(!isUndef(val)) return; return Reflect.deleteProperty(obj, key); } + Reflect.set(obj, key, val); if(!isUndef(val)) return; return Reflect.deleteProperty(obj, key); +} diff --git a/src/events-observer.js b/src/events-observer.js index 846340e..bc5f518 100644 --- a/src/events-observer.js +++ b/src/events-observer.js @@ -1,11 +1,26 @@ import { enviroment as env, evc, evd } from './dom-common.js'; + +/** + * Connection changes observer for tracking element connection/disconnection + * Falls back to a dummy implementation if MutationObserver is not available + */ export const c_ch_o= env.M ? connectionsChangesObserverConstructor() : new Proxy({}, { get(){ return ()=> {}; } }); +/** + * Creates an observer that tracks elements being connected to and disconnected from the DOM + * @returns {Object} Observer with methods to register element listeners + */ function connectionsChangesObserverConstructor(){ const store= new Map(); let is_observing= false; + + /** + * Creates a mutation observer callback + * @param {Function} stop - Function to stop observation when no longer needed + * @returns {Function} MutationObserver callback + */ const observerListener= stop=> function(mutations){ for(const mutation of mutations){ if(mutation.type!=="childList") continue; @@ -17,13 +32,26 @@ function connectionsChangesObserverConstructor(){ stop(); } }; + const observer= new env.M(observerListener(stop)); + return { + /** + * Creates an observer for a specific element + * @param {Element} element - Element to observe + * @returns {Function} Cleanup function + */ observe(element){ const o= new env.M(observerListener(()=> {})); o.observe(element, { childList: true, subtree: true }); return ()=> o.disconnect(); }, + + /** + * Register a connection listener for an element + * @param {Element} element - Element to watch + * @param {Function} listener - Callback for connection event + */ onConnected(element, listener){ start(); const listeners= getElementStore(element); @@ -31,6 +59,12 @@ function connectionsChangesObserverConstructor(){ listeners.connected.add(listener); listeners.length_c+= 1; }, + + /** + * Unregister a connection listener + * @param {Element} element - Element being watched + * @param {Function} listener - Callback to remove + */ offConnected(element, listener){ if(!store.has(element)) return; const ls= store.get(element); @@ -39,6 +73,12 @@ function connectionsChangesObserverConstructor(){ ls.length_c-= 1; cleanWhenOff(element, ls); }, + + /** + * Register a disconnection listener for an element + * @param {Element} element - Element to watch + * @param {Function} listener - Callback for disconnection event + */ onDisconnected(element, listener){ start(); const listeners= getElementStore(element); @@ -46,6 +86,12 @@ function connectionsChangesObserverConstructor(){ listeners.disconnected.add(listener); listeners.length_d+= 1; }, + + /** + * Unregister a disconnection listener + * @param {Element} element - Element being watched + * @param {Function} listener - Callback to remove + */ offDisconnected(element, listener){ if(!store.has(element)) return; const ls= store.get(element); @@ -55,12 +101,24 @@ function connectionsChangesObserverConstructor(){ cleanWhenOff(element, ls); } }; + + /** + * Cleanup element tracking when all listeners are removed + * @param {Element} element - Element to potentially remove from tracking + * @param {Object} ls - Element's listener store + */ function cleanWhenOff(element, ls){ if(ls.length_c || ls.length_d) return; store.delete(element); stop(); } + + /** + * Gets or creates a store for element listeners + * @param {Element} element - Element to get store for + * @returns {Object} Listener store for the element + */ function getElementStore(element){ if(store.has(element)) return store.get(element); const out= { @@ -72,20 +130,39 @@ function connectionsChangesObserverConstructor(){ store.set(element, out); return out; } + + /** + * Start observing DOM changes + */ function start(){ if(is_observing) return; is_observing= true; observer.observe(env.D.body, { childList: true, subtree: true }); } + + /** + * Stop observing DOM changes when no longer needed + */ function stop(){ if(!is_observing || store.size) return; is_observing= false; observer.disconnect(); } + //TODO: remount support? + /** + * Schedule a task during browser idle time + * @returns {Promise} Promise that resolves when browser is idle + */ function requestIdle(){ return new Promise(function(resolve){ (requestIdleCallback || requestAnimationFrame)(resolve); }); } + + /** + * Collects child elements from the store that are contained by the given element + * @param {Element} element - Parent element + * @returns {Promise} Promise resolving to array of child elements + */ async function collectChildren(element){ if(store.size > 30)//TODO?: limit await requestIdle(); @@ -98,6 +175,13 @@ function connectionsChangesObserverConstructor(){ } return out; } + + /** + * Process nodes added to the DOM + * @param {NodeList} addedNodes - Nodes that were added + * @param {boolean} is_root - Whether these are root-level additions + * @returns {boolean} Whether any relevant elements were processed + */ function observerAdded(addedNodes, is_root){ let out= false; for(const element of addedNodes){ @@ -115,6 +199,13 @@ function connectionsChangesObserverConstructor(){ } return out; } + + /** + * Process nodes removed from the DOM + * @param {NodeList} removedNodes - Nodes that were removed + * @param {boolean} is_root - Whether these are root-level removals + * @returns {boolean} Whether any relevant elements were processed + */ function observerRemoved(removedNodes, is_root){ let out= false; for(const element of removedNodes){ @@ -128,6 +219,12 @@ function connectionsChangesObserverConstructor(){ } return out; } + + /** + * Creates a function to dispatch the disconnect event + * @param {Element} element - Element that was removed + * @returns {Function} Function to dispatch event after confirming disconnection + */ function dispatchRemove(element){ return ()=> { if(element.isConnected) return; diff --git a/src/events.js b/src/events.js index 3645507..d055c4d 100644 --- a/src/events.js +++ b/src/events.js @@ -1,6 +1,14 @@ export { registerReactivity } from './signals-lib/common.js'; import { enviroment as env, keyLTE, evc, evd, eva } from './dom-common.js'; +/** + * Creates a function to dispatch events on elements + * + * @param {string} name - Event name + * @param {Object} [options] - Event options + * @param {Element|Function} [host] - Host element or function returning host element + * @returns {Function} Function that dispatches the event + */ export function dispatchEvent(name, options, host){ if(!options) options= {}; return function dispatch(element, ...d){ @@ -13,6 +21,15 @@ export function dispatchEvent(name, options, host){ return element.dispatchEvent(event); }; } + +/** + * Creates a function to register event listeners on elements + * + * @param {string} event - Event name + * @param {Function} listener - Event handler + * @param {Object} [options] - Event listener options + * @returns {Function} Function that registers the listener + */ export function on(event, listener, options){ return function registerElement(element){ element.addEventListener(event, listener, options); @@ -22,9 +39,23 @@ export function on(event, listener, options){ import { c_ch_o } from "./events-observer.js"; import { onAbort } from './helpers.js'; + +/** + * Prepares lifecycle event options with once:true default + * @private + */ const lifeOptions= obj=> Object.assign({}, typeof obj==="object" ? obj : null, { once: true }); + //TODO: cleanUp when event before abort? //TODO: docs (e.g.) https://nolanlawson.com/2024/01/13/web-component-gotcha-constructor-vs-connectedcallback/ + +/** + * Creates a function to register connected lifecycle event listeners + * + * @param {Function} listener - Event handler + * @param {Object} [options] - Event listener options + * @returns {Function} Function that registers the connected listener + */ on.connected= function(listener, options){ options= lifeOptions(options); return function registerElement(element){ @@ -37,6 +68,14 @@ on.connected= function(listener, options){ return element; }; }; + +/** + * Creates a function to register disconnected lifecycle event listeners + * + * @param {Function} listener - Event handler + * @param {Object} [options] - Event listener options + * @returns {Function} Function that registers the disconnected listener + */ on.disconnected= function(listener, options){ options= lifeOptions(options); return function registerElement(element){ @@ -48,7 +87,16 @@ on.disconnected= function(listener, options){ return element; }; }; + +/** Store for disconnect abort controllers */ const store_abort= new WeakMap(); + +/** + * Creates an AbortController that triggers when the element disconnects + * + * @param {Element|Function} host - Host element or function taking an element + * @returns {AbortController} AbortController that aborts on disconnect + */ on.disconnectedAsAbort= function(host){ if(store_abort.has(host)) return store_abort.get(host); @@ -57,7 +105,17 @@ on.disconnectedAsAbort= function(host){ host(on.disconnected(()=> a.abort())); return a; }; + +/** Store for elements with attribute observers */ const els_attribute_store= new WeakSet(); + +/** + * Creates a function to register attribute change event listeners + * + * @param {Function} listener - Event handler + * @param {Object} [options] - Event listener options + * @returns {Function} Function that registers the attribute change listener + */ on.attributeChanged= function(listener, options){ if(typeof options !== "object") options= {}; diff --git a/src/helpers.js b/src/helpers.js index 8273af7..ba966c4 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -1,11 +1,35 @@ +/** + * Safe method to check if an object has a specific property + * @param {...any} a - Arguments to pass to Object.prototype.hasOwnProperty.call + * @returns {boolean} Result of hasOwnProperty check + */ export const hasOwn= (...a)=> Object.prototype.hasOwnProperty.call(...a); + +/** + * Checks if a value is undefined + * @param {any} value - The value to check + * @returns {boolean} True if the value is undefined + */ export function isUndef(value){ return typeof value==="undefined"; } + +/** + * Enhanced typeof that handles null and objects better + * @param {any} v - The value to check + * @returns {string} Type as a string + */ export function typeOf(v){ const t= typeof v; if(t!=="object") return t; if(v===null) return "null"; return Object.prototype.toString.call(v); } + +/** + * Handles AbortSignal registration and cleanup + * @param {AbortSignal} signal - The AbortSignal to listen to + * @param {Function} listener - The abort event listener + * @returns {Function|undefined|boolean} Cleanup function or undefined if already aborted + */ export function onAbort(signal, listener){ if(!signal || !(signal instanceof AbortSignal)) return true; @@ -16,6 +40,13 @@ export function onAbort(signal, listener){ signal.removeEventListener("abort", listener); }; } + +/** + * Processes observed attributes for custom elements + * @param {object} instance - The custom element instance + * @param {Function} observedAttribute - Function to process each attribute + * @returns {object} Object with processed attributes + */ export function observedAttributes(instance, observedAttribute){ const { observedAttributes= [] }= instance.constructor; return observedAttributes @@ -24,4 +55,10 @@ export function observedAttributes(instance, observedAttribute){ return out; }, {}); } + +/** + * Converts kebab-case strings to camelCase + * @param {string} name - The kebab-case string + * @returns {string} The camelCase string + */ function kebabToCamel(name){ return name.replace(/-./g, x=> x[1].toUpperCase()); } diff --git a/src/signals-lib/common.js b/src/signals-lib/common.js index abe281a..1f02d3f 100644 --- a/src/signals-lib/common.js +++ b/src/signals-lib/common.js @@ -1,13 +1,43 @@ +/** + * Global signals object with default implementation + * @type {Object} + */ export const signals_global= { + /** + * Checks if a value is a signal + * @param {any} attributes - Value to check + * @returns {boolean} Whether the value is a signal + */ isSignal(attributes){ return false; }, + + /** + * Processes an attribute that might be reactive + * @param {Element} obj - Element that owns the attribute + * @param {string} key - Attribute name + * @param {any} attr - Attribute value + * @param {Function} set - Function to set the attribute + * @returns {any} Processed attribute value + */ processReactiveAttribute(obj, key, attr, set){ return attr; }, }; + +/** + * Registers a reactivity implementation + * @param {Object} def - Reactivity implementation + * @param {boolean} [global=true] - Whether to set globally or create a new implementation + * @returns {Object} The registered reactivity implementation + */ export function registerReactivity(def, global= true){ if(global) return Object.assign(signals_global, def); Object.setPrototypeOf(def, signals_global); return def; } -/** @param {unknown} _this @returns {typeof signals_global} */ + +/** + * Gets the signals implementation from a context + * @param {unknown} _this - Context to check for signals implementation + * @returns {typeof signals_global} Signals implementation + */ export function signals(_this){ return signals_global.isPrototypeOf(_this) && _this!==signals_global ? _this : signals_global; } diff --git a/src/signals-lib/helpers.js b/src/signals-lib/helpers.js index a42f5dc..7095579 100644 --- a/src/signals-lib/helpers.js +++ b/src/signals-lib/helpers.js @@ -1,5 +1,13 @@ +/** + * Symbol used to identify signals in objects + * @type {string} + */ export const mark= "__dde_signal"; +/** + * Error class for signal definition tracking + * Shows the correct stack trace for debugging signal creation + */ export class SignalDefined extends Error{ constructor(){ super(); @@ -8,10 +16,19 @@ export class SignalDefined extends Error{ this.stack= rest.find(l=> !l.includes(curr_file)); } } + +/** + * Batches signal updates to improve performance + * @type {Function} + */ export const queueSignalWrite= (()=> { let pendingSignals= new Set(); let scheduled= false; + /** + * Processes all pending signal updates + * @private + */ function flushSignals() { scheduled = false; for(const signal of pendingSignals){ @@ -20,6 +37,11 @@ export const queueSignalWrite= (()=> { } pendingSignals.clear(); } + + /** + * Queues a signal for update + * @param {Object} s - Signal to queue + */ return function(s){ pendingSignals.add(s); if(scheduled) return; diff --git a/src/signals-lib/signals-lib.js b/src/signals-lib/signals-lib.js index fd3d49c..2da1eb3 100644 --- a/src/signals-lib/signals-lib.js +++ b/src/signals-lib/signals-lib.js @@ -2,12 +2,25 @@ import { SignalDefined, queueSignalWrite, mark } from "./helpers.js"; export { mark }; import { hasOwn } from "../helpers.js"; +/** + * Checks if a value is a signal + * + * @param {any} candidate - Value to check + * @returns {boolean} True if the value is a signal + */ export function isSignal(candidate){ return typeof candidate === "function" && hasOwn(candidate, mark); } -/** @type {function[]} */ -const stack_watch= []; + /** + * Stack for tracking nested signal computations + * @type {function[]} + */ +const stack_watch= []; + +/** + * Dependencies tracking map for signals + * * ### `WeakMap>>` * The `Set` is in the form of `[ source, ...depended signals (DSs) ]`. * When the DS is cleaned (`S.clear`) it is removed from DSs, @@ -15,14 +28,26 @@ const stack_watch= []; * ### `WeakMap` * This is used for revesed deps, the `function` is also key for `deps`. * @type {WeakMap>|function>} - * */ + */ const deps= new WeakMap(); +/** + * Creates a new signal or converts a function into a derived signal + * + * @param {any|function} value - Initial value or function that computes the value + * @param {Object} [actions] - Custom actions for the signal + * @returns {function} Signal function + */ export function signal(value, actions){ if(typeof value!=="function") return create(false, value, actions); if(isSignal(value)) return value; const out= create(true); + + /** + * Updates the derived signal when dependencies change + * @private + */ function contextReWatch(){ const [ origin, ...deps_old ]= deps.get(contextReWatch); deps.set(contextReWatch, new Set([ origin ])); @@ -43,7 +68,16 @@ export function signal(value, actions){ contextReWatch(); return out; } + +/** Alias for signal */ export { signal as S }; +/** + * Calls a custom action on a signal + * + * @param {function} s - Signal to call action on + * @param {string} name - Action name + * @param {...any} a - Arguments to pass to the action + */ signal.action= function(s, name, ...a){ const M= s[mark]; if(!M) return; @@ -54,6 +88,15 @@ signal.action= function(s, name, ...a){ if(M.skip) return (delete M.skip); queueSignalWrite(s); }; + +/** + * Subscribes a listener to signal changes + * + * @param {function|function[]} s - Signal or array of signals to subscribe to + * @param {function} listener - Callback function receiving signal value + * @param {Object} [options={}] - Subscription options + * @param {AbortSignal} [options.signal] - Signal to abort subscription + */ signal.on= function on(s, listener, options= {}){ const { signal: as }= options; if(as && as.aborted) return; @@ -61,10 +104,20 @@ signal.on= function on(s, listener, options= {}){ addSignalListener(s, listener); if(as) as.addEventListener("abort", ()=> removeSignalListener(s, listener)); }; + +/** + * Symbol constants for signal internals + */ signal.symbols= { //signal: mark, onclear: Symbol.for("Signal.onclear") }; + +/** + * Cleans up signals and their dependencies + * + * @param {...function} signals - Signals to clean up + */ signal.clear= function(...signals){ for(const s of signals){ const M= s[mark]; @@ -74,6 +127,13 @@ signal.clear= function(...signals){ clearListDeps(s, M); delete s[mark]; } + + /** + * Cleans up signal dependencies + * @param {function} s - Signal being cleared + * @param {Object} o - Signal metadata + * @private + */ function clearListDeps(s, o){ o.listeners.forEach(l=> { o.listeners.delete(l); @@ -88,13 +148,24 @@ signal.clear= function(...signals){ }); } }; +/** Property key for tracking reactive elements */ const key_reactive= "__dde_reactive"; import { enviroment as env } from "../dom-common.js"; import { el } from "../dom.js"; import { scope } from "../dom.js"; import { on } from "../events.js"; +/** Store for memoized values */ const storeMemo= new WeakMap(); + +/** + * Memoizes a function result by key + * + * @param {string|any} key - Cache key (non-strings will be stringified) + * @param {Function} fun - Function to compute value + * @param {Object} [cache] - Optional explicit cache object + * @returns {any} Cached or computed result + */ export function memo(key, fun, cache){ if(typeof key!=="string") key= JSON.stringify(key); if(!cache) { @@ -109,6 +180,13 @@ export function memo(key, fun, cache){ return hasOwn(cache, key) ? cache[key] : (cache[key]= fun()); } // TODO: third argument for handle `cache_tmp` in re-render +/** + * Creates a reactive DOM element that re-renders when signal changes + * + * @param {function} s - Signal to watch + * @param {Function} map - Function mapping signal value to DOM elements + * @returns {DocumentFragment} Fragment containing reactive elements + */ signal.el= function(s, map){ const mark_start= el.mark({ type: "reactive" }, true); const mark_end= mark_start.end; @@ -147,6 +225,12 @@ signal.el= function(s, map){ cache= {})); return out; }; +/** + * Cleans up reactive elements that are no longer connected + * + * @param {Element} host - Host element containing reactive elements + * @private + */ function requestCleanUpReactives(host){ if(!host || !host[key_reactive]) return; (requestIdleCallback || setTimeout)(function(){ @@ -155,9 +239,22 @@ function requestCleanUpReactives(host){ }); } import { observedAttributes } from "../helpers.js"; + +/** + * Actions for observed attribute signals + * @private + */ const observedAttributeActions= { _set(value){ this.value= value; }, }; + +/** + * Creates a function that returns signals for element attributes + * + * @param {Object} store - Storage object for attribute signals + * @returns {Function} Function creating attribute signals + * @private + */ function observedAttribute(store){ return function(instance, name){ const varS= (...args)=> !args.length @@ -168,7 +265,15 @@ function observedAttribute(store){ return out; }; } +/** Property key for storing attribute signals */ const key_attributes= "__dde_attributes"; + +/** + * Creates signals for observed attributes in custom elements + * + * @param {Element} element - Custom element instance + * @returns {Object} Object with attribute signals + */ signal.observedAttributes= function(element){ const store= element[key_attributes]= {}; const attrs= observedAttributes(element, observedAttribute(store)); @@ -188,8 +293,23 @@ signal.observedAttributes= function(element){ }; import { typeOf } from '../helpers.js'; + +/** + * Signal configuration for the library + * Implements processReactiveAttribute to handle signal-based attributes + */ export const signals_config= { isSignal, + + /** + * Processes attributes that might be signals + * + * @param {Element} element - Element with the attribute + * @param {string} key - Attribute name + * @param {any} attrs - Attribute value (possibly a signal) + * @param {Function} set - Function to set attribute value + * @returns {any} Processed attribute value + */ processReactiveAttribute(element, key, attrs, set){ if(!isSignal(attrs)) return attrs; const l= attr=> { @@ -202,6 +322,14 @@ export const signals_config= { return attrs(); } }; +/** + * Registers signal listener for cleanup when element is removed + * + * @param {function} s - Signal to track + * @param {Function} listener - Signal listener + * @param {...any} notes - Additional context information + * @private + */ function removeSignalsFromElements(s, listener, ...notes){ const { current }= scope; current.host(function(element){ @@ -218,9 +346,22 @@ function removeSignalsFromElements(s, listener, ...notes){ }); } +/** + * Registry for cleaning up signals when they are garbage collected + * @type {FinalizationRegistry} + */ const cleanUpRegistry = new FinalizationRegistry(function(s){ signal.clear({ [mark]: s }); }); +/** + * Creates a new signal function + * + * @param {boolean} is_readonly - Whether the signal is readonly + * @param {any} value - Initial signal value + * @param {Object} actions - Custom actions for the signal + * @returns {function} Signal function + * @private + */ function create(is_readonly, value, actions){ const varS= is_readonly ? ()=> read(varS) @@ -229,11 +370,29 @@ function create(is_readonly, value, actions){ cleanUpRegistry.register(SI, SI[mark]); return SI; } + +/** + * Prototype for signal internal objects + * @private + */ const protoSigal= Object.assign(Object.create(null), { + /** + * Prevents signal propagation + */ stopPropagation(){ this.skip= true; } }); +/** + * Transforms a function into a signal + * + * @param {function} s - Function to transform + * @param {any} value - Initial value + * @param {Object} actions - Custom actions + * @param {boolean} [readonly=false] - Whether the signal is readonly + * @returns {function} Signal function + * @private + */ function toSignal(s, value, actions, readonly= false){ const onclear= []; if(typeOf(actions)!=="[object Object]") @@ -260,9 +419,22 @@ function toSignal(s, value, actions, readonly= false){ Object.setPrototypeOf(s[mark], protoSigal); return s; } +/** + * Gets the current computation context + * @returns {function|undefined} Current context function + * @private + */ function currentContext(){ return stack_watch[stack_watch.length - 1]; } + +/** + * Reads a signal's value and tracks dependencies + * + * @param {function} s - Signal to read + * @returns {any} Signal value + * @private + */ function read(s){ if(!s[mark]) return; const { value, listeners }= s[mark]; @@ -271,6 +443,16 @@ function read(s){ if(deps.has(context)) deps.get(context).add(s); return value; } + +/** + * Writes a new value to a signal + * + * @param {function} s - Signal to update + * @param {any} value - New value + * @param {boolean} [force=false] - Force update even if value is unchanged + * @returns {any} The new value + * @private + */ function write(s, value, force){ const M= s[mark]; if(!M || (!force && M.value===value)) return; @@ -280,10 +462,28 @@ function write(s, value, force){ return value; } +/** + * Adds a listener to a signal + * + * @param {function} s - Signal to listen to + * @param {Function} listener - Callback function + * @returns {Set} Listener set + * @private + */ function addSignalListener(s, listener){ if(!s[mark]) return; return s[mark].listeners.add(listener); } + +/** + * Removes a listener from a signal + * + * @param {function} s - Signal to modify + * @param {Function} listener - Listener to remove + * @param {boolean} [clear_when_empty] - Whether to clear the signal when no listeners remain + * @returns {boolean} Whether the listener was found and removed + * @private + */ function removeSignalListener(s, listener, clear_when_empty){ const M= s[mark]; if(!M) return;