vanillaforge 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,925 @@
1
+ /**
2
+ * Base Component Class
3
+ *
4
+ * This is the foundation class for all UI components in VanillaForge applications.
5
+ * It provides common functionality for component lifecycle, event handling, and state management.
6
+ *
7
+ * Features:
8
+ * - Lifecycle methods (init, render, destroy)
9
+ * - Event handling and cleanup
10
+ * - State management
11
+ * - Property validation
12
+ * - Error boundary protection
13
+ *
14
+ * @author VanillaForge Team
15
+ * @version 1.0.0
16
+ * @since 2025-06-15
17
+ */
18
+
19
+ import { Logger } from '../utils/logger.js';
20
+ import { ErrorHandler } from '../utils/error-handler.js';
21
+ import { morph } from '../core/dom-morph.js';
22
+ import { Signal } from '../core/signal.js';
23
+
24
+ /**
25
+ * Base Component Class
26
+ *
27
+ * All UI components should extend this class to inherit common functionality
28
+ * and maintain consistency across the application.
29
+ */
30
+ export class BaseComponent {
31
+ /**
32
+ * Create a new component instance
33
+ *
34
+ * @param {EventBus} eventBus - The event bus for communication
35
+ * @param {Object} props - Component properties and configuration
36
+ * @param {string} props.name - Component name for logging and debugging
37
+ * @param {boolean} props.autoRender - Whether to automatically render on initialization
38
+ */
39
+ constructor(eventBus, props = {}) {
40
+ // Validate required parameters
41
+ if (!eventBus) {
42
+ throw new Error('BaseComponent requires an EventBus instance');
43
+ }
44
+
45
+ // Core properties
46
+ this.eventBus = eventBus;
47
+ this.container = null; // Will be set during rendering
48
+ this.element = null; // Will be set during rendering
49
+ this.name = props.name || this.constructor.name;
50
+ this.props = props || {};
51
+ this.state = {};
52
+
53
+ // Component lifecycle flags
54
+ this.isInitialized = false;
55
+ this.isRendered = false;
56
+ this.isDestroyed = false;
57
+
58
+ // Event management
59
+ this.eventListeners = new Map();
60
+ this.eventBusSubscriptions = []; // Configuration
61
+ this.autoRender = props.autoRender !== false; // Default to true
62
+ this.autoLoadCSSEnabled = props.autoLoadCSS !== false; // Default to true
63
+ // Initialize logger with component context
64
+ this.logger = new Logger(`Component:${this.name}`, 'info');
65
+
66
+ // Initialize error handler
67
+ this.errorHandler = new ErrorHandler();
68
+
69
+ // Delegated DOM listeners attached once to the stable wrapper element.
70
+ this._delegationBound = false;
71
+ this._delegatedListeners = [];
72
+
73
+ // Composition: children and per-render spec buffer.
74
+ // _children: Map<resolvedKey, BaseComponent instance>
75
+ // _childSpecs: Array filled by child() calls during getTemplate()
76
+ this._children = new Map();
77
+ this._childSpecs = [];
78
+ this._childIndex = 0;
79
+
80
+ // Signals: reactive primitives created by this.signal()
81
+ this._signals = [];
82
+ this._signalRenderPending = false;
83
+
84
+ // Set by the framework (ComponentManager / parent component) so child()
85
+ // calls can look up sibling components by registered name.
86
+ this._resolveComponent = null;
87
+
88
+ // Set by ComponentManager so components can reach plugin services.
89
+ this.app = null;
90
+
91
+ // Bind methods to maintain context
92
+ this.handleError = this.handleError.bind(this);
93
+ this.cleanup = this.cleanup.bind(this);
94
+
95
+ this.logger.info('Component initialized', { name: this.name, props: this.props });
96
+ }
97
+
98
+ /**
99
+ * Initialize the component
100
+ *
101
+ * This method should be called after component construction to set up
102
+ * event listeners, validate props, and perform initial rendering.
103
+ *
104
+ * @returns {Promise<void>}
105
+ */ async init() {
106
+ if (this.isInitialized) {
107
+ this.logger.warn('Component already initialized');
108
+ return;
109
+ }
110
+
111
+ try {
112
+ this.logger.debug('Initializing component');
113
+
114
+ // Validate component properties
115
+ await this.validateProps(); // Auto-load CSS files if enabled
116
+ if (this.autoLoadCSSEnabled !== false) {
117
+ await this.autoLoadCSS();
118
+ }
119
+
120
+ // Setup component-specific initialization
121
+ await this.onInit();
122
+
123
+ // Setup event listeners
124
+ this.setupEventListeners();
125
+
126
+ // Auto-render if enabled
127
+ if (this.autoRender) {
128
+ await this.render();
129
+ }
130
+ this.isInitialized = true;
131
+ this.logger.info('Component initialized successfully', { name: this.name, props: this.props });
132
+
133
+ // Emit initialization complete event
134
+ this.emit('component:initialized', { component: this.name });
135
+
136
+ } catch (error) {
137
+ this.handleError(error, 'Component initialization failed');
138
+ throw error;
139
+ }
140
+ }/**
141
+ * Render the component
142
+ *
143
+ * This method generates and inserts the component's HTML into the container.
144
+ * It handles the complete rendering lifecycle including cleanup of existing content.
145
+ *
146
+ * @returns {Promise<void>}
147
+ */
148
+ async render() {
149
+ if (this.isDestroyed) {
150
+ throw new Error('Cannot render destroyed component');
151
+ }
152
+ if (!this.container) {
153
+ throw new Error('Component has no container to render into');
154
+ }
155
+
156
+ const renderStartTime = performance.now();
157
+
158
+ try {
159
+ this.logger.debug('Rendering component');
160
+
161
+ // Reset per-render child spec buffer before calling getTemplate(),
162
+ // which will refill it via this.child() calls.
163
+ this._childSpecs = [];
164
+ this._childIndex = 0;
165
+
166
+ // Generate component HTML
167
+ let html;
168
+ if (typeof this.getHTML === 'function') {
169
+ html = await this.getHTML();
170
+ } else if (typeof this.getTemplate === 'function') {
171
+ html = this.getTemplate();
172
+ } else {
173
+ throw new Error('Component must implement getHTML() or getTemplate() method');
174
+ }
175
+
176
+ if (!this.element) {
177
+ // First render: create a stable wrapper that persists across
178
+ // re-renders so event delegation and focus survive morphing.
179
+ this.element = document.createElement('div');
180
+ this.element.className = `component ${this.name}`.trim();
181
+ this.container.innerHTML = '';
182
+ this.container.appendChild(this.element);
183
+ this.element.innerHTML = html;
184
+ this.bindEventDelegation();
185
+ } else {
186
+ // Subsequent renders: patch only what changed.
187
+ morph(this.element, html);
188
+ }
189
+
190
+ // Mount, update, or unmount child components declared via child().
191
+ await this.reconcileChildren();
192
+
193
+ // Post-render setup
194
+ await this.onRender();
195
+
196
+ this.isRendered = true;
197
+
198
+ const renderTime = performance.now() - renderStartTime;
199
+ this.logger.debug('Component rendered successfully', { renderTime: `${renderTime.toFixed(2)}ms` });
200
+
201
+ // Emit render complete event with performance data
202
+ this.emit('component:rendered', {
203
+ component: this.name,
204
+ renderTime
205
+ });
206
+
207
+ } catch (error) {
208
+ this.handleError(error, 'Component rendering failed');
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Update component properties and re-render if necessary
215
+ *
216
+ * @param {Object} newProps - New properties to merge with existing props
217
+ * @param {boolean} forceRender - Force re-render even if props haven't changed
218
+ * @returns {Promise<void>}
219
+ */
220
+ async updateProps(newProps = {}, forceRender = false) {
221
+ try {
222
+ const oldProps = { ...this.props };
223
+ this.props = { ...this.props, ...newProps };
224
+
225
+ // Check if props actually changed
226
+ const propsChanged = JSON.stringify(oldProps) !== JSON.stringify(this.props);
227
+
228
+ if (propsChanged || forceRender) {
229
+ this.logger.debug('Props updated, re-rendering', { oldProps, newProps });
230
+
231
+ // Validate new props
232
+ await this.validateProps();
233
+
234
+ // Re-render component
235
+ await this.render();
236
+
237
+ // Emit props updated event
238
+ this.emit('component:propsUpdated', {
239
+ component: this.name,
240
+ oldProps,
241
+ newProps: this.props
242
+ });
243
+ }
244
+
245
+ } catch (error) {
246
+ this.handleError(error, 'Props update failed');
247
+ throw error;
248
+ }
249
+ } /**
250
+ * Update component state and trigger re-render if needed
251
+ *
252
+ * @param {Object} newState - New state to merge with existing state
253
+ * @param {boolean} autoRender - Whether to automatically re-render after state update
254
+ * @returns {Promise<void>}
255
+ */
256
+ async setState(newState = {}, autoRender = true) {
257
+ try {
258
+ // Prevent concurrent state updates
259
+ if (this._updatingState) {
260
+ this.logger.warn('State update already in progress, queuing update');
261
+ // Queue the state update
262
+ if (!this._stateUpdateQueue) {
263
+ this._stateUpdateQueue = [];
264
+ }
265
+ this._stateUpdateQueue.push({ newState, autoRender });
266
+ return;
267
+ }
268
+
269
+ this._updatingState = true;
270
+
271
+ try {
272
+ const oldState = { ...this.state };
273
+ this.state = { ...this.state, ...newState };
274
+
275
+ this.logger.debug('State updated', { oldState, newState: this.state });
276
+
277
+ if (autoRender && this.isRendered && !this.isDestroyed) {
278
+ await this.render();
279
+ }
280
+
281
+ // Emit state updated event
282
+ this.emit('component:stateUpdated', {
283
+ component: this.name,
284
+ oldState,
285
+ newState: this.state
286
+ });
287
+
288
+ } finally {
289
+ this._updatingState = false;
290
+
291
+ // Process queued state updates
292
+ if (this._stateUpdateQueue && this._stateUpdateQueue.length > 0) {
293
+ const queuedUpdate = this._stateUpdateQueue.shift();
294
+ // Process next update asynchronously to avoid blocking
295
+ setTimeout(() => {
296
+ this.setState(queuedUpdate.newState, queuedUpdate.autoRender);
297
+ }, 0);
298
+ }
299
+ }
300
+
301
+ } catch (error) {
302
+ this._updatingState = false;
303
+ this.handleError(error, 'State update failed');
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Destroy the component and clean up resources
310
+ *
311
+ * This method should be called when the component is no longer needed
312
+ * to prevent memory leaks and clean up event listeners.
313
+ */
314
+ destroy() {
315
+ if (this.isDestroyed) {
316
+ this.logger.warn('Component already destroyed');
317
+ return;
318
+ }
319
+
320
+ try {
321
+ this.logger.debug('Destroying component');
322
+
323
+ // Component-specific cleanup
324
+ this.onDestroy();
325
+
326
+ // Clean up event listeners and subscriptions
327
+ this.cleanup();
328
+
329
+ // Clear container
330
+ if (this.container) {
331
+ this.container.innerHTML = '';
332
+ }
333
+
334
+ // Mark as destroyed
335
+ this.isDestroyed = true;
336
+ this.isRendered = false;
337
+ this.isInitialized = false;
338
+
339
+ this.logger.info('Component destroyed successfully');
340
+
341
+ // Emit destruction event
342
+ this.emit('component:destroyed', { component: this.name });
343
+
344
+ } catch (error) {
345
+ this.handleError(error, 'Component destruction failed');
346
+ }
347
+ } /**
348
+ * Add event listener to an element with automatic cleanup
349
+ *
350
+ * @param {HTMLElement|string} target - Element or selector to attach event to
351
+ * @param {string} event - Event type (e.g., 'click', 'change')
352
+ * @param {Function} handler - Event handler function
353
+ * @param {Object} options - Event listener options
354
+ */
355
+ addEventListener(target, event, handler, options = {}) {
356
+ try {
357
+ let element = target;
358
+
359
+ // If target is a string, find element within component container
360
+ if (typeof target === 'string') {
361
+ element = this.container.querySelector(target);
362
+ if (!element) {
363
+ this.logger.warn('Element not found for event listener', { selector: target });
364
+ return;
365
+ }
366
+ }
367
+
368
+ // Validate element
369
+ if (!element || !(element instanceof HTMLElement)) {
370
+ throw new Error('Invalid element for event listener');
371
+ }
372
+
373
+ // Create wrapped handler for error handling
374
+ const wrappedHandler = (e) => {
375
+ try {
376
+ handler.call(this, e);
377
+ } catch (error) {
378
+ this.handleError(error, `Event handler failed for ${event}`);
379
+ }
380
+ };
381
+
382
+ // Add event listener
383
+ element.addEventListener(event, wrappedHandler, options);
384
+
385
+ // Store for cleanup with a more unique key
386
+ const key = `${element.tagName}-${event}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
387
+ this.eventListeners.set(key, {
388
+ element,
389
+ event,
390
+ handler: wrappedHandler,
391
+ options
392
+ });
393
+
394
+ this.logger.debug('Event listener added', { target: typeof target === 'string' ? target : target.tagName, event });
395
+
396
+ return key; // Return key for manual removal if needed
397
+
398
+ } catch (error) {
399
+ this.handleError(error, 'Failed to add event listener');
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Subscribe to EventBus events with automatic cleanup
406
+ *
407
+ * @param {string} eventName - Event name to subscribe to
408
+ * @param {Function} handler - Event handler function
409
+ */ subscribe(eventName, handler) {
410
+ try {
411
+ // Create wrapped handler for error handling
412
+ const wrappedHandler = (data) => {
413
+ try {
414
+ handler.call(this, data);
415
+ } catch (error) {
416
+ this.handleError(error, `EventBus handler failed for ${eventName}`);
417
+ }
418
+ };
419
+
420
+ // Subscribe to event using the eventBus instance and get unsubscribe function
421
+ const unsubscribe = this.eventBus.on(eventName, wrappedHandler);
422
+
423
+ // Store for cleanup
424
+ this.eventBusSubscriptions.push({
425
+ eventName,
426
+ unsubscribe
427
+ });
428
+
429
+ this.logger.debug('EventBus subscription added', { eventName });
430
+
431
+ } catch (error) {
432
+ this.handleError(error, 'Failed to subscribe to EventBus event');
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Emit an event through the EventBus
438
+ *
439
+ * @param {string} eventName - Event name to emit
440
+ * @param {*} data - Event data
441
+ */ emit(eventName, data) {
442
+ try {
443
+ this.eventBus.emit(eventName, data);
444
+ this.logger.debug('Event emitted', { eventName, data });
445
+ } catch (error) {
446
+ this.handleError(error, 'Failed to emit event');
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Create a reactive signal linked to this component.
452
+ *
453
+ * When signal.set(newValue) is called, the component re-renders via the
454
+ * existing DOM morph. Multiple .set() calls within the same synchronous
455
+ * block are batched into a single render (scheduled as a microtask).
456
+ *
457
+ * The signal is automatically destroyed when the component is destroyed.
458
+ *
459
+ * @param {*} initialValue
460
+ * @returns {Signal}
461
+ */
462
+ signal(initialValue) {
463
+ const sig = new Signal(initialValue)._link(this);
464
+ this._signals.push(sig);
465
+ return sig;
466
+ }
467
+
468
+ /**
469
+ * Schedule one morph re-render in the next microtask.
470
+ * Idempotent: calling it multiple times before the microtask fires
471
+ * results in exactly one render.
472
+ * @private
473
+ */
474
+ _scheduleSignalRender() {
475
+ if (this._signalRenderPending) return;
476
+ this._signalRenderPending = true;
477
+ Promise.resolve().then(() => {
478
+ this._signalRenderPending = false;
479
+ if (this.isRendered && !this.isDestroyed) {
480
+ this.render();
481
+ }
482
+ });
483
+ }
484
+
485
+ /**
486
+ * Find elements within the component container
487
+ *
488
+ * @param {string} selector - CSS selector
489
+ * @returns {HTMLElement|null} First matching element
490
+ */
491
+ querySelector(selector) {
492
+ return (this.element || this.container).querySelector(selector);
493
+ }
494
+
495
+ /**
496
+ * Find all elements within the component container
497
+ *
498
+ * @param {string} selector - CSS selector
499
+ * @returns {NodeList} All matching elements
500
+ */
501
+ querySelectorAll(selector) {
502
+ return (this.element || this.container).querySelectorAll(selector);
503
+ }
504
+
505
+ /**
506
+ * Load CSS file dynamically
507
+ *
508
+ * @param {string} cssPath - Path to the CSS file
509
+ * @returns {Promise<void>}
510
+ */
511
+ async loadCSS(cssPath) {
512
+ return new Promise((resolve, reject) => {
513
+ // Check if CSS is already loaded
514
+ const existingLink = document.querySelector(`link[href="${cssPath}"]`);
515
+ if (existingLink) {
516
+ resolve();
517
+ return;
518
+ }
519
+
520
+ // Create link element
521
+ const link = document.createElement('link');
522
+ link.rel = 'stylesheet';
523
+ link.type = 'text/css';
524
+ link.href = cssPath;
525
+
526
+ // Handle load/error events
527
+ link.onload = () => resolve();
528
+ link.onerror = () => reject(new Error(`Failed to load CSS: ${cssPath}`));
529
+
530
+ // Add to document head
531
+ document.head.appendChild(link);
532
+ });
533
+ } /**
534
+ * Unload CSS file
535
+ *
536
+ * @param {string} cssPath - Path to the CSS file
537
+ */
538
+ unloadCSS(cssPath) {
539
+ const link = document.querySelector(`link[href="${cssPath}"]`);
540
+ if (link) {
541
+ link.remove();
542
+ }
543
+ } /**
544
+ * Auto-load CSS for component based on naming convention
545
+ * Looks for CSS files in: styles/components/{component-name}.css
546
+ *
547
+ * @private
548
+ * @returns {Promise<void>}
549
+ */
550
+ async autoLoadCSS() {
551
+ if (!this.name || typeof window === 'undefined') return;
552
+
553
+ // Generate CSS filename from component name
554
+ const cssFileName = this.name.toLowerCase()
555
+ .replace(/component$/, '') // Remove 'component' suffix if present
556
+ .replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
557
+ .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
558
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
559
+
560
+ // Add 'component' suffix back for CSS file naming convention
561
+ const fullCssName = cssFileName ? `${cssFileName}-component` : this.name.toLowerCase();
562
+
563
+ const cssPaths = [
564
+ `styles/components/${fullCssName}.css`, // For built version (primary)
565
+ `src/styles/components/${fullCssName}.css`, // For development
566
+ `styles/components/${this.name.toLowerCase()}.css`, // Fallback with exact name
567
+ `src/styles/components/${this.name.toLowerCase()}.css` // Fallback development
568
+ ];
569
+
570
+ for (const cssPath of cssPaths) {
571
+ try {
572
+ await this.loadCSS(cssPath);
573
+ this.logger.debug(`Auto-loaded CSS: ${cssPath}`);
574
+ break; // Stop after first successful load
575
+ } catch (_e) {
576
+ // Continue to next path - this is expected behavior
577
+ continue;
578
+ }
579
+ }
580
+ }
581
+
582
+ // ===========================
583
+ // Composition API
584
+ // ===========================
585
+
586
+ /**
587
+ * Declare a child component inside a getTemplate() call.
588
+ *
589
+ * Returns a placeholder string that the DOM morph treats as an opaque host
590
+ * boundary. After the morph, reconcileChildren() mounts / updates / removes
591
+ * real child instances to match what the template declared.
592
+ *
593
+ * @param {Function|string} componentClassOrName - Component class or registered name
594
+ * @param {Object} [props={}] - Props to pass to the child
595
+ * @param {string|number|null} [key=null] - Stable key for keyed reconciliation
596
+ * @returns {string} Host placeholder HTML string
597
+ *
598
+ * @example
599
+ * getTemplate() {
600
+ * return `
601
+ * <ul>
602
+ * ${this.state.items.map(item =>
603
+ * this.child(ItemRow, { item }, item.id)
604
+ * ).join('')}
605
+ * </ul>`;
606
+ * }
607
+ */
608
+ child(componentClassOrName, props = {}, key = null) {
609
+ let ComponentClass = componentClassOrName;
610
+
611
+ if (typeof componentClassOrName === 'string') {
612
+ ComponentClass = this._resolveComponent
613
+ ? this._resolveComponent(componentClassOrName)
614
+ : null;
615
+ if (!ComponentClass) {
616
+ this.logger.warn(`child(): component not registered: "${componentClassOrName}"`);
617
+ return '';
618
+ }
619
+ }
620
+
621
+ const index = this._childIndex++;
622
+ const resolvedKey = key != null ? String(key) : `__vf_${index}`;
623
+
624
+ this._childSpecs.push({ ComponentClass, props, key: resolvedKey });
625
+
626
+ return `<div data-vf-host="${index}" data-key="${resolvedKey}"></div>`;
627
+ }
628
+
629
+ /**
630
+ * Mount, update, or unmount child components after a render.
631
+ * Called automatically by render() — do not call this manually.
632
+ * @private
633
+ */
634
+ async reconcileChildren() {
635
+ if (this._childSpecs.length === 0 && this._children.size === 0) return;
636
+
637
+ const hostEls = Array.from(this.element.querySelectorAll('[data-vf-host]'));
638
+ const usedKeys = new Set();
639
+
640
+ for (let i = 0; i < this._childSpecs.length; i++) {
641
+ const spec = this._childSpecs[i];
642
+ const hostEl = hostEls[i];
643
+ if (!hostEl) continue;
644
+
645
+ usedKeys.add(spec.key);
646
+
647
+ if (this._children.has(spec.key)) {
648
+ // Existing child: update props (re-renders only if they changed).
649
+ const child = this._children.get(spec.key);
650
+ // Re-attach the wrapper to the host in case morph moved the host.
651
+ if (child.element && child.element.parentElement !== hostEl) {
652
+ hostEl.innerHTML = '';
653
+ hostEl.appendChild(child.element);
654
+ }
655
+ await child.updateProps(spec.props);
656
+ } else {
657
+ // New child: instantiate, wire, and mount.
658
+ const child = new spec.ComponentClass(this.eventBus, spec.props);
659
+ child.container = hostEl;
660
+ // Propagate app reference and component resolver down the tree.
661
+ child.app = this.app;
662
+ child._resolveComponent = this._resolveComponent;
663
+ await child.init();
664
+ if (typeof child.getLifecycle === 'function') {
665
+ const lc = child.getLifecycle();
666
+ if (lc && typeof lc.onMount === 'function') await lc.onMount.call(child);
667
+ }
668
+ this._children.set(spec.key, child);
669
+ }
670
+ }
671
+
672
+ // Destroy children whose host was removed from the template.
673
+ for (const [key, child] of this._children) {
674
+ if (!usedKeys.has(key)) {
675
+ try {
676
+ if (typeof child.getLifecycle === 'function') {
677
+ const lc = child.getLifecycle();
678
+ if (lc && typeof lc.onUnmount === 'function') await lc.onUnmount.call(child);
679
+ }
680
+ child.destroy();
681
+ } catch (err) {
682
+ this.logger.warn('Error destroying removed child', { key, err });
683
+ }
684
+ this._children.delete(key);
685
+ }
686
+ }
687
+ }
688
+
689
+ // ===========================
690
+ // Plugin service access
691
+ // ===========================
692
+
693
+ /**
694
+ * Look up a plugin service registered with app.provide().
695
+ * Returns null when the app reference is not set or the service is absent
696
+ * (so templates can safely fall back: `this.service('icons')?.render(...) ?? ''`).
697
+ *
698
+ * @param {string} name - Service name (e.g. 'icons', 'theme', 'alerts')
699
+ * @returns {*|null} Service instance or null
700
+ */
701
+ service(name) {
702
+ return this.app ? this.app.get(name) : null;
703
+ }
704
+
705
+ /**
706
+ * Render an inline SVG icon from the built-in icons service.
707
+ * Returns an empty string if the icons plugin is not installed, so templates
708
+ * degrade gracefully without needing null checks everywhere.
709
+ *
710
+ * @param {string} name - Icon name (e.g. 'check', 'trash', 'menu')
711
+ * @param {Object} [opts={}] - Options forwarded to IconsService.render()
712
+ * @returns {string} Inline SVG string, or '' if icons service is unavailable
713
+ */
714
+ icon(name, opts = {}) {
715
+ const icons = this.service('icons');
716
+ return icons ? icons.render(name, opts) : '';
717
+ }
718
+
719
+ // ===========================
720
+ // Lifecycle Hooks (Override in subclasses)
721
+ // ===========================
722
+
723
+ /**
724
+ * Component-specific initialization
725
+ * Override in subclasses for custom initialization logic
726
+ *
727
+ * @returns {Promise<void>}
728
+ */
729
+ async onInit() {
730
+ // Override in subclasses
731
+ }
732
+
733
+ /**
734
+ * Component-specific post-render setup
735
+ * Override in subclasses for post-render logic
736
+ *
737
+ * @returns {Promise<void>}
738
+ */
739
+ async onRender() {
740
+ // Override in subclasses
741
+ }
742
+
743
+ /**
744
+ * Component-specific cleanup
745
+ * Override in subclasses for custom cleanup logic
746
+ */
747
+ onDestroy() {
748
+ // Override in subclasses
749
+ } /**
750
+ * Generate component HTML
751
+ * MUST be implemented in subclasses
752
+ *
753
+ * @returns {Promise<string>} Component HTML string
754
+ */
755
+ async getHTML() {
756
+ // Check if component has getTemplate method (new architecture)
757
+ if (typeof this.getTemplate === 'function') {
758
+ return this.getTemplate();
759
+ }
760
+ throw new Error('getHTML() or getTemplate() must be implemented in component subclass');
761
+ }
762
+
763
+ /**
764
+ * Validate component properties
765
+ * Override in subclasses for custom validation
766
+ *
767
+ * @returns {Promise<void>}
768
+ */
769
+ async validateProps() {
770
+ // Override in subclasses for prop validation
771
+ }
772
+
773
+ // ===========================
774
+ // Private Methods
775
+ // ===========================
776
+
777
+ /**
778
+ * Setup component-specific event listeners
779
+ * Override in subclasses for one-time, non-delegated setup.
780
+ * @private
781
+ */
782
+ setupEventListeners() {
783
+ // Override in subclasses
784
+ }
785
+
786
+ /**
787
+ * Bind delegated DOM listeners once to the stable wrapper element.
788
+ *
789
+ * Because the wrapper survives every morph, these listeners are attached a
790
+ * single time and never need rebinding. Each event type maps to exactly one
791
+ * declarative attribute so a given interaction fires once:
792
+ *
793
+ * data-action -> click (buttons, links, anything clickable)
794
+ * data-change -> change (checkboxes, radios, selects)
795
+ * data-input -> input (text inputs, textareas)
796
+ * data-keydown -> keydown
797
+ * data-submit -> submit (forms)
798
+ *
799
+ * The named method is looked up on getMethods() at dispatch time and called
800
+ * with (event, matchedElement).
801
+ * @private
802
+ */
803
+ bindEventDelegation() {
804
+ if (this._delegationBound || !this.element) return;
805
+
806
+ const map = [
807
+ ['click', 'data-action', true],
808
+ ['change', 'data-change', false],
809
+ ['input', 'data-input', false],
810
+ ['keydown', 'data-keydown', false],
811
+ ['submit', 'data-submit', true],
812
+ ];
813
+
814
+ for (const [eventType, attr, preventDefault] of map) {
815
+ const handler = (event) => this.dispatchDelegated(event, attr, preventDefault);
816
+ this.element.addEventListener(eventType, handler);
817
+ this._delegatedListeners.push({ eventType, handler });
818
+ }
819
+
820
+ this._delegationBound = true;
821
+ this.logger.debug('Event delegation bound');
822
+ }
823
+
824
+ /**
825
+ * Resolve and invoke the method named by a delegated attribute.
826
+ * @private
827
+ */
828
+ dispatchDelegated(event, attr, preventDefault) {
829
+ const target = event.target.closest(`[${attr}]`);
830
+ if (!target || !this.element.contains(target)) return;
831
+
832
+ // If the event originated inside a child component's host element, that
833
+ // child's own delegation listener handles it. Don't double-dispatch.
834
+ const host = event.target.closest('[data-vf-host]');
835
+ if (host && this.element.contains(host)) return;
836
+
837
+ const action = target.getAttribute(attr);
838
+ const methods = (typeof this.getMethods === 'function') ? this.getMethods() : {};
839
+
840
+ if (action && typeof methods[action] === 'function') {
841
+ if (preventDefault) event.preventDefault();
842
+ try {
843
+ methods[action].call(this, event, target);
844
+ this.logger.debug(`Action executed: ${action} (${attr})`);
845
+ } catch (error) {
846
+ this.handleError(error, `Failed to execute action: ${action}`);
847
+ }
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Handle component errors
853
+ * @private
854
+ */
855
+ handleError(error, context = 'Component error') {
856
+ this.logger.error(context, error);
857
+ this.errorHandler.handleError(error, {
858
+ component: this.name,
859
+ context,
860
+ state: this.state,
861
+ props: this.props
862
+ });
863
+ }
864
+
865
+ /**
866
+ * Clean up event listeners and subscriptions
867
+ * @private
868
+ */
869
+ cleanup() {
870
+ // Tear down child components first so their listeners and subtrees are
871
+ // destroyed before we remove our own wrapper from the DOM.
872
+ for (const child of this._children.values()) {
873
+ try {
874
+ if (typeof child.getLifecycle === 'function') {
875
+ const lc = child.getLifecycle();
876
+ if (lc && typeof lc.onUnmount === 'function') lc.onUnmount.call(child);
877
+ }
878
+ child.destroy();
879
+ } catch (err) {
880
+ this.logger.warn('Error destroying child component during cleanup', { err });
881
+ }
882
+ }
883
+ this._children.clear();
884
+
885
+ // Clean up DOM event listeners
886
+ for (const [key, listener] of this.eventListeners) {
887
+ try {
888
+ listener.element.removeEventListener(
889
+ listener.event,
890
+ listener.handler,
891
+ listener.options
892
+ );
893
+ } catch (error) {
894
+ this.logger.warn('Failed to remove event listener', { key, error });
895
+ }
896
+ }
897
+ this.eventListeners.clear();
898
+
899
+ // Clean up delegated listeners attached to the wrapper element
900
+ if (this.element) {
901
+ for (const { eventType, handler } of this._delegatedListeners) {
902
+ this.element.removeEventListener(eventType, handler);
903
+ }
904
+ }
905
+ this._delegatedListeners = [];
906
+ this._delegationBound = false;
907
+
908
+ // Clean up EventBus subscriptions
909
+ for (const subscription of this.eventBusSubscriptions) {
910
+ try {
911
+ subscription.unsubscribe();
912
+ } catch (error) {
913
+ this.logger.warn('Failed to unsubscribe from event', {
914
+ eventName: subscription.eventName,
915
+ error
916
+ });
917
+ }
918
+ }
919
+ this.eventBusSubscriptions = [];
920
+
921
+ // Destroy signals so pending renders and subscriber refs are released.
922
+ for (const sig of this._signals) sig._destroy();
923
+ this._signals = [];
924
+ this._signalRenderPending = false;
925
+ }}