@ruledwdl/dom 0.1.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/LICENSE +71 -0
- package/README.md +146 -0
- package/dist/wdl-dom.min.js +2 -0
- package/examples/dom-refactor-cdn.html +936 -0
- package/examples/index.html +936 -0
- package/package.json +40 -0
- package/src/index.js +753 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ruledwdl/dom — Native DOM runtime for @ruledwdl/state
|
|
3
|
+
*
|
|
4
|
+
* Listens to every event emitted by ComponentState / ComponentManager and
|
|
5
|
+
* applies surgical DOM mutations with createElement / insertBefore / remove.
|
|
6
|
+
* No innerHTML on updates.
|
|
7
|
+
*
|
|
8
|
+
* State events handled (from @ruledwdl/state):
|
|
9
|
+
* layers:change actions: set | append | before | after | wrap | remove | update
|
|
10
|
+
* attr:change actions: set | update | remove
|
|
11
|
+
* data:change actions: set | remove (optional data-* / text binding)
|
|
12
|
+
* variant:change actions: set
|
|
13
|
+
* registry:change actions: set | update | addRule | removeRule
|
|
14
|
+
*
|
|
15
|
+
* Event payload shape (from state.emit):
|
|
16
|
+
* {
|
|
17
|
+
* type: string, // e.g. "layers:change"
|
|
18
|
+
* componentId: string,
|
|
19
|
+
* action: string, // e.g. "append"
|
|
20
|
+
* targetId?: string, // semantic id involved
|
|
21
|
+
* payload?: any, // layer expr | attrs | path value | rule …
|
|
22
|
+
* timestamp: number
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* import { createWdlDom } from './wdl-dom.js';
|
|
27
|
+
* // or: import { ComponentManager } from '@ruledwdl/state';
|
|
28
|
+
*
|
|
29
|
+
* const hero = manager.create('hero', { layers: '...', attr: {...} });
|
|
30
|
+
* const dom = createWdlDom({
|
|
31
|
+
* container: document.getElementById('app'),
|
|
32
|
+
* component: hero, // ComponentState instance
|
|
33
|
+
* // optional:
|
|
34
|
+
* // onDataBind: (el, path, value) => { ... },
|
|
35
|
+
* // styleTarget: document.head,
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* // later: hero.attr.set('title', { text: 'Hi' }); // → surgical update
|
|
39
|
+
* // destroy: dom.destroy();
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const ATTR_SKIP = new Set(['text', 'class', 'html']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a single layer token: "button.cta" | "div" | "h1.title"
|
|
50
|
+
* @returns {{ tag: string, semanticId: string }}
|
|
51
|
+
*/
|
|
52
|
+
function parseLayerToken(expr) {
|
|
53
|
+
if (typeof expr !== 'string') {
|
|
54
|
+
if (expr && typeof expr === 'object') {
|
|
55
|
+
return {
|
|
56
|
+
tag: String(expr.tag || 'div').toLowerCase(),
|
|
57
|
+
semanticId: String(expr.semanticId || expr.id || '').replace(/^\./, ''),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
throw new Error('[wdl-dom] invalid layer expression');
|
|
61
|
+
}
|
|
62
|
+
const trimmed = expr.trim();
|
|
63
|
+
const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);
|
|
64
|
+
if (!m) {
|
|
65
|
+
// fallback: treat whole string as tag
|
|
66
|
+
return { tag: trimmed.toLowerCase() || 'div', semanticId: '' };
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
tag: m[1].toLowerCase(),
|
|
70
|
+
semanticId: (m[2] || '').replace(/^\./, ''),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Very small layers string → tree (supports > and + only).
|
|
76
|
+
* For full WDL grammar prefer tree from component.layers.tree().
|
|
77
|
+
*/
|
|
78
|
+
function parseLayersSimple(str) {
|
|
79
|
+
if (Array.isArray(str)) return structuredClone(str);
|
|
80
|
+
if (typeof str !== 'string' || !str.trim()) return [];
|
|
81
|
+
|
|
82
|
+
const root = { tag: '__root__', semanticId: '', children: [] };
|
|
83
|
+
const stack = [root];
|
|
84
|
+
let i = 0;
|
|
85
|
+
|
|
86
|
+
const top = () => stack[stack.length - 1];
|
|
87
|
+
|
|
88
|
+
while (i < str.length) {
|
|
89
|
+
const ch = str[i];
|
|
90
|
+
if (/\s/.test(ch)) { i++; continue; }
|
|
91
|
+
if (ch === '>') {
|
|
92
|
+
const last = top().children[top().children.length - 1];
|
|
93
|
+
if (last) stack.push(last);
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === '+') { i++; continue; }
|
|
98
|
+
if (ch === '<') {
|
|
99
|
+
i++;
|
|
100
|
+
if (stack.length > 1) stack.pop();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// element token
|
|
104
|
+
let tag = '';
|
|
105
|
+
while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) tag += str[i++];
|
|
106
|
+
let semanticId = '';
|
|
107
|
+
if (str[i] === '.') {
|
|
108
|
+
i++;
|
|
109
|
+
while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) semanticId += str[i++];
|
|
110
|
+
}
|
|
111
|
+
top().children.push({ tag: tag.toLowerCase() || 'div', semanticId, children: [] });
|
|
112
|
+
}
|
|
113
|
+
return root.children;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeId(id) {
|
|
117
|
+
if (id == null) return '';
|
|
118
|
+
return String(id).replace(/^\./, '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// WdlDom
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export class WdlDom {
|
|
126
|
+
/**
|
|
127
|
+
* @param {object} options
|
|
128
|
+
* @param {HTMLElement|string} options.container
|
|
129
|
+
* @param {object} options.component ComponentState instance (has .on, .layers, .attr, .getSnapshot)
|
|
130
|
+
* @param {(el: HTMLElement, path: string, value: any) => void} [options.onDataBind]
|
|
131
|
+
* @param {HTMLElement|Document} [options.styleTarget=document.head]
|
|
132
|
+
* @param {boolean} [options.debug=false]
|
|
133
|
+
*/
|
|
134
|
+
constructor(options = {}) {
|
|
135
|
+
const {
|
|
136
|
+
container,
|
|
137
|
+
component,
|
|
138
|
+
onDataBind = null,
|
|
139
|
+
styleTarget = typeof document !== 'undefined' ? document.head : null,
|
|
140
|
+
debug = false,
|
|
141
|
+
} = options;
|
|
142
|
+
|
|
143
|
+
if (!container) throw new Error('[wdl-dom] container is required');
|
|
144
|
+
if (!component) throw new Error('[wdl-dom] component (ComponentState) is required');
|
|
145
|
+
|
|
146
|
+
this.container =
|
|
147
|
+
typeof container === 'string'
|
|
148
|
+
? document.querySelector(container)
|
|
149
|
+
: container;
|
|
150
|
+
if (!this.container) throw new Error('[wdl-dom] container not found');
|
|
151
|
+
|
|
152
|
+
this.component = component;
|
|
153
|
+
this.onDataBind = onDataBind;
|
|
154
|
+
this.styleTarget = styleTarget;
|
|
155
|
+
this.debug = debug;
|
|
156
|
+
|
|
157
|
+
/** @type {Map<string, HTMLElement>} semanticId → element */
|
|
158
|
+
this.liveMap = new Map();
|
|
159
|
+
|
|
160
|
+
/** @type {Array<() => void>} */
|
|
161
|
+
this._unsubs = [];
|
|
162
|
+
|
|
163
|
+
/** style element for registry rules */
|
|
164
|
+
this._styleEl = null;
|
|
165
|
+
|
|
166
|
+
this._mount();
|
|
167
|
+
this._bindStateEvents();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Public API
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** Force full remount from current snapshot */
|
|
175
|
+
remount() {
|
|
176
|
+
this._mount();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Current live map (read-only view) */
|
|
180
|
+
getLiveMap() {
|
|
181
|
+
return new Map(this.liveMap);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Clean up listeners and DOM */
|
|
185
|
+
destroy() {
|
|
186
|
+
this._unsubs.forEach((u) => {
|
|
187
|
+
try { u(); } catch (_) {}
|
|
188
|
+
});
|
|
189
|
+
this._unsubs = [];
|
|
190
|
+
this.container.replaceChildren();
|
|
191
|
+
this.liveMap.clear();
|
|
192
|
+
if (this._styleEl && this._styleEl.parentNode) {
|
|
193
|
+
this._styleEl.parentNode.removeChild(this._styleEl);
|
|
194
|
+
}
|
|
195
|
+
this._styleEl = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// -----------------------------------------------------------------------
|
|
199
|
+
// Initial mount
|
|
200
|
+
// -----------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
_mount() {
|
|
203
|
+
this.container.replaceChildren();
|
|
204
|
+
this.liveMap.clear();
|
|
205
|
+
|
|
206
|
+
let tree;
|
|
207
|
+
try {
|
|
208
|
+
tree = typeof this.component.layers?.tree === 'function'
|
|
209
|
+
? this.component.layers.tree()
|
|
210
|
+
: parseLayersSimple(this.component.layers?.list?.() ?? this.component.getSnapshot?.()?.layers);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
this._log('warn', 'tree() failed, falling back to simple parse', e);
|
|
213
|
+
const layers = this.component.getSnapshot?.()?.layers ?? '';
|
|
214
|
+
tree = parseLayersSimple(layers);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!Array.isArray(tree)) tree = [];
|
|
218
|
+
|
|
219
|
+
for (const node of tree) {
|
|
220
|
+
const el = this._createElementFromNode(node);
|
|
221
|
+
this.container.appendChild(el);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Apply all current attributes
|
|
225
|
+
const attrMap =
|
|
226
|
+
this.component.attr?.list?.() ??
|
|
227
|
+
this.component.getSnapshot?.()?.attr ??
|
|
228
|
+
{};
|
|
229
|
+
for (const [sel, props] of Object.entries(attrMap)) {
|
|
230
|
+
this._applyAttrs(normalizeId(sel), props);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Apply variant if present on root
|
|
234
|
+
this._applyRootVariant();
|
|
235
|
+
|
|
236
|
+
// Registry styles
|
|
237
|
+
this._syncRegistryStyles();
|
|
238
|
+
|
|
239
|
+
this._log('mount', `mounted ${this.liveMap.size} nodes`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {{ tag: string, semanticId?: string, id?: string, children?: any[] }} node
|
|
244
|
+
*/
|
|
245
|
+
_createElementFromNode(node) {
|
|
246
|
+
const tag = (node.tag || 'div').toLowerCase();
|
|
247
|
+
const id = normalizeId(node.semanticId || node.id || '');
|
|
248
|
+
const el = document.createElement(tag);
|
|
249
|
+
|
|
250
|
+
if (id) {
|
|
251
|
+
el.classList.add(id);
|
|
252
|
+
el.setAttribute('wdl-comp', id);
|
|
253
|
+
this.liveMap.set(id, el);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (Array.isArray(node.children)) {
|
|
257
|
+
for (const child of node.children) {
|
|
258
|
+
el.appendChild(this._createElementFromNode(child));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return el;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// -----------------------------------------------------------------------
|
|
265
|
+
// State event binding
|
|
266
|
+
// -----------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
_bindStateEvents() {
|
|
269
|
+
const c = this.component;
|
|
270
|
+
if (typeof c.on !== 'function') {
|
|
271
|
+
this._log('warn', 'component has no .on() — events will not be applied');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const handler = (event) => this._onStateEvent(event);
|
|
276
|
+
|
|
277
|
+
// All events documented by @ruledwdl/state
|
|
278
|
+
const types = [
|
|
279
|
+
'layers:change',
|
|
280
|
+
'attr:change',
|
|
281
|
+
'data:change',
|
|
282
|
+
'variant:change',
|
|
283
|
+
'registry:change',
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
for (const type of types) {
|
|
287
|
+
const off = c.on(type, handler);
|
|
288
|
+
// state.on may return unsubscribe fn or nothing
|
|
289
|
+
if (typeof off === 'function') this._unsubs.push(off);
|
|
290
|
+
else this._unsubs.push(() => c.off?.(type, handler));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {{ type: string, action: string, targetId?: string, payload?: any, componentId?: string }} event
|
|
296
|
+
*/
|
|
297
|
+
_onStateEvent(event) {
|
|
298
|
+
if (!event || !event.type) return;
|
|
299
|
+
this._log('event', event.type, event.action, event.targetId, event.payload);
|
|
300
|
+
|
|
301
|
+
switch (event.type) {
|
|
302
|
+
case 'layers:change':
|
|
303
|
+
this._handleLayers(event);
|
|
304
|
+
break;
|
|
305
|
+
case 'attr:change':
|
|
306
|
+
this._handleAttr(event);
|
|
307
|
+
break;
|
|
308
|
+
case 'data:change':
|
|
309
|
+
this._handleData(event);
|
|
310
|
+
break;
|
|
311
|
+
case 'variant:change':
|
|
312
|
+
this._handleVariant(event);
|
|
313
|
+
break;
|
|
314
|
+
case 'registry:change':
|
|
315
|
+
this._handleRegistry(event);
|
|
316
|
+
break;
|
|
317
|
+
default:
|
|
318
|
+
this._log('warn', 'unknown event type', event.type);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// -----------------------------------------------------------------------
|
|
323
|
+
// layers:change
|
|
324
|
+
// actions: set | append | before | after | wrap | remove | update
|
|
325
|
+
// -----------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
_handleLayers(event) {
|
|
328
|
+
const { action, targetId, payload } = event;
|
|
329
|
+
const id = normalizeId(targetId);
|
|
330
|
+
|
|
331
|
+
switch (action) {
|
|
332
|
+
case 'set': {
|
|
333
|
+
// Full layers replacement → remount
|
|
334
|
+
this._mount();
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case 'append': {
|
|
338
|
+
// payload = layer expression (string or node-like)
|
|
339
|
+
const parentEl = this.liveMap.get(id);
|
|
340
|
+
if (!parentEl) {
|
|
341
|
+
this._log('warn', `append: parent "${id}" not in liveMap — remounting`);
|
|
342
|
+
this._mount();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const token = parseLayerToken(payload);
|
|
346
|
+
const el = this._createElementFromNode({
|
|
347
|
+
tag: token.tag,
|
|
348
|
+
semanticId: token.semanticId,
|
|
349
|
+
children: [],
|
|
350
|
+
});
|
|
351
|
+
parentEl.appendChild(el);
|
|
352
|
+
this._log('op', `append <${token.tag}.${token.semanticId}> → #${id}`);
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case 'prepend': {
|
|
356
|
+
const parentEl = this.liveMap.get(id);
|
|
357
|
+
if (!parentEl) {
|
|
358
|
+
this._log('warn', `prepend: parent "${id}" not in liveMap — remounting`);
|
|
359
|
+
this._mount();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const token = parseLayerToken(payload);
|
|
363
|
+
const el = this._createElementFromNode({
|
|
364
|
+
tag: token.tag,
|
|
365
|
+
semanticId: token.semanticId,
|
|
366
|
+
children: [],
|
|
367
|
+
});
|
|
368
|
+
parentEl.insertBefore(el, parentEl.firstChild);
|
|
369
|
+
this._log('op', `prepend <${token.tag}.${token.semanticId}> → #${id}`);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'before':
|
|
373
|
+
case 'after': {
|
|
374
|
+
const targetEl = this.liveMap.get(id);
|
|
375
|
+
if (!targetEl || !targetEl.parentNode) {
|
|
376
|
+
this._log('warn', `${action}: target "${id}" missing — remounting`);
|
|
377
|
+
this._mount();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const token = parseLayerToken(payload);
|
|
381
|
+
const el = this._createElementFromNode({
|
|
382
|
+
tag: token.tag,
|
|
383
|
+
semanticId: token.semanticId,
|
|
384
|
+
children: [],
|
|
385
|
+
});
|
|
386
|
+
if (action === 'before') {
|
|
387
|
+
targetEl.parentNode.insertBefore(el, targetEl);
|
|
388
|
+
} else {
|
|
389
|
+
targetEl.parentNode.insertBefore(el, targetEl.nextSibling);
|
|
390
|
+
}
|
|
391
|
+
this._log('op', `${action} <${token.tag}.${token.semanticId}> relative to #${id}`);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case 'wrap': {
|
|
395
|
+
// payload = wrapper layer expression
|
|
396
|
+
const targetEl = this.liveMap.get(id);
|
|
397
|
+
if (!targetEl || !targetEl.parentNode) {
|
|
398
|
+
this._log('warn', `wrap: target "${id}" missing — remounting`);
|
|
399
|
+
this._mount();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const token = parseLayerToken(payload);
|
|
403
|
+
const wrapper = this._createElementFromNode({
|
|
404
|
+
tag: token.tag,
|
|
405
|
+
semanticId: token.semanticId,
|
|
406
|
+
children: [],
|
|
407
|
+
});
|
|
408
|
+
targetEl.parentNode.insertBefore(wrapper, targetEl);
|
|
409
|
+
wrapper.appendChild(targetEl);
|
|
410
|
+
this._log('op', `wrap #${id} with <${token.tag}.${token.semanticId}>`);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case 'unwrap': {
|
|
414
|
+
const wrapperEl = this.liveMap.get(id);
|
|
415
|
+
if (!wrapperEl || !wrapperEl.parentNode) {
|
|
416
|
+
this._log('warn', `unwrap: target "${id}" missing — remounting`);
|
|
417
|
+
this._mount();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const parent = wrapperEl.parentNode;
|
|
421
|
+
while (wrapperEl.firstChild) {
|
|
422
|
+
parent.insertBefore(wrapperEl.firstChild, wrapperEl);
|
|
423
|
+
}
|
|
424
|
+
wrapperEl.remove();
|
|
425
|
+
this.liveMap.delete(id);
|
|
426
|
+
this._log('op', `unwrap #${id}`);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'move': {
|
|
430
|
+
const sourceEl = this.liveMap.get(id);
|
|
431
|
+
const { targetSemanticId, position } = payload || {};
|
|
432
|
+
const targetId = normalizeId(targetSemanticId);
|
|
433
|
+
const targetEl = this.liveMap.get(targetId);
|
|
434
|
+
|
|
435
|
+
if (!sourceEl || !targetEl) {
|
|
436
|
+
this._log('warn', `move: source "${id}" or target "${targetId}" missing — remounting`);
|
|
437
|
+
this._mount();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (position === 'before') {
|
|
442
|
+
targetEl.parentNode?.insertBefore(sourceEl, targetEl);
|
|
443
|
+
} else if (position === 'after') {
|
|
444
|
+
targetEl.parentNode?.insertBefore(sourceEl, targetEl.nextSibling);
|
|
445
|
+
} else {
|
|
446
|
+
targetEl.appendChild(sourceEl);
|
|
447
|
+
}
|
|
448
|
+
this._log('op', `move #${id} -> ${position} #${targetId}`);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case 'remove': {
|
|
452
|
+
const el = this.liveMap.get(id);
|
|
453
|
+
if (!el) {
|
|
454
|
+
this._log('warn', `remove: #${id} not in liveMap`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Remove descendants from liveMap first
|
|
458
|
+
for (const [sid, node] of [...this.liveMap]) {
|
|
459
|
+
if (sid !== id && el.contains(node)) this.liveMap.delete(sid);
|
|
460
|
+
}
|
|
461
|
+
el.remove();
|
|
462
|
+
this.liveMap.delete(id);
|
|
463
|
+
this._log('op', `remove #${id}`);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case 'update': {
|
|
467
|
+
// payload = { tag?, semanticId? }
|
|
468
|
+
const el = this.liveMap.get(id);
|
|
469
|
+
if (!el) {
|
|
470
|
+
this._log('warn', `update: #${id} missing — remounting`);
|
|
471
|
+
this._mount();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const patch = payload || {};
|
|
475
|
+
if (patch.tag && patch.tag.toLowerCase() !== el.tagName.toLowerCase()) {
|
|
476
|
+
// Tag change requires recreate
|
|
477
|
+
const next = document.createElement(String(patch.tag).toLowerCase());
|
|
478
|
+
// copy attributes & children
|
|
479
|
+
for (const attr of el.attributes) next.setAttribute(attr.name, attr.value);
|
|
480
|
+
while (el.firstChild) next.appendChild(el.firstChild);
|
|
481
|
+
el.parentNode?.replaceChild(next, el);
|
|
482
|
+
this.liveMap.set(id, next);
|
|
483
|
+
if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
|
|
484
|
+
const newId = normalizeId(patch.semanticId);
|
|
485
|
+
next.classList.remove(id);
|
|
486
|
+
next.classList.add(newId);
|
|
487
|
+
next.setAttribute('wdl-comp', newId);
|
|
488
|
+
this.liveMap.delete(id);
|
|
489
|
+
this.liveMap.set(newId, next);
|
|
490
|
+
}
|
|
491
|
+
} else if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
|
|
492
|
+
const newId = normalizeId(patch.semanticId);
|
|
493
|
+
el.classList.remove(id);
|
|
494
|
+
el.classList.add(newId);
|
|
495
|
+
el.setAttribute('wdl-comp', newId);
|
|
496
|
+
this.liveMap.delete(id);
|
|
497
|
+
this.liveMap.set(newId, el);
|
|
498
|
+
}
|
|
499
|
+
this._log('op', `update #${id}`, patch);
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
default:
|
|
503
|
+
this._log('warn', `layers: unknown action "${action}" — remounting`);
|
|
504
|
+
this._mount();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// -----------------------------------------------------------------------
|
|
509
|
+
// attr:change
|
|
510
|
+
// actions: set | update | remove
|
|
511
|
+
// -----------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
_handleAttr(event) {
|
|
514
|
+
const { action, targetId, payload } = event;
|
|
515
|
+
const id = normalizeId(targetId);
|
|
516
|
+
|
|
517
|
+
if (action === 'set' || action === 'update') {
|
|
518
|
+
// payload = full attrs object (set) or patch (update)
|
|
519
|
+
this._applyAttrs(id, payload || {});
|
|
520
|
+
this._log('op', `attr.${action} #${id}`, payload);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (action === 'remove') {
|
|
525
|
+
const el = this.liveMap.get(id);
|
|
526
|
+
if (!el) return;
|
|
527
|
+
const attrKey = payload; // may be undefined → remove whole entry
|
|
528
|
+
if (attrKey == null || attrKey === '') {
|
|
529
|
+
el.textContent = '';
|
|
530
|
+
// keep semantic class
|
|
531
|
+
el.className = id;
|
|
532
|
+
this._log('op', `attr.remove entire #${id}`);
|
|
533
|
+
} else if (attrKey === 'text') {
|
|
534
|
+
el.textContent = '';
|
|
535
|
+
this._log('op', `attr.remove text from #${id}`);
|
|
536
|
+
} else if (attrKey === 'class') {
|
|
537
|
+
el.className = id;
|
|
538
|
+
this._log('op', `attr.remove class from #${id}`);
|
|
539
|
+
} else if (attrKey === 'html') {
|
|
540
|
+
el.innerHTML = '';
|
|
541
|
+
this._log('op', `attr.remove html from #${id}`);
|
|
542
|
+
} else {
|
|
543
|
+
el.removeAttribute(attrKey);
|
|
544
|
+
this._log('op', `attr.remove ${attrKey} from #${id}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Apply attribute object onto live element.
|
|
551
|
+
* Supports: text, class, html, data-*, style object, arbitrary attrs.
|
|
552
|
+
*/
|
|
553
|
+
_applyAttrs(id, props) {
|
|
554
|
+
if (!props || typeof props !== 'object') return;
|
|
555
|
+
const el = this.liveMap.get(id);
|
|
556
|
+
if (!el) {
|
|
557
|
+
this._log('warn', `attr: #${id} not in liveMap`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (props.text !== undefined) {
|
|
562
|
+
el.textContent = String(props.text);
|
|
563
|
+
}
|
|
564
|
+
if (props.html !== undefined) {
|
|
565
|
+
el.innerHTML = String(props.html);
|
|
566
|
+
}
|
|
567
|
+
if (props.class !== undefined) {
|
|
568
|
+
const extra = String(props.class).trim();
|
|
569
|
+
el.className = extra ? `${id} ${extra}` : id;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (const [key, value] of Object.entries(props)) {
|
|
573
|
+
if (ATTR_SKIP.has(key)) continue;
|
|
574
|
+
if (key === 'style' && value && typeof value === 'object') {
|
|
575
|
+
Object.assign(el.style, value);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (value == null) {
|
|
579
|
+
el.removeAttribute(key);
|
|
580
|
+
} else {
|
|
581
|
+
el.setAttribute(key, String(value));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// -----------------------------------------------------------------------
|
|
587
|
+
// data:change
|
|
588
|
+
// actions: set | remove
|
|
589
|
+
// Optional: onDataBind callback for custom binding
|
|
590
|
+
// -----------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
_handleData(event) {
|
|
593
|
+
const { action, targetId, payload } = event;
|
|
594
|
+
// targetId is the path (e.g. "user.name"), payload is the value
|
|
595
|
+
const path = targetId || '';
|
|
596
|
+
if (typeof this.onDataBind === 'function') {
|
|
597
|
+
// Let consumer decide how data maps to DOM
|
|
598
|
+
// We pass the root container + path + value
|
|
599
|
+
try {
|
|
600
|
+
this.onDataBind(this.container, path, action === 'remove' ? undefined : payload);
|
|
601
|
+
} catch (e) {
|
|
602
|
+
this._log('warn', 'onDataBind error', e);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
this._log('op', `data.${action} ${path}`, action === 'remove' ? undefined : payload);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// -----------------------------------------------------------------------
|
|
609
|
+
// variant:change
|
|
610
|
+
// action: set targetId = semanticId (or component id) payload = variantName
|
|
611
|
+
// -----------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
_handleVariant(event) {
|
|
614
|
+
const { targetId, payload } = event;
|
|
615
|
+
const id = normalizeId(targetId) || this._rootSemanticId();
|
|
616
|
+
const el = this.liveMap.get(id);
|
|
617
|
+
if (!el) {
|
|
618
|
+
// try root
|
|
619
|
+
const rootId = this._rootSemanticId();
|
|
620
|
+
const rootEl = rootId ? this.liveMap.get(rootId) : null;
|
|
621
|
+
if (rootEl) {
|
|
622
|
+
if (payload) rootEl.dataset.variant = String(payload);
|
|
623
|
+
else delete rootEl.dataset.variant;
|
|
624
|
+
this._log('op', `variant → ${payload || '(none)'} on #${rootId}`);
|
|
625
|
+
}
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (payload) el.dataset.variant = String(payload);
|
|
629
|
+
else delete el.dataset.variant;
|
|
630
|
+
this._log('op', `variant → ${payload || '(none)'} on #${id}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_applyRootVariant() {
|
|
634
|
+
// Read current variant from attr if present
|
|
635
|
+
const attr =
|
|
636
|
+
this.component.attr?.list?.() ??
|
|
637
|
+
this.component.getSnapshot?.()?.attr ??
|
|
638
|
+
{};
|
|
639
|
+
const rootId = this._rootSemanticId();
|
|
640
|
+
if (!rootId) return;
|
|
641
|
+
const rootKey = '.' + rootId;
|
|
642
|
+
const variant =
|
|
643
|
+
attr[rootKey]?.['data-variant'] ??
|
|
644
|
+
attr['.']?.['data-variant'] ??
|
|
645
|
+
null;
|
|
646
|
+
if (variant && this.liveMap.has(rootId)) {
|
|
647
|
+
this.liveMap.get(rootId).dataset.variant = String(variant);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
_rootSemanticId() {
|
|
652
|
+
// First entry in liveMap that is a direct child of container, or first key
|
|
653
|
+
for (const [id, el] of this.liveMap) {
|
|
654
|
+
if (el.parentNode === this.container) return id;
|
|
655
|
+
}
|
|
656
|
+
const first = this.liveMap.keys().next();
|
|
657
|
+
return first.done ? '' : first.value;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// -----------------------------------------------------------------------
|
|
661
|
+
// registry:change
|
|
662
|
+
// actions: set | update | addRule | removeRule
|
|
663
|
+
// Injects / updates a <style data-wdl-dom="componentId"> in styleTarget
|
|
664
|
+
// -----------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
_handleRegistry(event) {
|
|
667
|
+
this._syncRegistryStyles();
|
|
668
|
+
this._log('op', `registry.${event.action}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
_syncRegistryStyles() {
|
|
672
|
+
if (!this.styleTarget) return;
|
|
673
|
+
|
|
674
|
+
const registry =
|
|
675
|
+
this.component.registry?.get?.() ??
|
|
676
|
+
this.component.getSnapshot?.()?.registry ??
|
|
677
|
+
null;
|
|
678
|
+
|
|
679
|
+
const componentId = this.component.id || this.component.getSnapshot?.()?.id || 'comp';
|
|
680
|
+
|
|
681
|
+
if (!this._styleEl) {
|
|
682
|
+
this._styleEl = document.createElement('style');
|
|
683
|
+
this._styleEl.setAttribute('data-wdl-dom', componentId);
|
|
684
|
+
this.styleTarget.appendChild(this._styleEl);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!registry) {
|
|
688
|
+
this._styleEl.textContent = '';
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const rules = Array.isArray(registry.rules) ? registry.rules : [];
|
|
693
|
+
const parts = [];
|
|
694
|
+
|
|
695
|
+
// CSS variables
|
|
696
|
+
if (registry.vars && typeof registry.vars === 'object') {
|
|
697
|
+
const decls = Object.entries(registry.vars)
|
|
698
|
+
.map(([k, v]) => `--${k}: ${v};`)
|
|
699
|
+
.join(' ');
|
|
700
|
+
if (decls) parts.push(`:root, [wdl-comp] { ${decls} }`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
for (const rule of rules) {
|
|
704
|
+
if (!rule || !rule.selector) continue;
|
|
705
|
+
const css =
|
|
706
|
+
typeof rule.css === 'string'
|
|
707
|
+
? rule.css
|
|
708
|
+
: rule.css && typeof rule.css === 'object'
|
|
709
|
+
? Object.entries(rule.css)
|
|
710
|
+
.map(([k, v]) => `${camelToKebab(k)}: ${v};`)
|
|
711
|
+
.join(' ')
|
|
712
|
+
: '';
|
|
713
|
+
if (!css) continue;
|
|
714
|
+
const selector = rule.selector.startsWith('&')
|
|
715
|
+
? rule.selector.replace('&', `[wdl-comp="${componentId}"]`)
|
|
716
|
+
: rule.selector;
|
|
717
|
+
const media = rule.media ? `@media ${rule.media} { ${selector} { ${css} } }` : `${selector} { ${css} }`;
|
|
718
|
+
parts.push(media);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
this._styleEl.textContent = parts.join('\n');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// -----------------------------------------------------------------------
|
|
725
|
+
// Utils
|
|
726
|
+
// -----------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
_log(kind, ...args) {
|
|
729
|
+
if (!this.debug && kind !== 'warn') return;
|
|
730
|
+
const prefix = `[wdl-dom:${kind}]`;
|
|
731
|
+
if (kind === 'warn') console.warn(prefix, ...args);
|
|
732
|
+
else console.log(prefix, ...args);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function camelToKebab(str) {
|
|
737
|
+
return String(str).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// Factory
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Create and mount a WdlDom runtime.
|
|
746
|
+
* @param {ConstructorParameters<typeof WdlDom>[0]} options
|
|
747
|
+
* @returns {WdlDom}
|
|
748
|
+
*/
|
|
749
|
+
export function createWdlDom(options) {
|
|
750
|
+
return new WdlDom(options);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export default createWdlDom;
|