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,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
|
+
}
|