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.
- package/CHANGELOG.md +466 -0
- package/README.md +198 -0
- package/package.json +91 -0
- package/src/components/base-component.js +925 -0
- package/src/core/component-manager.js +306 -0
- package/src/core/dom-morph.js +234 -0
- package/src/core/event-bus.js +229 -0
- package/src/core/router.js +487 -0
- package/src/core/signal.js +114 -0
- package/src/framework.js +323 -0
- package/src/plugins/alerts/alerts-plugin.js +427 -0
- package/src/plugins/fonts/files/inter.js +4 -0
- package/src/plugins/fonts/files/jetbrains-mono.js +4 -0
- package/src/plugins/fonts/font-manifests.js +53 -0
- package/src/plugins/fonts/fonts-plugin.js +246 -0
- package/src/plugins/icons/default-icons.js +51 -0
- package/src/plugins/icons/icons-plugin.js +130 -0
- package/src/plugins/store/store-plugin.js +127 -0
- package/src/plugins/theme/base-styles.js +58 -0
- package/src/plugins/theme/theme-plugin.js +160 -0
- package/src/utils/decorators.js +51 -0
- package/src/utils/dom.js +40 -0
- package/src/utils/error-handler.js +442 -0
- package/src/utils/framework-debug.js +375 -0
- package/src/utils/logger.js +324 -0
- package/src/utils/notification.js +123 -0
- package/src/utils/performance.js +281 -0
- package/src/utils/storage.js +86 -0
- package/src/utils/sweet-alert.js +84 -0
- package/src/utils/validation.js +70 -0
- package/src/utils/validators.js +129 -0
- package/types/index.d.ts +524 -0
|
@@ -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
|
+
}}
|