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,306 @@
1
+ /**
2
+ * Component Manager
3
+ *
4
+ * Manages component registration, loading, rendering, and lifecycle.
5
+ * Handles component registration, instantiation, and lifecycle management.
6
+ *
7
+ * @author VanillaForge Team
8
+ * @version 1.0.0
9
+ * @since 2025-06-15
10
+ */
11
+
12
+ import { Logger } from '../utils/logger.js';
13
+ import { ErrorHandler } from '../utils/error-handler.js';
14
+
15
+ export class ComponentManager {
16
+ constructor(eventBus, logger, errorHandler, options = {}) {
17
+ this.eventBus = eventBus;
18
+ this.logger = logger || new Logger('ComponentManager');
19
+ this.errorHandler = errorHandler || new ErrorHandler();
20
+
21
+ // Default container id route components are mounted into. Configurable so
22
+ // apps and examples aren't locked to a single magic element id.
23
+ this.mountId = options.mountId || 'main-content';
24
+
25
+ this.components = new Map();
26
+ this.activeComponents = new Map();
27
+ this.isInitialized = false;
28
+
29
+ this.logger.debug('ComponentManager instance created');
30
+ }
31
+ /**
32
+ * Initialize the component manager
33
+ *
34
+ * @returns {Promise<void>}
35
+ */
36
+ async init() {
37
+ try {
38
+ this.logger.info('Initializing component manager...');
39
+
40
+ // Register built-in components
41
+ this.registerBuiltInComponents();
42
+
43
+ // Set up event listeners
44
+ this.setupEventListeners();
45
+
46
+ this.isInitialized = true;
47
+ this.logger.info('Component manager initialized successfully');
48
+
49
+ } catch (error) {
50
+ this.logger.error('Failed to initialize component manager', error);
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Alias for init() to maintain compatibility with FrameworkApp
57
+ */
58
+ async initialize() {
59
+ return this.init();
60
+ } /**
61
+ * Register built-in components
62
+ *
63
+ * @private
64
+ */
65
+ registerBuiltInComponents() {
66
+ this.logger.debug('Registering built-in components...');
67
+
68
+ // Built-in components will be registered externally via app.initialize()
69
+ // to avoid circular import dependencies
70
+
71
+ this.logger.debug(`Built-in components ready for external registration`);
72
+ }
73
+
74
+ /**
75
+ * Register a component
76
+ *
77
+ * @param {string} name - Component name
78
+ * @param {Class} ComponentClass - Component class
79
+ */
80
+ registerComponent(name, ComponentClass) {
81
+ if (this.components.has(name)) {
82
+ this.logger.warn(`Component ${name} already registered, overwriting`);
83
+ }
84
+
85
+ this.components.set(name, ComponentClass);
86
+ this.logger.debug(`Component registered: ${name}`);
87
+ } /**
88
+ * Load and render a component
89
+ *
90
+ * @param {string} componentName - Name of component to load
91
+ * @param {Object} props - Props to pass to component
92
+ * @param {string} containerId - ID of container element
93
+ * @returns {Promise<Object>} Component instance
94
+ */
95
+ async loadComponent(componentName, props = {}, containerId = this.mountId) {
96
+ const ComponentClass = this.components.get(componentName);
97
+ if (!ComponentClass) {
98
+ const error = new Error(`Component not registered: ${componentName}`);
99
+ this.errorHandler.handleError(error);
100
+ throw error;
101
+ }
102
+ return this.loadComponentClass(ComponentClass, props, containerId);
103
+ }
104
+
105
+ /**
106
+ * Load a component by class directly (for router use)
107
+ *
108
+ * @param {Function} ComponentClass - Component class constructor
109
+ * @param {Object} props - Component props
110
+ * @param {string} containerId - Target container ID
111
+ * @returns {Promise<Object>} Component instance
112
+ */
113
+ async loadComponentClass(ComponentClass, props = {}, containerId = this.mountId) {
114
+ try {
115
+ const container = document.getElementById(containerId);
116
+ if (!container) {
117
+ throw new Error(`Container not found: ${containerId}`);
118
+ }
119
+
120
+ // Unload any component currently mounted in this container so the new
121
+ // route owns it cleanly (single render path, no leftover listeners).
122
+ await this.unloadComponentsInContainer(container);
123
+
124
+ const instance = new ComponentClass(this.eventBus, props);
125
+ instance.container = container;
126
+
127
+ // Give the instance a reference to the app (for plugin service access)
128
+ // and a resolver so child() calls can look up components by name.
129
+ if (this.app) {
130
+ instance.app = this.app;
131
+ }
132
+ instance._resolveComponent = (name) => this.components.get(name);
133
+
134
+ // init() performs the single render (auto-render is on by default) and
135
+ // binds delegated DOM listeners on the component's stable wrapper.
136
+ await instance.init();
137
+
138
+ if (typeof instance.getLifecycle === 'function') {
139
+ const lifecycle = instance.getLifecycle();
140
+ if (lifecycle && typeof lifecycle.onMount === 'function') {
141
+ await lifecycle.onMount.call(instance);
142
+ }
143
+ }
144
+
145
+ const instanceId = `${instance.name}-${Date.now()}`;
146
+ this.activeComponents.set(instanceId, instance);
147
+
148
+ this.logger.info(`Component loaded successfully: ${instance.name}`);
149
+ return instance;
150
+ } catch (error) {
151
+ this.errorHandler.handleError(error, {
152
+ componentName: ComponentClass.name,
153
+ containerId
154
+ });
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Unload every active component currently mounted inside a container.
161
+ *
162
+ * @param {HTMLElement} container - Target container element
163
+ * @private
164
+ */
165
+ async unloadComponentsInContainer(container) {
166
+ for (const [id, instance] of this.activeComponents) {
167
+ if (instance.container === container) {
168
+ await this.unloadComponent(id);
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Unload a component
175
+ *
176
+ * @param {string} componentId - Component instance ID
177
+ * @returns {Promise<boolean>} Success status
178
+ */
179
+ async unloadComponent(componentId) {
180
+ try {
181
+ const instance = this.activeComponents.get(componentId);
182
+
183
+ if (!instance) {
184
+ this.logger.warn(`Component instance not found: ${componentId}`);
185
+ return false;
186
+ } // Call lifecycle onUnmount
187
+ if (instance.getLifecycle && typeof instance.getLifecycle === 'function') {
188
+ const lifecycle = instance.getLifecycle();
189
+ if (lifecycle.onUnmount && typeof lifecycle.onUnmount === 'function') {
190
+ await lifecycle.onUnmount.call(instance);
191
+ }
192
+ }
193
+
194
+ // Component's own destroy() removes delegated/manual listeners and clears
195
+ // its container (single source of teardown).
196
+ if (typeof instance.destroy === 'function') {
197
+ instance.destroy();
198
+ }
199
+
200
+ // Remove the wrapper element from the DOM
201
+ if (instance.element) {
202
+ instance.element.remove();
203
+ }
204
+
205
+ // Remove from active components
206
+ this.activeComponents.delete(componentId);
207
+
208
+ this.logger.debug(`Component unloaded: ${instance.name}`);
209
+ return true;
210
+
211
+ } catch (error) {
212
+ this.logger.error(`Failed to unload component: ${componentId}`, error);
213
+ return false;
214
+ }
215
+ }
216
+
217
+
218
+ /**
219
+ * Set up event listeners
220
+ *
221
+ * @private
222
+ */ setupEventListeners() {
223
+ // Listen for component load requests
224
+ this.eventBus.on('component:load', async ({ name, props, containerId }) => {
225
+ try {
226
+ await this.loadComponent(name, props, containerId);
227
+ } catch (error) {
228
+ this.logger.error('Failed to load requested component', error);
229
+ this.eventBus.emit('component:error', { error, componentName: name });
230
+ }
231
+ }); // Listen for router component load requests
232
+ this.eventBus.on('router:load-component', async ({ component, route, loaderData }) => {
233
+ try {
234
+ const props = loaderData !== undefined
235
+ ? { route, data: loaderData }
236
+ : { route };
237
+ if (typeof component === 'string') {
238
+ // Component name - load by name
239
+ await this.loadComponent(component, props, this.mountId);
240
+ } else if (typeof component === 'function') {
241
+ // Component class - load directly
242
+ await this.loadComponentClass(component, props, this.mountId);
243
+ } else {
244
+ throw new Error(`Invalid component type: ${typeof component}`);
245
+ }
246
+
247
+ this.logger.info(`Component loaded successfully: ${component.name || component}`);
248
+ } catch (error) {
249
+ this.logger.error('Failed to load router component', error);
250
+ this.eventBus.emit('component:error', { error, componentName: component });
251
+ }
252
+ });
253
+
254
+ // Listen for component unload requests
255
+ this.eventBus.on('component:unload', async ({ componentId }) => {
256
+ await this.unloadComponent(componentId);
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Get active components
262
+ *
263
+ * @returns {Map} Active components
264
+ */
265
+ getActiveComponents() {
266
+ return new Map(this.activeComponents);
267
+ }
268
+
269
+ /**
270
+ * Get registered components
271
+ *
272
+ * @returns {Array} Component names
273
+ */
274
+ getRegisteredComponents() {
275
+ return Array.from(this.components.keys());
276
+ }
277
+
278
+ /**
279
+ * Clean up component manager
280
+ *
281
+ * @returns {Promise<void>}
282
+ */
283
+ async cleanup() {
284
+ try {
285
+ this.logger.info('Cleaning up component manager');
286
+
287
+ // Unload all active components
288
+ const componentIds = Array.from(this.activeComponents.keys());
289
+ for (const componentId of componentIds) {
290
+ await this.unloadComponent(componentId);
291
+ }
292
+
293
+ // Clear registrations
294
+ this.components.clear();
295
+ this.activeComponents.clear();
296
+
297
+ // Reset state
298
+ this.isInitialized = false;
299
+
300
+ this.logger.info('Component manager cleanup complete');
301
+
302
+ } catch (error) {
303
+ this.logger.error('Error during component manager cleanup', error);
304
+ }
305
+ }
306
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * DOM Morph
3
+ *
4
+ * A small, zero-dependency DOM-diffing utility. Given a live element and a new
5
+ * HTML string, it patches only the parts of the DOM that actually changed
6
+ * instead of replacing `innerHTML` wholesale. This is what makes VanillaForge
7
+ * components "reactive" without a virtual DOM:
8
+ *
9
+ * - Unchanged nodes are left untouched (no flicker, no lost scroll position).
10
+ * - Focused form fields keep their focus, value, and selection/cursor range.
11
+ * - Lists annotated with `data-key` are reconciled by key, so reordering or
12
+ * removing an item does not rebuild the whole list.
13
+ *
14
+ * Roadmap: fine-grained reactivity (signals) would remove the need to re-render
15
+ * a component's full template on every change. See README "Roadmap".
16
+ */
17
+
18
+ // Elements whose user-facing value lives in DOM properties, not attributes.
19
+ const FORM_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION']);
20
+
21
+ // Attributes handled explicitly via syncFormState (never copied blindly).
22
+ const FORM_STATE_ATTRS = new Set(['value', 'checked', 'selected']);
23
+
24
+ /**
25
+ * Morph the children of `fromEl` to match `toHtml`.
26
+ *
27
+ * `fromEl` itself (the stable wrapper) is preserved — only its contents change.
28
+ *
29
+ * @param {HTMLElement} fromEl - Live element to patch in place.
30
+ * @param {string} toHtml - New HTML for the element's contents.
31
+ */
32
+ export function morph(fromEl, toHtml) {
33
+ const template = document.createElement('template');
34
+ template.innerHTML = typeof toHtml === 'string' ? toHtml : '';
35
+ morphChildren(fromEl, template.content);
36
+ }
37
+
38
+ /**
39
+ * Return a stable key for a node, or null if it has none.
40
+ * @private
41
+ */
42
+ function nodeKey(node) {
43
+ if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('data-key')) {
44
+ return node.getAttribute('data-key');
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Whether two nodes are "the same" for morphing purposes (can be patched in
51
+ * place rather than replaced).
52
+ * @private
53
+ */
54
+ function isSameNode(a, b) {
55
+ if (a.nodeType !== b.nodeType) return false;
56
+ if (a.nodeType === Node.ELEMENT_NODE) {
57
+ return a.tagName === b.tagName && nodeKey(a) === nodeKey(b);
58
+ }
59
+ return true; // text / comment nodes
60
+ }
61
+
62
+ /**
63
+ * Reconcile the child nodes of `oldParent` to match `newParent`.
64
+ * Handles keyed reuse (and moves) plus positional matching for unkeyed nodes.
65
+ * @private
66
+ */
67
+ function morphChildren(oldParent, newParent) {
68
+ const newNodes = Array.from(newParent.childNodes);
69
+ const oldNodes = Array.from(oldParent.childNodes);
70
+
71
+ // Index keyed old nodes so they can be reused even if they moved.
72
+ const keyedOld = new Map();
73
+ for (const node of oldNodes) {
74
+ const key = nodeKey(node);
75
+ if (key != null) keyedOld.set(key, node);
76
+ }
77
+
78
+ const used = new Set();
79
+ let cursor = 0; // pointer into oldNodes for positional (unkeyed) matching
80
+
81
+ for (let i = 0; i < newNodes.length; i++) {
82
+ const newNode = newNodes[i];
83
+ const key = nodeKey(newNode);
84
+ let reuse = null;
85
+
86
+ if (key != null && keyedOld.has(key)) {
87
+ // Keyed reuse: same logical item, possibly moved.
88
+ reuse = keyedOld.get(key);
89
+ keyedOld.delete(key);
90
+ used.add(reuse);
91
+ } else {
92
+ // Positional reuse: first compatible, unused, unkeyed old node.
93
+ while (cursor < oldNodes.length) {
94
+ const candidate = oldNodes[cursor++];
95
+ if (used.has(candidate)) continue;
96
+ if (nodeKey(candidate) != null) continue; // keyed nodes only reused by key
97
+ if (isSameNode(candidate, newNode)) {
98
+ reuse = candidate;
99
+ used.add(candidate);
100
+ }
101
+ break;
102
+ }
103
+ }
104
+
105
+ const slot = oldParent.childNodes[i] || null;
106
+ if (reuse) {
107
+ morphNode(reuse, newNode);
108
+ if (slot !== reuse) oldParent.insertBefore(reuse, slot);
109
+ } else {
110
+ oldParent.insertBefore(newNode.cloneNode(true), slot);
111
+ }
112
+ }
113
+
114
+ // Drop any leftover old nodes that were pushed past the new length.
115
+ while (oldParent.childNodes.length > newNodes.length) {
116
+ oldParent.removeChild(oldParent.lastChild);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Patch a single node (and recurse into element children).
122
+ * @private
123
+ */
124
+ function morphNode(oldNode, newNode) {
125
+ if (oldNode.nodeType === Node.TEXT_NODE || oldNode.nodeType === Node.COMMENT_NODE) {
126
+ if (oldNode.nodeValue !== newNode.nodeValue) {
127
+ oldNode.nodeValue = newNode.nodeValue;
128
+ }
129
+ return;
130
+ }
131
+
132
+ if (oldNode.nodeType !== Node.ELEMENT_NODE) return;
133
+
134
+ morphAttributes(oldNode, newNode);
135
+
136
+ // Child-component host elements are opaque boundaries: the mounted child owns
137
+ // its inner DOM. We update the host's own attributes (including data-key so
138
+ // identity is tracked) but never recurse into its children. The composition
139
+ // system in BaseComponent.reconcileChildren() takes care of updates.
140
+ if (oldNode.hasAttribute('data-vf-host')) return;
141
+
142
+ if (FORM_TAGS.has(oldNode.tagName)) {
143
+ syncFormState(oldNode, newNode);
144
+ }
145
+
146
+ // A textarea's value is its text content; syncFormState already handled it,
147
+ // so don't let morphChildren clobber the live value while the user types.
148
+ if (oldNode.tagName !== 'TEXTAREA') {
149
+ morphChildren(oldNode, newNode);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Copy attributes from `newEl` onto `oldEl`, adding/updating/removing as needed.
155
+ * Form-state attributes are skipped here and owned by syncFormState.
156
+ * @private
157
+ */
158
+ function morphAttributes(oldEl, newEl) {
159
+ const isForm = FORM_TAGS.has(oldEl.tagName);
160
+
161
+ for (const attr of Array.from(newEl.attributes)) {
162
+ if (isForm && FORM_STATE_ATTRS.has(attr.name)) continue;
163
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
164
+ oldEl.setAttribute(attr.name, attr.value);
165
+ }
166
+ }
167
+
168
+ for (const attr of Array.from(oldEl.attributes)) {
169
+ if (isForm && FORM_STATE_ATTRS.has(attr.name)) continue;
170
+ if (!newEl.hasAttribute(attr.name)) {
171
+ oldEl.removeAttribute(attr.name);
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Synchronise a form field's live value/checked state with the new template,
178
+ * preserving the caret/selection of a focused text field so typing is not
179
+ * interrupted by a re-render.
180
+ * @private
181
+ */
182
+ function syncFormState(oldEl, newEl) {
183
+ const isActive = oldEl.ownerDocument.activeElement === oldEl;
184
+
185
+ switch (oldEl.tagName) {
186
+ case 'INPUT': {
187
+ const type = (newEl.getAttribute('type') || 'text').toLowerCase();
188
+ if (type === 'checkbox' || type === 'radio') {
189
+ const next = newEl.hasAttribute('checked');
190
+ if (oldEl.checked !== next) oldEl.checked = next;
191
+ } else {
192
+ setValuePreservingCaret(oldEl, newEl.getAttribute('value') ?? '', isActive);
193
+ }
194
+ break;
195
+ }
196
+ case 'TEXTAREA': {
197
+ setValuePreservingCaret(oldEl, newEl.textContent ?? '', isActive);
198
+ break;
199
+ }
200
+ case 'SELECT': {
201
+ // Option `selected` attributes are reconciled by morphChildren; mirror the
202
+ // resulting value onto the live property.
203
+ const next = newEl.value;
204
+ if (next != null && oldEl.value !== next) oldEl.value = next;
205
+ break;
206
+ }
207
+ case 'OPTION': {
208
+ const next = newEl.hasAttribute('selected');
209
+ if (oldEl.selected !== next) oldEl.selected = next;
210
+ break;
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Set an input/textarea value, restoring the selection range when the element
217
+ * is focused so the user's caret position survives the update.
218
+ * @private
219
+ */
220
+ function setValuePreservingCaret(el, value, isActive) {
221
+ if (el.value === value) return;
222
+ if (isActive && typeof el.selectionStart === 'number') {
223
+ const start = el.selectionStart;
224
+ const end = el.selectionEnd;
225
+ el.value = value;
226
+ try {
227
+ el.setSelectionRange(Math.min(start, value.length), Math.min(end, value.length));
228
+ } catch {
229
+ /* selection not supported for this input type */
230
+ }
231
+ } else {
232
+ el.value = value;
233
+ }
234
+ }