eleva
Version:
A minimalist and lightweight, pure vanilla JavaScript frontend runtime framework.
972 lines (917 loc) • 38.5 kB
JavaScript
/*! Eleva v1.0.0-rc.7 | MIT License | https://elevajs.com */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Eleva = factory());
})(this, (function () { 'use strict';
function _extends() {
return _extends = Object.assign ? Object.assign.bind() : function (n) {
for (var e = 1; e < arguments.length; e++) {
var t = arguments[e];
for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);
}
return n;
}, _extends.apply(null, arguments);
}
/**
* @class 🔒 TemplateEngine
* @classdesc A secure template engine that handles interpolation and dynamic attribute parsing.
* Provides a safe way to evaluate expressions in templates while preventing XSS attacks.
* All methods are static and can be called directly on the class.
*
* @example
* const template = "Hello, {{name}}!";
* const data = { name: "World" };
* const result = TemplateEngine.parse(template, data); // Returns: "Hello, World!"
*/
class TemplateEngine {
/**
* Parses a template string, replacing expressions with their evaluated values.
* Expressions are evaluated in the provided data context.
*
* @public
* @static
* @param {string} template - The template string to parse.
* @param {Record<string, unknown>} data - The data context for evaluating expressions.
* @returns {string} The parsed template with expressions replaced by their values.
* @example
* const result = TemplateEngine.parse("{{user.name}} is {{user.age}} years old", {
* user: { name: "John", age: 30 }
* }); // Returns: "John is 30 years old"
*/
static parse(template, data) {
if (typeof template !== "string") return template;
return template.replace(this.expressionPattern, (_, expression) => this.evaluate(expression, data));
}
/**
* Evaluates an expression in the context of the provided data object.
* Note: This does not provide a true sandbox and evaluated expressions may access global scope.
* The use of the `with` statement is necessary for expression evaluation but has security implications.
* Expressions should be carefully validated before evaluation.
*
* @public
* @static
* @param {string} expression - The expression to evaluate.
* @param {Record<string, unknown>} data - The data context for evaluation.
* @returns {unknown} The result of the evaluation, or an empty string if evaluation fails.
* @example
* const result = TemplateEngine.evaluate("user.name", { user: { name: "John" } }); // Returns: "John"
* const age = TemplateEngine.evaluate("user.age", { user: { age: 30 } }); // Returns: 30
*/
static evaluate(expression, data) {
if (typeof expression !== "string") return expression;
try {
return new Function("data", `with(data) { return ${expression}; }`)(data);
} catch (_unused) {
return "";
}
}
}
/**
* @private {RegExp} Regular expression for matching template expressions in the format {{ expression }}
* @type {RegExp}
*/
TemplateEngine.expressionPattern = /\{\{\s*(.*?)\s*\}\}/g;
/**
* @class ⚡ Signal
* @classdesc A reactive data holder that enables fine-grained reactivity in the Eleva framework.
* Signals notify registered watchers when their value changes, enabling efficient DOM updates
* through targeted patching rather than full re-renders.
* Updates are batched using microtasks to prevent multiple synchronous notifications.
* The class is generic, allowing type-safe handling of any value type T.
*
* @example
* const count = new Signal(0);
* count.watch((value) => console.log(`Count changed to: ${value}`));
* count.value = 1; // Logs: "Count changed to: 1"
* @template T
*/
class Signal {
/**
* Creates a new Signal instance with the specified initial value.
*
* @public
* @param {T} value - The initial value of the signal.
*/
constructor(value) {
/** @private {T} Internal storage for the signal's current value */
this._value = value;
/** @private {Set<(value: T) => void>} Collection of callback functions to be notified when value changes */
this._watchers = new Set();
/** @private {boolean} Flag to prevent multiple synchronous watcher notifications and batch updates into microtasks */
this._pending = false;
}
/**
* Gets the current value of the signal.
*
* @public
* @returns {T} The current value.
*/
get value() {
return this._value;
}
/**
* Sets a new value for the signal and notifies all registered watchers if the value has changed.
* The notification is batched using microtasks to prevent multiple synchronous updates.
*
* @public
* @param {T} newVal - The new value to set.
* @returns {void}
*/
set value(newVal) {
if (this._value === newVal) return;
this._value = newVal;
this._notify();
}
/**
* Registers a watcher function that will be called whenever the signal's value changes.
* The watcher will receive the new value as its argument.
*
* @public
* @param {(value: T) => void} fn - The callback function to invoke on value change.
* @returns {() => boolean} A function to unsubscribe the watcher.
* @example
* const unsubscribe = signal.watch((value) => console.log(value));
* // Later...
* unsubscribe(); // Stops watching for changes
*/
watch(fn) {
this._watchers.add(fn);
return () => this._watchers.delete(fn);
}
/**
* Notifies all registered watchers of a value change using microtask scheduling.
* Uses a pending flag to batch multiple synchronous updates into a single notification.
* All watcher callbacks receive the current value when executed.
*
* @private
* @returns {void}
*/
_notify() {
if (this._pending) return;
this._pending = true;
queueMicrotask(() => {
/** @type {(fn: (value: T) => void) => void} */
this._watchers.forEach(fn => fn(this._value));
this._pending = false;
});
}
}
/**
* @class 📡 Emitter
* @classdesc A robust event emitter that enables inter-component communication through a publish-subscribe pattern.
* Components can emit events and listen for events from other components, facilitating loose coupling
* and reactive updates across the application.
* Events are handled synchronously in the order they were registered, with proper cleanup
* of unsubscribed handlers.
* Event names should follow the format 'namespace:action' (e.g., 'user:login', 'cart:update').
*
* @example
* const emitter = new Emitter();
* emitter.on('user:login', (user) => console.log(`User logged in: ${user.name}`));
* emitter.emit('user:login', { name: 'John' }); // Logs: "User logged in: John"
*/
class Emitter {
/**
* Creates a new Emitter instance.
*
* @public
*/
constructor() {
/** @private {Map<string, Set<(data: unknown) => void>>} Map of event names to their registered handler functions */
this._events = new Map();
}
/**
* Registers an event handler for the specified event name.
* The handler will be called with the event data when the event is emitted.
* Event names should follow the format 'namespace:action' for consistency.
*
* @public
* @param {string} event - The name of the event to listen for (e.g., 'user:login').
* @param {(data: unknown) => void} handler - The callback function to invoke when the event occurs.
* @returns {() => void} A function to unsubscribe the event handler.
* @example
* const unsubscribe = emitter.on('user:login', (user) => console.log(user));
* // Later...
* unsubscribe(); // Stops listening for the event
*/
on(event, handler) {
if (!this._events.has(event)) this._events.set(event, new Set());
this._events.get(event).add(handler);
return () => this.off(event, handler);
}
/**
* Removes an event handler for the specified event name.
* If no handler is provided, all handlers for the event are removed.
* Automatically cleans up empty event sets to prevent memory leaks.
*
* @public
* @param {string} event - The name of the event to remove handlers from.
* @param {(data: unknown) => void} [handler] - The specific handler function to remove.
* @returns {void}
* @example
* // Remove a specific handler
* emitter.off('user:login', loginHandler);
* // Remove all handlers for an event
* emitter.off('user:login');
*/
off(event, handler) {
if (!this._events.has(event)) return;
if (handler) {
const handlers = this._events.get(event);
handlers.delete(handler);
// Remove the event if there are no handlers left
if (handlers.size === 0) this._events.delete(event);
} else {
this._events.delete(event);
}
}
/**
* Emits an event with the specified data to all registered handlers.
* Handlers are called synchronously in the order they were registered.
* If no handlers are registered for the event, the emission is silently ignored.
*
* @public
* @param {string} event - The name of the event to emit.
* @param {...unknown} args - Optional arguments to pass to the event handlers.
* @returns {void}
* @example
* // Emit an event with data
* emitter.emit('user:login', { name: 'John', role: 'admin' });
* // Emit an event with multiple arguments
* emitter.emit('cart:update', { items: [] }, { total: 0 });
*/
emit(event, ...args) {
if (!this._events.has(event)) return;
this._events.get(event).forEach(handler => handler(...args));
}
}
/**
* @class 🎨 Renderer
* @classdesc A high-performance DOM renderer that implements an optimized direct DOM diffing algorithm.
*
* Key features:
* - Single-pass diffing algorithm for efficient DOM updates
* - Key-based node reconciliation for optimal performance
* - Intelligent attribute handling for ARIA, data attributes, and boolean properties
* - Preservation of special Eleva-managed instances and style elements
* - Memory-efficient with reusable temporary containers
*
* The renderer is designed to minimize DOM operations while maintaining
* exact attribute synchronization and proper node identity preservation.
* It's particularly optimized for frequent updates and complex DOM structures.
*
* @example
* const renderer = new Renderer();
* const container = document.getElementById("app");
* const newHtml = "<div>Updated content</div>";
* renderer.patchDOM(container, newHtml);
*/
class Renderer {
/**
* Creates a new Renderer instance.
* @public
*/
constructor() {
/**
* A temporary container to hold the new HTML content while diffing.
* @private
* @type {HTMLElement}
*/
this._tempContainer = document.createElement("div");
}
/**
* Patches the DOM of the given container with the provided HTML string.
*
* @public
* @param {HTMLElement} container - The container element to patch.
* @param {string} newHtml - The new HTML string.
* @returns {void}
* @throws {TypeError} If container is not an HTMLElement or newHtml is not a string.
* @throws {Error} If DOM patching fails.
*/
patchDOM(container, newHtml) {
if (!(container instanceof HTMLElement)) {
throw new TypeError("Container must be an HTMLElement");
}
if (typeof newHtml !== "string") {
throw new TypeError("newHtml must be a string");
}
try {
this._tempContainer.innerHTML = newHtml;
this._diff(container, this._tempContainer);
} catch (error) {
throw new Error(`Failed to patch DOM: ${error.message}`);
}
}
/**
* Performs a diff between two DOM nodes and patches the old node to match the new node.
*
* @private
* @param {HTMLElement} oldParent - The original DOM element.
* @param {HTMLElement} newParent - The new DOM element.
* @returns {void}
*/
_diff(oldParent, newParent) {
if (oldParent === newParent || oldParent.isEqualNode != null && oldParent.isEqualNode(newParent)) return;
const oldChildren = Array.from(oldParent.childNodes);
const newChildren = Array.from(newParent.childNodes);
let oldStartIdx = 0,
newStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newEndIdx = newChildren.length - 1;
let oldKeyMap = null;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
let oldStartNode = oldChildren[oldStartIdx];
let newStartNode = newChildren[newStartIdx];
if (!oldStartNode) {
oldStartNode = oldChildren[++oldStartIdx];
} else if (this._isSameNode(oldStartNode, newStartNode)) {
this._patchNode(oldStartNode, newStartNode);
oldStartIdx++;
newStartIdx++;
} else {
if (!oldKeyMap) {
oldKeyMap = this._createKeyMap(oldChildren, oldStartIdx, oldEndIdx);
}
const key = this._getNodeKey(newStartNode);
const oldNodeToMove = key ? oldKeyMap.get(key) : null;
if (oldNodeToMove) {
this._patchNode(oldNodeToMove, newStartNode);
oldParent.insertBefore(oldNodeToMove, oldStartNode);
oldChildren[oldChildren.indexOf(oldNodeToMove)] = null;
} else {
oldParent.insertBefore(newStartNode.cloneNode(true), oldStartNode);
}
newStartIdx++;
}
}
if (oldStartIdx > oldEndIdx) {
const refNode = newChildren[newEndIdx + 1] ? oldChildren[oldStartIdx] : null;
for (let i = newStartIdx; i <= newEndIdx; i++) {
if (newChildren[i]) oldParent.insertBefore(newChildren[i].cloneNode(true), refNode);
}
} else if (newStartIdx > newEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldChildren[i]) this._removeNode(oldParent, oldChildren[i]);
}
}
}
/**
* Patches a single node.
*
* @private
* @param {Node} oldNode - The original DOM node.
* @param {Node} newNode - The new DOM node.
* @returns {void}
*/
_patchNode(oldNode, newNode) {
if (oldNode != null && oldNode._eleva_instance) return;
if (!this._isSameNode(oldNode, newNode)) {
oldNode.replaceWith(newNode.cloneNode(true));
return;
}
if (oldNode.nodeType === Node.ELEMENT_NODE) {
this._updateAttributes(oldNode, newNode);
this._diff(oldNode, newNode);
} else if (oldNode.nodeType === Node.TEXT_NODE && oldNode.nodeValue !== newNode.nodeValue) {
oldNode.nodeValue = newNode.nodeValue;
}
}
/**
* Removes a node from its parent.
*
* @private
* @param {HTMLElement} parent - The parent element containing the node to remove.
* @param {Node} node - The node to remove.
* @returns {void}
*/
_removeNode(parent, node) {
if (node.nodeName === "STYLE" && node.hasAttribute("data-e-style")) return;
parent.removeChild(node);
}
/**
* Updates the attributes of an element to match a new element's attributes.
*
* @private
* @param {HTMLElement} oldEl - The original element to update.
* @param {HTMLElement} newEl - The new element to update.
* @returns {void}
*/
_updateAttributes(oldEl, newEl) {
const oldAttrs = oldEl.attributes;
const newAttrs = newEl.attributes;
// Process new attributes
for (let i = 0; i < newAttrs.length; i++) {
const {
name,
value
} = newAttrs[i];
// Skip event attributes (handled by event system)
if (name.startsWith("@")) continue;
// Skip if attribute hasn't changed
if (oldEl.getAttribute(name) === value) continue;
// Basic attribute setting
oldEl.setAttribute(name, value);
}
// Remove old attributes that are no longer present
for (let i = oldAttrs.length - 1; i >= 0; i--) {
const name = oldAttrs[i].name;
if (!newEl.hasAttribute(name)) {
oldEl.removeAttribute(name);
}
}
}
/**
* Determines if two nodes are the same based on their type, name, and key attributes.
*
* @private
* @param {Node} oldNode - The first node to compare.
* @param {Node} newNode - The second node to compare.
* @returns {boolean} True if the nodes are considered the same, false otherwise.
*/
_isSameNode(oldNode, newNode) {
if (!oldNode || !newNode) return false;
const oldKey = oldNode.nodeType === Node.ELEMENT_NODE ? oldNode.getAttribute("key") : null;
const newKey = newNode.nodeType === Node.ELEMENT_NODE ? newNode.getAttribute("key") : null;
if (oldKey && newKey) return oldKey === newKey;
return !oldKey && !newKey && oldNode.nodeType === newNode.nodeType && oldNode.nodeName === newNode.nodeName;
}
/**
* Creates a key map for the children of a parent node.
*
* @private
* @param {Array<Node>} children - The children of the parent node.
* @param {number} start - The start index of the children.
* @param {number} end - The end index of the children.
* @returns {Map<string, Node>} A key map for the children.
*/
_createKeyMap(children, start, end) {
const map = new Map();
for (let i = start; i <= end; i++) {
const child = children[i];
const key = this._getNodeKey(child);
if (key) map.set(key, child);
}
return map;
}
/**
* Extracts the key attribute from a node if it exists.
*
* @private
* @param {Node} node - The node to extract the key from.
* @returns {string|null} The key attribute value or null if not found.
*/
_getNodeKey(node) {
return (node == null ? void 0 : node.nodeType) === Node.ELEMENT_NODE ? node.getAttribute("key") : null;
}
}
/**
* @typedef {Object} ComponentDefinition
* @property {function(ComponentContext): (Record<string, unknown>|Promise<Record<string, unknown>>)} [setup]
* Optional setup function that initializes the component's state and returns reactive data
* @property {(function(ComponentContext): string|Promise<string>)} template
* Required function that defines the component's HTML structure
* @property {(function(ComponentContext): string)|string} [style]
* Optional function or string that provides component-scoped CSS styles
* @property {Record<string, ComponentDefinition>} [children]
* Optional object defining nested child components
*/
/**
* @typedef {Object} ComponentContext
* @property {Record<string, unknown>} props
* Component properties passed during mounting
* @property {Emitter} emitter
* Event emitter instance for component event handling
* @property {function<T>(value: T): Signal<T>} signal
* Factory function to create reactive Signal instances
* @property {function(LifecycleHookContext): Promise<void>} [onBeforeMount]
* Hook called before component mounting
* @property {function(LifecycleHookContext): Promise<void>} [onMount]
* Hook called after component mounting
* @property {function(LifecycleHookContext): Promise<void>} [onBeforeUpdate]
* Hook called before component update
* @property {function(LifecycleHookContext): Promise<void>} [onUpdate]
* Hook called after component update
* @property {function(UnmountHookContext): Promise<void>} [onUnmount]
* Hook called during component unmounting
*/
/**
* @typedef {Object} LifecycleHookContext
* @property {HTMLElement} container
* The DOM element where the component is mounted
* @property {ComponentContext} context
* The component's reactive state and context data
*/
/**
* @typedef {Object} UnmountHookContext
* @property {HTMLElement} container
* The DOM element where the component is mounted
* @property {ComponentContext} context
* The component's reactive state and context data
* @property {{
* watchers: Array<() => void>, // Signal watcher cleanup functions
* listeners: Array<() => void>, // Event listener cleanup functions
* children: Array<MountResult> // Child component instances
* }} cleanup
* Object containing cleanup functions and instances
*/
/**
* @typedef {Object} MountResult
* @property {HTMLElement} container
* The DOM element where the component is mounted
* @property {ComponentContext} data
* The component's reactive state and context data
* @property {function(): Promise<void>} unmount
* Function to clean up and unmount the component
*/
/**
* @typedef {Object} ElevaPlugin
* @property {function(Eleva, Record<string, unknown>): void} install
* Function that installs the plugin into the Eleva instance
* @property {string} name
* Unique identifier name for the plugin
*/
/**
* @class 🧩 Eleva
* @classdesc A modern, signal-based component runtime framework that provides lifecycle hooks,
* scoped styles, and plugin support. Eleva manages component registration, plugin integration,
* event handling, and DOM rendering with a focus on performance and developer experience.
*
* @example
* // Basic component creation and mounting
* const app = new Eleva("myApp");
* app.component("myComponent", {
* setup: (ctx) => ({ count: ctx.signal(0) }),
* template: (ctx) => `<div>Hello ${ctx.props.name}</div>`
* });
* app.mount(document.getElementById("app"), "myComponent", { name: "World" });
*
* @example
* // Using lifecycle hooks
* app.component("lifecycleDemo", {
* setup: () => {
* return {
* onMount: ({ container, context }) => {
* console.log('Component mounted!');
* }
* };
* },
* template: `<div>Lifecycle Demo</div>`
* });
*/
class Eleva {
/**
* Creates a new Eleva instance with the specified name and configuration.
*
* @public
* @param {string} name - The unique identifier name for this Eleva instance.
* @param {Record<string, unknown>} [config={}] - Optional configuration object for the instance.
* May include framework-wide settings and default behaviors.
* @throws {Error} If the name is not provided or is not a string.
* @returns {Eleva} A new Eleva instance.
*
* @example
* const app = new Eleva("myApp");
* app.component("myComponent", {
* setup: (ctx) => ({ count: ctx.signal(0) }),
* template: (ctx) => `<div>Hello ${ctx.props.name}!</div>`
* });
* app.mount(document.getElementById("app"), "myComponent", { name: "World" });
*
*/
constructor(name, config = {}) {
/** @public {string} The unique identifier name for this Eleva instance */
this.name = name;
/** @public {Object<string, unknown>} Optional configuration object for the Eleva instance */
this.config = config;
/** @public {Emitter} Instance of the event emitter for handling component events */
this.emitter = new Emitter();
/** @public {typeof Signal} Static reference to the Signal class for creating reactive state */
this.signal = Signal;
/** @public {Renderer} Instance of the renderer for handling DOM updates and patching */
this.renderer = new Renderer();
/** @private {Map<string, ComponentDefinition>} Registry of all component definitions by name */
this._components = new Map();
/** @private {Map<string, ElevaPlugin>} Collection of installed plugin instances by name */
this._plugins = new Map();
/** @private {boolean} Flag indicating if the root component is currently mounted */
this._isMounted = false;
/** @private {number} Counter for generating unique component IDs */
this._componentCounter = 0;
}
/**
* Integrates a plugin with the Eleva framework.
* The plugin's install function will be called with the Eleva instance and provided options.
* After installation, the plugin will be available for use by components.
*
* @public
* @param {ElevaPlugin} plugin - The plugin object which must have an `install` function.
* @param {Object<string, unknown>} [options={}] - Optional configuration options for the plugin.
* @returns {Eleva} The Eleva instance (for method chaining).
* @example
* app.use(myPlugin, { option1: "value1" });
*/
use(plugin, options = {}) {
this._plugins.set(plugin.name, plugin);
const result = plugin.install(this, options);
return result !== undefined ? result : this;
}
/**
* Registers a new component with the Eleva instance.
* The component will be available for mounting using its registered name.
*
* @public
* @param {string} name - The unique name of the component to register.
* @param {ComponentDefinition} definition - The component definition including setup, template, style, and children.
* @returns {Eleva} The Eleva instance (for method chaining).
* @throws {Error} If the component name is already registered.
* @example
* app.component("myButton", {
* template: (ctx) => `<button>${ctx.props.text}</button>`,
* style: `button { color: blue; }`
* });
*/
component(name, definition) {
/** @type {Map<string, ComponentDefinition>} */
this._components.set(name, definition);
return this;
}
/**
* Mounts a registered component to a DOM element.
* This will initialize the component, set up its reactive state, and render it to the DOM.
*
* @public
* @param {HTMLElement} container - The DOM element where the component will be mounted.
* @param {string|ComponentDefinition} compName - The name of the registered component or a direct component definition.
* @param {Object<string, unknown>} [props={}] - Optional properties to pass to the component.
* @returns {Promise<MountResult>}
* A Promise that resolves to an object containing:
* - container: The mounted component's container element
* - data: The component's reactive state and context
* - unmount: Function to clean up and unmount the component
* @throws {Error} If the container is not found, or component is not registered.
* @example
* const instance = await app.mount(document.getElementById("app"), "myComponent", { text: "Click me" });
* // Later...
* instance.unmount();
*/
async mount(container, compName, props = {}) {
if (!container) throw new Error(`Container not found: ${container}`);
if (container._eleva_instance) return container._eleva_instance;
/** @type {ComponentDefinition} */
const definition = typeof compName === "string" ? this._components.get(compName) : compName;
if (!definition) throw new Error(`Component "${compName}" not registered.`);
/** @type {string} */
const compId = `c${++this._componentCounter}`;
/**
* Destructure the component definition to access core functionality.
* - setup: Optional function for component initialization and state management
* - template: Required function or string that returns the component's HTML structure
* - style: Optional function or string for component-scoped CSS styles
* - children: Optional object defining nested child components
*/
const {
setup,
template,
style,
children
} = definition;
/** @type {ComponentContext} */
const context = {
props,
emitter: this.emitter,
/** @type {(v: unknown) => Signal<unknown>} */
signal: v => new this.signal(v)
};
/**
* Processes the mounting of the component.
* This function handles:
* 1. Merging setup data with the component context
* 2. Setting up reactive watchers
* 3. Rendering the component
* 4. Managing component lifecycle
*
* @param {Object<string, unknown>} data - Data returned from the component's setup function
* @returns {Promise<MountResult>} An object containing:
* - container: The mounted component's container element
* - data: The component's reactive state and context
* - unmount: Function to clean up and unmount the component
*/
const processMount = async data => {
/** @type {ComponentContext} */
const mergedContext = _extends({}, context, data);
/** @type {Array<() => void>} */
const watchers = [];
/** @type {Array<MountResult>} */
const childInstances = [];
/** @type {Array<() => void>} */
const listeners = [];
// Execute before hooks
if (!this._isMounted) {
/** @type {LifecycleHookContext} */
await (mergedContext.onBeforeMount == null ? void 0 : mergedContext.onBeforeMount({
container,
context: mergedContext
}));
} else {
/** @type {LifecycleHookContext} */
await (mergedContext.onBeforeUpdate == null ? void 0 : mergedContext.onBeforeUpdate({
container,
context: mergedContext
}));
}
/**
* Renders the component by:
* 1. Processing the template
* 2. Updating the DOM
* 3. Processing events, injecting styles, and mounting child components.
*/
const render = async () => {
const templateResult = typeof template === "function" ? await template(mergedContext) : template;
const newHtml = TemplateEngine.parse(templateResult, mergedContext);
this.renderer.patchDOM(container, newHtml);
this._processEvents(container, mergedContext, listeners);
if (style) this._injectStyles(container, compId, style, mergedContext);
if (children) await this._mountComponents(container, children, childInstances);
if (!this._isMounted) {
/** @type {LifecycleHookContext} */
await (mergedContext.onMount == null ? void 0 : mergedContext.onMount({
container,
context: mergedContext
}));
this._isMounted = true;
} else {
/** @type {LifecycleHookContext} */
await (mergedContext.onUpdate == null ? void 0 : mergedContext.onUpdate({
container,
context: mergedContext
}));
}
};
/**
* Sets up reactive watchers for all Signal instances in the component's data.
* When a Signal's value changes, the component will re-render to reflect the updates.
* Stores unsubscribe functions to clean up watchers when component unmounts.
*/
for (const val of Object.values(data)) {
if (val instanceof Signal) watchers.push(val.watch(render));
}
await render();
const instance = {
container,
data: mergedContext,
/**
* Unmounts the component, cleaning up watchers and listeners, child components, and clearing the container.
*
* @returns {void}
*/
unmount: async () => {
/** @type {UnmountHookContext} */
await (mergedContext.onUnmount == null ? void 0 : mergedContext.onUnmount({
container,
context: mergedContext,
cleanup: {
watchers: watchers,
listeners: listeners,
children: childInstances
}
}));
for (const fn of watchers) fn();
for (const fn of listeners) fn();
for (const child of childInstances) await child.unmount();
container.innerHTML = "";
delete container._eleva_instance;
}
};
container._eleva_instance = instance;
return instance;
};
// Handle asynchronous setup.
const setupResult = typeof setup === "function" ? await setup(context) : {};
return await processMount(setupResult);
}
/**
* Processes DOM elements for event binding based on attributes starting with "@".
* This method handles the event delegation system and ensures proper cleanup of event listeners.
*
* @private
* @param {HTMLElement} container - The container element in which to search for event attributes.
* @param {ComponentContext} context - The current component context containing event handler definitions.
* @param {Array<() => void>} listeners - Array to collect cleanup functions for each event listener.
* @returns {void}
*/
_processEvents(container, context, listeners) {
/** @type {NodeListOf<Element>} */
const elements = container.querySelectorAll("*");
for (const el of elements) {
/** @type {NamedNodeMap} */
const attrs = el.attributes;
for (let i = 0; i < attrs.length; i++) {
/** @type {Attr} */
const attr = attrs[i];
if (!attr.name.startsWith("@")) continue;
/** @type {keyof HTMLElementEventMap} */
const event = attr.name.slice(1);
/** @type {string} */
const handlerName = attr.value;
/** @type {(event: Event) => void} */
const handler = context[handlerName] || TemplateEngine.evaluate(handlerName, context);
if (typeof handler === "function") {
el.addEventListener(event, handler);
el.removeAttribute(attr.name);
listeners.push(() => el.removeEventListener(event, handler));
}
}
}
}
/**
* Injects scoped styles into the component's container.
* The styles are automatically prefixed to prevent style leakage to other components.
*
* @private
* @param {HTMLElement} container - The container element where styles should be injected.
* @param {string} compId - The component ID used to identify the style element.
* @param {(function(ComponentContext): string)|string} styleDef - The component's style definition (function or string).
* @param {ComponentContext} context - The current component context for style interpolation.
* @returns {void}
*/
_injectStyles(container, compId, styleDef, context) {
/** @type {string} */
const newStyle = typeof styleDef === "function" ? TemplateEngine.parse(styleDef(context), context) : styleDef;
/** @type {HTMLStyleElement|null} */
let styleEl = container.querySelector(`style[data-e-style="${compId}"]`);
if (styleEl && styleEl.textContent === newStyle) return;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.setAttribute("data-e-style", compId);
container.appendChild(styleEl);
}
styleEl.textContent = newStyle;
}
/**
* Extracts props from an element's attributes that start with the specified prefix.
* This method is used to collect component properties from DOM elements.
*
* @private
* @param {HTMLElement} element - The DOM element to extract props from
* @returns {Record<string, string>} An object containing the extracted props
* @example
* // For an element with attributes:
* // <div :name="John" :age="25">
* // Returns: { name: "John", age: "25" }
*/
_extractProps(element) {
if (!element.attributes) return {};
const props = {};
const attrs = element.attributes;
for (let i = attrs.length - 1; i >= 0; i--) {
const attr = attrs[i];
if (attr.name.startsWith(":")) {
const propName = attr.name.slice(1);
props[propName] = attr.value;
element.removeAttribute(attr.name);
}
}
return props;
}
/**
* Mounts all components within the parent component's container.
* This method handles mounting of explicitly defined children components.
*
* The mounting process follows these steps:
* 1. Cleans up any existing component instances
* 2. Mounts explicitly defined children components
*
* @private
* @param {HTMLElement} container - The container element to mount components in
* @param {Object<string, ComponentDefinition>} children - Map of selectors to component definitions for explicit children
* @param {Array<MountResult>} childInstances - Array to store all mounted component instances
* @returns {Promise<void>}
*
* @example
* // Explicit children mounting:
* const children = {
* 'UserProfile': UserProfileComponent,
* '#settings-panel': "settings-panel"
* };
*/
async _mountComponents(container, children, childInstances) {
for (const [selector, component] of Object.entries(children)) {
if (!selector) continue;
for (const el of container.querySelectorAll(selector)) {
if (!(el instanceof HTMLElement)) continue;
/** @type {Record<string, string>} */
const props = this._extractProps(el);
/** @type {MountResult} */
const instance = await this.mount(el, component, props);
if (instance && !childInstances.includes(instance)) {
childInstances.push(instance);
}
}
}
}
}
return Eleva;
}));
//# sourceMappingURL=eleva.umd.js.map