diff --git a/lib/compiler/ComponentParser.js b/lib/compiler/ComponentParser.js index a445055..bdc7cbc 100644 --- a/lib/compiler/ComponentParser.js +++ b/lib/compiler/ComponentParser.js @@ -677,7 +677,7 @@ class ${name} extends AvenxComponent { }); } - const dirRegex = /\b(data-ax-show|data-ax-class|data-ax-html)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + const dirRegex = /\b(data-ax-[a-zA-Z0-9_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; let dirMatch; while ((dirMatch = dirRegex.exec(match[0])) !== null) { events.push({ diff --git a/lib/core/index.d.ts b/lib/core/index.d.ts index 59a5c6f..656d82d 100644 --- a/lib/core/index.d.ts +++ b/lib/core/index.d.ts @@ -330,6 +330,11 @@ export class AvenxApp { */ bridges: Record; + /** + * Registered custom directives. + */ + directives: Map; + /** * Active router instance. */ @@ -395,6 +400,17 @@ export class AvenxApp { * @param callback Callback triggered when an unhandled lifecycle or event handler error occurs. */ onError(callback: (error: Error, component: AvenxComponent, origin: string) => void): this; + + /** + * Registers a custom directive. + * @param name Directive name. + * @param definition Directive lifecycle definition. + */ + directive(name: string, definition: { + mounted?(el: any, binding: { value: any; expression: string }): void; + updated?(el: any, binding: { value: any; oldValue: any; expression: string }): void; + unmounted?(el: any, binding: { value: any; oldValue: any; expression: string }): void; + }): this; } /** diff --git a/lib/core/renderer/domPatch.js b/lib/core/renderer/domPatch.js index c89e985..22afc47 100644 --- a/lib/core/renderer/domPatch.js +++ b/lib/core/renderer/domPatch.js @@ -49,8 +49,9 @@ export class DomPatcher { * @param {Element} target - The element to patch. * @param {string} html - The new HTML content. * @param {function(string): any} [resolveExpression] - Function to evaluate expressions. + * @param {object} [app] - The application context. */ - patch(target, html, resolveExpression) { + patch(target, html, resolveExpression, app) { const { enableProfiling, componentName } = getComponentProfilingInfo(target); profile(enableProfiling, componentName, 'patch', () => { const parser = new DOMParser(); @@ -67,7 +68,18 @@ export class DomPatcher { const newRoot = newDoc.body; this.flattenTransitionTags(newRoot); - this.#patchNode(target, newRoot, true, true, resolveExpression); + const prevSession = this.sessionElements; + const prevRoot = this.patchRoot; + this.sessionElements = new Set(); + this.patchRoot = target; + + try { + this.#patchNode(target, newRoot, true, true, resolveExpression, app); + this.flushLifecycleHooks(app); + } finally { + this.sessionElements = prevSession; + this.patchRoot = prevRoot; + } }); } @@ -76,12 +88,25 @@ export class DomPatcher { * @param {Element} oldElement - The existing element. * @param {Element} newElement - The new element structure. * @param {function(string): any} [resolveExpression] - Function to evaluate expressions. + * @param {object} [app] - The application context. */ - patchElement(oldElement, newElement, resolveExpression) { + patchElement(oldElement, newElement, resolveExpression, app) { const { enableProfiling, componentName } = getComponentProfilingInfo(oldElement); profile(enableProfiling, componentName, 'patch', () => { this.flattenTransitionTags(newElement); - this.#patchNode(oldElement, newElement, false, true, resolveExpression); + + const prevSession = this.sessionElements; + const prevRoot = this.patchRoot; + this.sessionElements = new Set(); + this.patchRoot = oldElement; + + try { + this.#patchNode(oldElement, newElement, false, true, resolveExpression, app); + this.flushLifecycleHooks(app); + } finally { + this.sessionElements = prevSession; + this.patchRoot = prevRoot; + } }); } @@ -92,9 +117,10 @@ export class DomPatcher { * @param {boolean} [isBodyWrapper] - Whether the new node is a temporary body wrapper. * @param {boolean} [isPatchRoot] - Whether this is the root node of the patching operation. * @param {function(string): any} [resolveExpression] - Function to evaluate expressions. + * @param {object} [app] - The application context. * @private */ - #patchNode(oldNode, newNode, isBodyWrapper = false, isPatchRoot = false, resolveExpression) { + #patchNode(oldNode, newNode, isBodyWrapper = false, isPatchRoot = false, resolveExpression, app) { if ( !isPatchRoot && oldNode.nodeType === Node.ELEMENT_NODE && @@ -105,7 +131,7 @@ export class DomPatcher { this.#patchAttributes(oldNode, newNode); oldNode.setAttribute('data-avenx-transcluded', 'true'); if (resolveExpression) { - this.#applyDirectives(oldNode, resolveExpression); + this.#applyDirectives(oldNode, resolveExpression, app); } } return; @@ -123,7 +149,7 @@ export class DomPatcher { compInstance.__updateTranscludedContent(newNode.childNodes); } if (resolveExpression) { - this.#applyDirectives(oldNode, resolveExpression); + this.#applyDirectives(oldNode, resolveExpression, app); } } return; @@ -149,7 +175,7 @@ export class DomPatcher { if (!isBodyWrapper && oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) { this.#patchAttributes(oldNode, newNode); if (resolveExpression) { - skipChildren = this.#applyDirectives(oldNode, resolveExpression); + skipChildren = this.#applyDirectives(oldNode, resolveExpression, app); } } @@ -214,7 +240,7 @@ export class DomPatcher { oldNode && oldNode.nodeType === Node.ELEMENT_NODE && (oldNode.namespaceURI === 'http://www.w3.org/2000/svg' || oldNode.tagName.toLowerCase() === 'svg'); - const prepared = this.#prepareNode(newChild, isParentSvg, resolveExpression); + const prepared = this.#prepareNode(newChild, isParentSvg, resolveExpression, app); oldNode.appendChild(prepared); this.triggerEnter(prepared, resolveExpression); } else if (this.#isSameNodeType(oldChild, newChild)) { @@ -224,7 +250,7 @@ export class DomPatcher { oldChild.textContent = newChild.textContent; } } else { - this.#patchNode(oldChild, newChild, false, false, resolveExpression); + this.#patchNode(oldChild, newChild, false, false, resolveExpression, app); } oldIndex++; } else { @@ -233,7 +259,7 @@ export class DomPatcher { oldNode && oldNode.nodeType === Node.ELEMENT_NODE && (oldNode.namespaceURI === 'http://www.w3.org/2000/svg' || oldNode.tagName.toLowerCase() === 'svg'); - const prepared = this.#prepareNode(newChild, isParentSvg, resolveExpression); + const prepared = this.#prepareNode(newChild, isParentSvg, resolveExpression, app); const transitionName = this.getTransitionName(oldChild, resolveExpression); if (transitionName) { @@ -242,8 +268,9 @@ export class DomPatcher { if (oldChild.parentNode === oldNode) { oldNode.removeChild(oldChild); } - }); + }, app); } else { + this.triggerUnmounted(oldChild, app); oldNode.replaceChild(prepared, oldChild); } this.triggerEnter(prepared, resolveExpression); @@ -260,7 +287,7 @@ export class DomPatcher { if (oldChild.parentNode === oldNode) { oldNode.removeChild(oldChild); } - }); + }, app); } oldIndex++; } @@ -394,10 +421,11 @@ export class DomPatcher { * @param {Node} node - The node to prepare. * @param {boolean} [isSvg] - Whether the node is within an SVG context. * @param {function(string): any} [resolveExpression] - Function to evaluate expressions. + * @param {object} [app] - The application context. * @returns {Node} The prepared node. * @private */ - #prepareNode(node, isSvg = false, resolveExpression) { + #prepareNode(node, isSvg = false, resolveExpression, app) { if (node.nodeType === Node.ELEMENT_NODE) { const tagName = node.tagName.toLowerCase(); if (tagName === 'template' || tagName === '@for') { @@ -407,7 +435,7 @@ export class DomPatcher { let skipChildren = false; if (resolveExpression) { - skipChildren = this.#applyDirectives(node, resolveExpression); + skipChildren = this.#applyDirectives(node, resolveExpression, app); } if (currentIsSvg) { @@ -416,7 +444,7 @@ export class DomPatcher { if (!skipChildren) { const children = Array.from(node.childNodes); for (const child of children) { - this.#prepareNode(child, currentIsSvg, resolveExpression); + this.#prepareNode(child, currentIsSvg, resolveExpression, app); } } return node; @@ -439,12 +467,12 @@ export class DomPatcher { } } if (resolveExpression) { - this.#applyDirectives(svgElement, resolveExpression); + this.#applyDirectives(svgElement, resolveExpression, app); } if (!skipChildren) { const children = Array.from(node.childNodes); for (const child of children) { - svgElement.appendChild(this.#prepareNode(child, currentIsSvg, resolveExpression)); + svgElement.appendChild(this.#prepareNode(child, currentIsSvg, resolveExpression, app)); } } return svgElement; @@ -454,7 +482,7 @@ export class DomPatcher { if (!skipChildren) { const children = Array.from(node.childNodes); for (const child of children) { - this.#prepareNode(child, false, resolveExpression); + this.#prepareNode(child, false, resolveExpression, app); } } return node; @@ -484,10 +512,11 @@ export class DomPatcher { * Evaluates and applies directives on a single element. * @param {Element} el - The element to evaluate directives on. * @param {function(string): any} resolveExpression - The expression evaluator. + * @param {object} [app] - The application context. * @returns {boolean} Whether children evaluation/diffing should be skipped. * @private */ - #applyDirectives(el, resolveExpression) { + #applyDirectives(el, resolveExpression, app) { if (!resolveExpression || el.nodeType !== Node.ELEMENT_NODE) { return false; } @@ -590,6 +619,70 @@ export class DomPatcher { } } + // 4. Custom Directives + if (app && app.directives) { + const attrs = Array.from(el.attributes || []); + for (const attr of attrs) { + if (attr.name.startsWith('data-ax-')) { + const dirName = attr.name.slice(8); + // Ignore built-in core directives + if ( + dirName === 'show' || + dirName === 'class' || + dirName === 'html' || + dirName === 'static' || + dirName === 'transition' || + dirName === 'for' || + dirName === 'as' || + dirName === 'key' || + dirName === 'list-item' || + dirName === 'key-val' + ) { + continue; + } + if (app.directives.has(dirName)) { + const expr = attr.value; + let value; + if (expr) { + try { + value = localResolve(expr); + } catch (err) { + logger.warn( + `Failed to evaluate custom directive ${dirName} expression "${expr}": ${err.message || err}` + ); + } + } + + if (!el.__avenx_directives) { + el.__avenx_directives = new Map(); + } + + const oldInfo = el.__avenx_directives.get(dirName); + if (!oldInfo) { + el.__avenx_directives.set(dirName, { + value: value, + oldValue: undefined, + expression: expr, + mountedCalled: false, + valueChanged: false, + }); + } else { + const oldValue = oldInfo.value; + if (value !== oldValue) { + oldInfo.oldValue = oldValue; + oldInfo.value = value; + oldInfo.valueChanged = true; + } + } + + if (this.sessionElements) { + this.sessionElements.add(el); + } + } + } + } + } + return skipChildren; } @@ -597,14 +690,30 @@ export class DomPatcher { * Recursively applies custom directives to an element and its children. * @param {Element} element - The element tree root. * @param {function(string): any} resolveExpression - The expression evaluator. + * @param {object} [app] - The application context. */ - applyDirectives(element, resolveExpression) { - const skip = this.#applyDirectives(element, resolveExpression); + applyDirectives(element, resolveExpression, app) { + const prevSession = this.sessionElements; + const prevRoot = this.patchRoot; + this.sessionElements = new Set(); + this.patchRoot = element; + + try { + this.applyDirectivesInternal(element, resolveExpression, app); + this.flushLifecycleHooks(app); + } finally { + this.sessionElements = prevSession; + this.patchRoot = prevRoot; + } + } + + applyDirectivesInternal(element, resolveExpression, app) { + const skip = this.#applyDirectives(element, resolveExpression, app); if (!skip) { const children = Array.from(element.childNodes); for (const child of children) { if (child.nodeType === Node.ELEMENT_NODE) { - this.applyDirectives(child, resolveExpression); + this.applyDirectivesInternal(child, resolveExpression, app); } } } @@ -768,16 +877,20 @@ export class DomPatcher { * @param {function(string): any} [resolveExpression] - The expression evaluator. * @param {function(): void} removeCallback - Callback invoked when transition completes or immediately. */ - triggerLeave(el, resolveExpression, removeCallback) { + triggerLeave(el, resolveExpression, removeCallback, app) { if (el.nodeType !== Node.ELEMENT_NODE) { if (removeCallback) removeCallback(); return; } + const done = () => { + this.triggerUnmounted(el, app); + if (removeCallback) removeCallback(); + }; const transitionName = el._transitionName || this.getTransitionName(el, resolveExpression); if (transitionName) { - this.leave(el, transitionName, removeCallback); + this.leave(el, transitionName, done); } else { - if (removeCallback) removeCallback(); + done(); } } @@ -808,4 +921,109 @@ export class DomPatcher { trans.parentNode.removeChild(trans); } } + + /** + * Checks if a node is in the currently patched DOM tree. + * @param {Element} el + * @returns {boolean} + */ + isNodeInPatchTree(el) { + if (!this.patchRoot) return false; + let curr = el; + while (curr) { + if (curr === this.patchRoot) return true; + curr = curr.parentNode || (curr.host && curr.host.nodeType === 1 ? curr.host : null); + } + return false; + } + + /** + * Triggers the unmounted hook recursively on a node and its descendants. + * @param {Node} node + * @param {object} app + */ + triggerUnmounted(node, app) { + if (!node || node.nodeType !== Node.ELEMENT_NODE) return; + + const descendants = Array.from(node.querySelectorAll('*')); + for (const desc of descendants) { + this.triggerUnmountedForElement(desc, app); + } + this.triggerUnmountedForElement(node, app); + } + + /** + * Triggers the unmounted hook on a single element. + * @param {Element} el + * @param {object} app + */ + triggerUnmountedForElement(el, app) { + if (el.__avenx_directives) { + for (const [dirName, info] of el.__avenx_directives.entries()) { + if (info.mountedCalled) { + const dirDef = app && app.directives && app.directives.get(dirName); + if (dirDef && typeof dirDef.unmounted === 'function') { + try { + dirDef.unmounted(el, { + value: info.value, + oldValue: info.value, + expression: info.expression, + }); + } catch (err) { + logger.warn(`Error in unmounted hook of directive ${dirName}: ${err.message || err}`); + } + } + info.mountedCalled = false; + } + } + } + } + + /** + * Flushes all lifecycle hooks collected during the patch session. + * @param {object} app + */ + flushLifecycleHooks(app) { + if (!this.sessionElements || !app) return; + for (const el of this.sessionElements) { + if (!el.__avenx_directives) continue; + + const isConnected = this.isNodeInPatchTree(el); + if (isConnected) { + for (const [dirName, info] of el.__avenx_directives.entries()) { + const dirDef = app.directives.get(dirName); + if (!dirDef) continue; + + if (!info.mountedCalled) { + if (typeof dirDef.mounted === 'function') { + try { + dirDef.mounted(el, { + value: info.value, + expression: info.expression, + }); + } catch (err) { + logger.error(`Error in mounted hook of directive ${dirName}: ${err.message || err}`); + } + } + info.mountedCalled = true; + } else if (info.valueChanged) { + if (typeof dirDef.updated === 'function') { + try { + dirDef.updated(el, { + value: info.value, + oldValue: info.oldValue, + expression: info.expression, + }); + } catch (err) { + logger.error(`Error in updated hook of directive ${dirName}: ${err.message || err}`); + } + } + info.valueChanged = false; + } + } + } else { + this.triggerUnmounted(el, app); + } + } + } } diff --git a/lib/core/renderer/listManager.js b/lib/core/renderer/listManager.js index a871bea..0274b3d 100644 --- a/lib/core/renderer/listManager.js +++ b/lib/core/renderer/listManager.js @@ -32,8 +32,9 @@ export class ListManager { * @param {Element} root - The root element to search in. * @param {object} scope - The evaluation scope. * @param {object} state - The component state. + * @param {object} [app] - The application context. */ - process(root, scope, state) { + process(root, scope, state, app) { const templates = root.querySelectorAll('template[data-ax-for]'); templates.forEach((template) => { let parent = template.parentNode; @@ -46,7 +47,7 @@ export class ListManager { parent = parent.parentNode; } if (!insideSlot) { - this.#updateList(template, scope, state); + this.#updateList(template, scope, state, app); } }); } @@ -56,9 +57,10 @@ export class ListManager { * @param {HTMLTemplateElement} template - The list template. * @param {object} scope - The evaluation scope. * @param {object} state - The component state. + * @param {object} [app] - The application context. * @private */ - #updateList(template, scope, state) { + #updateList(template, scope, state, app) { const listExpr = template.getAttribute('data-ax-for'); const itemVar = template.getAttribute('data-ax-as'); const keyExpr = template.getAttribute('data-ax-key'); @@ -139,7 +141,7 @@ export class ListManager { this.#nodePool.set(template, pool); } pool.push(element); - }); + }, app); } } @@ -172,20 +174,12 @@ export class ListManager { if (newElement) { let needsPatch = element.outerHTML !== newElement.outerHTML; if (!needsPatch) { - const hasDirectives = - element.hasAttribute('data-ax-show') || - element.hasAttribute('data-ax-class') || - element.hasAttribute('data-ax-html') || - (typeof element.querySelector === 'function' && - (element.querySelector('[data-ax-show]') || - element.querySelector('[data-ax-class]') || - element.querySelector('[data-ax-html]'))); - if (hasDirectives) { + if (hasDirectivesHelper(element)) { needsPatch = true; } } if (needsPatch) { - this.patcher.patchElement(element, newElement, resolver); + this.patcher.patchElement(element, newElement, resolver, app); } } } else { @@ -194,12 +188,12 @@ export class ListManager { const recycledElement = pool ? pool.pop() : null; if (recycledElement && newElement) { - this.patcher.patchElement(recycledElement, newElement, resolver); + this.patcher.patchElement(recycledElement, newElement, resolver, app); element = recycledElement; this.patcher.triggerEnter(element, resolver); } else if (newElement) { element = newElement; - this.patcher.applyDirectives(element, resolver); + this.patcher.applyDirectives(element, resolver, app); this.patcher.triggerEnter(element, resolver); } } @@ -306,3 +300,35 @@ export class ListManager { return items; } } + +/** + * Helper to check if an element or its descendants have custom directives. + * @param {Element} el + * @returns {boolean} + */ +function hasDirectivesHelper(el) { + if (!el || el.nodeType !== 1) return false; + const checkAttrs = (node) => { + if (!node.attributes) return false; + for (const attr of node.attributes) { + const name = attr.name; + if ( + name.startsWith('data-ax-') && + name !== 'data-ax-static' && + name !== 'data-ax-list-item' && + name !== 'data-ax-key-val' + ) { + return true; + } + } + return false; + }; + if (checkAttrs(el)) return true; + if (typeof el.querySelectorAll === 'function') { + const descendants = el.querySelectorAll('*'); + for (const desc of descendants) { + if (checkAttrs(desc)) return true; + } + } + return false; +} diff --git a/lib/core/runtime/AvenxApp.js b/lib/core/runtime/AvenxApp.js index 066597e..422a1b6 100644 --- a/lib/core/runtime/AvenxApp.js +++ b/lib/core/runtime/AvenxApp.js @@ -39,6 +39,8 @@ export class AvenxApp { this.components.set('VirtualList', VirtualList); /** @type {Map} */ this.pages = new Map(); + /** @type {Map} */ + this.directives = new Map(); /** @type {object} */ this.bridges = {}; /** @type {AvenxRouter|null} */ @@ -145,6 +147,17 @@ export class AvenxApp { this.pages.set(name, pageClass); } + /** + * Registers a custom directive with the application. + * @param {string} name - The name of the directive. + * @param {object} definition - The directive definition with lifecycle hooks. + * @returns {AvenxApp} The app instance. + */ + directive(name, definition) { + this.directives.set(name, definition); + return this; + } + /** * Initializes the router for the application. * @param {Object} routes - Route mapping. diff --git a/lib/core/runtime/AvenxComponent.js b/lib/core/runtime/AvenxComponent.js index 8b8f8c3..9939db5 100644 --- a/lib/core/runtime/AvenxComponent.js +++ b/lib/core/runtime/AvenxComponent.js @@ -436,12 +436,12 @@ export class AvenxComponent { this.#triggerLifecycle('onBeforeUpdate'); } - this.#patcher.patch(this.#element, this.render(), (expression, slotScope) => this.#resolveTemplateExpression(expression, slotScope)); + this.#patcher.patch(this.#element, this.render(), (expression, slotScope) => this.#resolveTemplateExpression(expression, slotScope), this.$app); // Fill slots with transcluded content. this.#fillSlots(); - this.#listManager.process(this.#element, this.#createScope(), this.state); + this.#listManager.process(this.#element, this.#createScope(), this.state, this.$app); this.#eventBinder.bind(this.#element, this.#eventExecutor); this.#collectRefs(); @@ -742,7 +742,7 @@ export class AvenxComponent { if (node.nodeType === 1) { this.#patcher.applyDirectives(node, (expr) => { return parentComp._evaluate(expr, extraScope); - }); + }, this.$app); } }); @@ -842,7 +842,7 @@ export class AvenxComponent { return this.#resolveTemplateExpression(expr); }; - this.#patcher.patchElement(slotEl, newSlotWrapper, slotEvaluator); + this.#patcher.patchElement(slotEl, newSlotWrapper, slotEvaluator, this.$app); }); } } @@ -911,6 +911,7 @@ export class AvenxComponent { this.$refs = {}; if (this.#element) { + this.#patcher.triggerUnmounted(this.#element, this.$app); delete this.#element.__avenx_comp_instance; this.#element.innerHTML = ''; this.#element = null; diff --git a/lib/core/runtime/VirtualList.js b/lib/core/runtime/VirtualList.js index d0e987a..9290b97 100644 --- a/lib/core/runtime/VirtualList.js +++ b/lib/core/runtime/VirtualList.js @@ -283,7 +283,7 @@ export class VirtualList extends AvenxComponent { newElement = this.patcher.cleanElement(newElement); newElement.setAttribute('data-index', String(itemIndex)); - this.patcher.patchElement(domNode, newElement, resolver); + this.patcher.patchElement(domNode, newElement, resolver, this.$app); } } } diff --git a/test/unit/directives_custom.test.js b/test/unit/directives_custom.test.js new file mode 100644 index 0000000..73b77ba --- /dev/null +++ b/test/unit/directives_custom.test.js @@ -0,0 +1,147 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +import { setupDOMMock, teardownDOMMock, MockDOMElement } from '../helpers/dom-mock.js'; +import { AvenxApp } from '../../lib/core/runtime/AvenxApp.js'; +import { AvenxComponent } from '../../lib/core/runtime/AvenxComponent.js'; +import ComponentParser from '../../lib/compiler/ComponentParser.js'; +import StyleProcessor from '../../lib/compiler/StyleProcessor.js'; +import { logger } from '../../lib/core/runtime/AvenxLogger.js'; + +async function runTests() { + console.log('๐Ÿงช Testing Runtime Custom Directives API in AvenxApp...'); + + setupDOMMock(); + + try { + // 1. Verify app.directive registration API + console.log(' Testing app.directive registration API...'); + const app = new AvenxApp({ target: 'div' }); + let mountedCalled = false; + let updatedCalled = false; + let unmountedCalled = false; + let mountedEl = null; + let mountedBinding = null; + let updatedBinding = null; + let unmountedBinding = null; + + app.directive('custom-test', { + mounted(el, binding) { + mountedCalled = true; + mountedEl = el; + mountedBinding = binding; + }, + updated(el, binding) { + updatedCalled = true; + updatedBinding = binding; + }, + unmounted(el, binding) { + unmountedCalled = true; + unmountedBinding = binding; + } + }); + + assert.ok(app.directives.has('custom-test'), 'custom-test directive should be registered in app.directives'); + + // 2. Verify focus directive makes the target input gain focus upon load + console.log(' Testing focus directive target focus on load...'); + let focusCalled = false; + app.directive('focus', { + mounted(el) { + el.focus = () => { + focusCalled = true; + }; + el.focus(); + } + }); + + class FocusComponent extends AvenxComponent { + constructor() { + super({}); + } + render() { + return ''; + } + } + + app.register('FocusComponent', FocusComponent); + app.mount('FocusComponent', 'div'); + + assert.ok(focusCalled, 'The focus hook should be called and input should gain focus'); + + // 3. Verify custom directive lifecycle (mounted, updated, unmounted) and bindings + console.log(' Testing custom-test directive lifecycle hooks...'); + class LifecycleComponent extends AvenxComponent { + constructor() { + super({ stateVal: 'initial' }); + } + render() { + return `
Text
`; + } + } + + const lifeComp = new LifecycleComponent({}); + lifeComp.$app = app; + + const targetEl = new MockDOMElement('div'); + lifeComp.mount(targetEl); + + assert.ok(mountedCalled, 'mounted hook should have been called'); + assert.strictEqual(mountedEl.tagName, 'DIV'); + assert.deepStrictEqual(mountedBinding, { value: 'initial', expression: 'stateVal' }); + + // Update stateVal to trigger updated hook + lifeComp.state.stateVal = 'updated-value'; + // Wait for batch update + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.ok(updatedCalled, 'updated hook should have been called when bound value shifts'); + assert.deepStrictEqual(updatedBinding, { value: 'updated-value', oldValue: 'initial', expression: 'stateVal' }); + + // Unmount component to trigger unmounted hook + lifeComp.unmount(); + assert.ok(unmountedCalled, 'unmounted hook should have been called'); + assert.deepStrictEqual(unmountedBinding, { value: 'updated-value', oldValue: 'updated-value', expression: 'stateVal' }); + + // 4. Verify compiler template checks reject undeclared identifiers in custom directives + console.log(' Testing compiler validation for custom directives...'); + const cp = new ComponentParser(new StyleProcessor()); + const tempFile = path.join(__dirname, 'TempCustomDirComp.component.js'); + + const tempContent = ` +
Hello
+ `; + fs.writeFileSync(tempFile, tempContent, 'utf-8'); + + let loggedWarning = false; + const originalLoggerWarn = logger.warn; + logger.warn = (msg) => { + if (msg.includes('Undeclared variable') && msg.includes('undeclaredCustomVar')) { + loggedWarning = true; + } + }; + + cp.parse(tempFile, 'TempCustomDirComp'); + + // Restore logger and clean up + logger.warn = originalLoggerWarn; + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + + assert.ok(loggedWarning, 'Compiler should warn about undeclared variables inside custom directives'); + + console.log(' โœ… Custom Directives unit tests successfully passed!'); + } catch (error) { + console.error('โŒ Custom Directives tests failed!'); + console.error(error); + process.exit(1); + } finally { + teardownDOMMock(); + } +} + +runTests();