@ruledwdl/dom 0.1.0 → 0.1.2

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/src/index.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * No innerHTML on updates.
7
7
  *
8
8
  * State events handled (from @ruledwdl/state):
9
- * layers:change actions: set | append | before | after | wrap | remove | update
9
+ * layers:change actions: set | append | prepend | before | after | wrap | unwrap | move | remove | update
10
10
  * attr:change actions: set | update | remove
11
- * data:change actions: set | remove (optional data-* / text binding)
11
+ * data:change actions: set | update | remove (surgical loop child reconciliation & item-scoped binding)
12
12
  * variant:change actions: set
13
13
  * registry:change actions: set | update | addRule | removeRule
14
14
  *
@@ -23,59 +23,109 @@
23
23
  * }
24
24
  *
25
25
  * Usage:
26
- * import { createWdlDom } from './wdl-dom.js';
26
+ * import { createWdlDom } from '@ruledwdl/dom';
27
27
  * // or: import { ComponentManager } from '@ruledwdl/state';
28
28
  *
29
- * const hero = manager.create('hero', { layers: '...', attr: {...} });
29
+ * const comp = manager.create('pricing-card', {
30
+ * layers: 'ul.features > li.feature*features',
31
+ * attr: { '.feature': { text: '${text}' } },
32
+ * data: { features: [{ text: 'First' }, { text: 'Second' }] }
33
+ * });
30
34
  * const dom = createWdlDom({
31
35
  * container: document.getElementById('app'),
32
- * component: hero, // ComponentState instance
33
- * // optional:
34
- * // onDataBind: (el, path, value) => { ... },
35
- * // styleTarget: document.head,
36
+ * component: comp
36
37
  * });
37
38
  *
38
- * // later: hero.attr.set('title', { text: 'Hi' }); // → surgical update
39
+ * // later: comp.data.set('features', [{ text: 'Updated' }]); // → surgical row reconciliation
39
40
  * // destroy: dom.destroy();
40
41
  */
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
- // Helpers
44
+ // Helpers & Data Resolvers
44
45
  // ---------------------------------------------------------------------------
45
46
 
46
- const ATTR_SKIP = new Set(['text', 'class', 'html']);
47
+ const ATTR_SKIP = new Set(['text', 'class', 'html', 'alpine', 'htmx', 'attr-ref']);
48
+
49
+ /**
50
+ * Resolve dot-notated path on an object (e.g. "user.name" or "text")
51
+ */
52
+ export function resolvePath(obj, path) {
53
+ if (!path || obj == null) return obj ?? '';
54
+ return String(path).split('.').reduce((a, k) => (a != null ? a[k] : ''), obj) ?? '';
55
+ }
56
+
57
+ /**
58
+ * Resolve template strings with ${path} or {{path}} expressions
59
+ */
60
+ export function resolveStr(str, data) {
61
+ if (typeof str !== 'string') return str;
62
+ return str
63
+ .replace(/\$\{([\w.]+)\}/g, (_, p) => {
64
+ const val = resolvePath(data, p);
65
+ return val != null ? String(val) : '';
66
+ })
67
+ .replace(/\{\{([\w.]+)\}\}/g, (_, p) => {
68
+ const val = resolvePath(data, p);
69
+ return val != null ? String(val) : '';
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Deeply resolve string templates within objects/arrays
75
+ */
76
+ export function resolveAll(obj, data) {
77
+ if (obj == null) return obj;
78
+ if (typeof obj === 'function') return '';
79
+ if (typeof obj === 'string') return resolveStr(obj, data);
80
+ if (Array.isArray(obj)) return obj.map((v) => resolveAll(v, data));
81
+ if (typeof obj === 'object') {
82
+ return Object.fromEntries(
83
+ Object.entries(obj).map(([k, v]) => [k, resolveAll(v, data)])
84
+ );
85
+ }
86
+ return obj;
87
+ }
47
88
 
48
89
  /**
49
- * Parse a single layer token: "button.cta" | "div" | "h1.title"
50
- * @returns {{ tag: string, semanticId: string }}
90
+ * Parse a single layer token: "button.cta" | "li.feature*features" | "div.card*3"
91
+ * @returns {{ tag: string, semanticId: string, repeator: string | null }}
51
92
  */
52
- function parseLayerToken(expr) {
93
+ export function parseLayerToken(expr) {
53
94
  if (typeof expr !== 'string') {
54
95
  if (expr && typeof expr === 'object') {
55
96
  return {
56
97
  tag: String(expr.tag || 'div').toLowerCase(),
57
98
  semanticId: String(expr.semanticId || expr.id || '').replace(/^\./, ''),
99
+ repeator: expr.repeator || expr.loopKey || null,
58
100
  };
59
101
  }
60
102
  throw new Error('[wdl-dom] invalid layer expression');
61
103
  }
62
104
  const trimmed = expr.trim();
63
- const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);
105
+ let str = trimmed;
106
+ let repeator = null;
107
+ const multIdx = str.indexOf('*');
108
+ if (multIdx !== -1) {
109
+ repeator = str.slice(multIdx + 1);
110
+ str = str.slice(0, multIdx);
111
+ }
112
+ const m = str.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);
64
113
  if (!m) {
65
114
  // fallback: treat whole string as tag
66
- return { tag: trimmed.toLowerCase() || 'div', semanticId: '' };
115
+ return { tag: str.toLowerCase() || 'div', semanticId: '', repeator };
67
116
  }
68
117
  return {
69
118
  tag: m[1].toLowerCase(),
70
119
  semanticId: (m[2] || '').replace(/^\./, ''),
120
+ repeator,
71
121
  };
72
122
  }
73
123
 
74
124
  /**
75
- * Very small layers string → tree (supports > and + only).
125
+ * Simple layers string → tree parser (supports >, +, <, <*N, <@N, and *repeator).
76
126
  * For full WDL grammar prefer tree from component.layers.tree().
77
127
  */
78
- function parseLayersSimple(str) {
128
+ export function parseLayersSimple(str) {
79
129
  if (Array.isArray(str)) return structuredClone(str);
80
130
  if (typeof str !== 'string' || !str.trim()) return [];
81
131
 
@@ -87,20 +137,57 @@ function parseLayersSimple(str) {
87
137
 
88
138
  while (i < str.length) {
89
139
  const ch = str[i];
90
- if (/\s/.test(ch)) { i++; continue; }
140
+ if (/\s/.test(ch)) {
141
+ i++;
142
+ continue;
143
+ }
91
144
  if (ch === '>') {
92
145
  const last = top().children[top().children.length - 1];
93
146
  if (last) stack.push(last);
94
147
  i++;
95
148
  continue;
96
149
  }
97
- if (ch === '+') { i++; continue; }
150
+ if (ch === '+') {
151
+ i++;
152
+ continue;
153
+ }
98
154
  if (ch === '<') {
99
155
  i++;
100
- if (stack.length > 1) stack.pop();
156
+ if (i < str.length && str[i] === '*') {
157
+ i++;
158
+ let numStr = '';
159
+ while (i < str.length && /\d/.test(str[i])) {
160
+ numStr += str[i++];
161
+ }
162
+ const count = numStr ? parseInt(numStr, 10) : 1;
163
+ for (let k = 0; k < count; k++) {
164
+ if (stack.length > 1) stack.pop();
165
+ }
166
+ } else if (i < str.length && str[i] === '@') {
167
+ i++;
168
+ let numStr = '';
169
+ while (i < str.length && /\d/.test(str[i])) {
170
+ numStr += str[i++];
171
+ }
172
+ const targetDepth = numStr ? parseInt(numStr, 10) : 0;
173
+ const targetStackLen = targetDepth + 1;
174
+ while (stack.length > targetStackLen && stack.length > 1) {
175
+ stack.pop();
176
+ }
177
+ } else {
178
+ let count = 1;
179
+ while (i < str.length && str[i] === '<') {
180
+ count++;
181
+ i++;
182
+ }
183
+ for (let k = 0; k < count; k++) {
184
+ if (stack.length > 1) stack.pop();
185
+ }
186
+ }
101
187
  continue;
102
188
  }
103
- // element token
189
+
190
+ // Element token
104
191
  let tag = '';
105
192
  while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) tag += str[i++];
106
193
  let semanticId = '';
@@ -108,12 +195,24 @@ function parseLayersSimple(str) {
108
195
  i++;
109
196
  while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) semanticId += str[i++];
110
197
  }
111
- top().children.push({ tag: tag.toLowerCase() || 'div', semanticId, children: [] });
198
+ let repeator = null;
199
+ if (str[i] === '*') {
200
+ i++;
201
+ let rep = '';
202
+ while (i < str.length && /[a-zA-Z0-9_.]/.test(str[i])) rep += str[i++];
203
+ if (rep) repeator = rep;
204
+ }
205
+ top().children.push({
206
+ tag: tag.toLowerCase() || 'div',
207
+ semanticId,
208
+ repeator,
209
+ children: [],
210
+ });
112
211
  }
113
212
  return root.children;
114
213
  }
115
214
 
116
- function normalizeId(id) {
215
+ export function normalizeId(id) {
117
216
  if (id == null) return '';
118
217
  return String(id).replace(/^\./, '');
119
218
  }
@@ -126,7 +225,7 @@ export class WdlDom {
126
225
  /**
127
226
  * @param {object} options
128
227
  * @param {HTMLElement|string} options.container
129
- * @param {object} options.component ComponentState instance (has .on, .layers, .attr, .getSnapshot)
228
+ * @param {object} options.component ComponentState instance (has .on, .layers, .attr, .data, .getSnapshot)
130
229
  * @param {(el: HTMLElement, path: string, value: any) => void} [options.onDataBind]
131
230
  * @param {HTMLElement|Document} [options.styleTarget=document.head]
132
231
  * @param {boolean} [options.debug=false]
@@ -154,9 +253,18 @@ export class WdlDom {
154
253
  this.styleTarget = styleTarget;
155
254
  this.debug = debug;
156
255
 
157
- /** @type {Map<string, HTMLElement>} semanticId → element */
256
+ /** @type {Map<string, HTMLElement>} semanticId → first live element (backward compatible) */
158
257
  this.liveMap = new Map();
159
258
 
259
+ /** @type {Map<string, HTMLElement[]>} semanticId → all live elements */
260
+ this.liveNodes = new Map();
261
+
262
+ /**
263
+ * Active loop bindings for surgical reconciliation
264
+ * @type {Array<{ parentEl: HTMLElement, node: any, repeator: string, elements: HTMLElement[], anchor: any }>}
265
+ */
266
+ this._loopBindings = [];
267
+
160
268
  /** @type {Array<() => void>} */
161
269
  this._unsubs = [];
162
270
 
@@ -176,25 +284,212 @@ export class WdlDom {
176
284
  this._mount();
177
285
  }
178
286
 
179
- /** Current live map (read-only view) */
287
+ /** Current live map of first elements (read-only view) */
180
288
  getLiveMap() {
181
289
  return new Map(this.liveMap);
182
290
  }
183
291
 
292
+ /** Get all live elements matching a semantic ID */
293
+ getLiveNodes(semanticId) {
294
+ const id = normalizeId(semanticId);
295
+ return [...(this.liveNodes.get(id) || [])];
296
+ }
297
+
298
+ /** Get single element by semantic ID and optional index */
299
+ getNode(semanticId, index = 0) {
300
+ const list = this.getLiveNodes(semanticId);
301
+ return list[index] || null;
302
+ }
303
+
184
304
  /** Clean up listeners and DOM */
185
305
  destroy() {
186
306
  this._unsubs.forEach((u) => {
187
- try { u(); } catch (_) {}
307
+ try {
308
+ u();
309
+ } catch (_) {}
188
310
  });
189
311
  this._unsubs = [];
190
312
  this.container.replaceChildren();
191
313
  this.liveMap.clear();
314
+ this.liveNodes.clear();
315
+ this._loopBindings = [];
192
316
  if (this._styleEl && this._styleEl.parentNode) {
193
317
  this._styleEl.parentNode.removeChild(this._styleEl);
194
318
  }
195
319
  this._styleEl = null;
196
320
  }
197
321
 
322
+ // -----------------------------------------------------------------------
323
+ // Node Registry Management
324
+ // -----------------------------------------------------------------------
325
+
326
+ _registerNode(id, el) {
327
+ if (!id) return;
328
+ const norm = normalizeId(id);
329
+ if (!this.liveNodes.has(norm)) {
330
+ this.liveNodes.set(norm, []);
331
+ }
332
+ const list = this.liveNodes.get(norm);
333
+ if (!list.includes(el)) {
334
+ list.push(el);
335
+ }
336
+ this.liveMap.set(norm, list[0]);
337
+ }
338
+
339
+ _unregisterNode(id, el) {
340
+ if (!id) return;
341
+ const norm = normalizeId(id);
342
+ if (this.liveNodes.has(norm)) {
343
+ const list = this.liveNodes.get(norm);
344
+ const idx = list.indexOf(el);
345
+ if (idx !== -1) list.splice(idx, 1);
346
+ if (list.length === 0) {
347
+ this.liveNodes.delete(norm);
348
+ this.liveMap.delete(norm);
349
+ } else {
350
+ this.liveMap.set(norm, list[0]);
351
+ }
352
+ } else {
353
+ this.liveMap.delete(norm);
354
+ }
355
+ }
356
+
357
+ _unregisterElementHierarchy(el) {
358
+ if (!el) return;
359
+ const compId = el.getAttribute?.('wdl-comp');
360
+ if (compId) {
361
+ this._unregisterNode(compId, el);
362
+ }
363
+ if (Array.isArray(el.children)) {
364
+ for (const child of el.children) {
365
+ this._unregisterElementHierarchy(child);
366
+ }
367
+ }
368
+ }
369
+
370
+ // -----------------------------------------------------------------------
371
+ // Data and Scope Helpers
372
+ // -----------------------------------------------------------------------
373
+
374
+ _getComponentData() {
375
+ return (
376
+ this.component.data?.get?.() ??
377
+ this.component.getSnapshot?.()?.data ??
378
+ {}
379
+ );
380
+ }
381
+
382
+ _getComponentAttrMap() {
383
+ return (
384
+ this.component.attr?.list?.() ??
385
+ this.component.getSnapshot?.()?.attr ??
386
+ {}
387
+ );
388
+ }
389
+
390
+ _getComponentRegistry() {
391
+ return (
392
+ this.component.registry?.get?.() ??
393
+ this.component.getSnapshot?.()?.registry ??
394
+ {}
395
+ );
396
+ }
397
+
398
+ _getRegistryClasses(id, tag, dataScope, el) {
399
+ const registry = this._getComponentRegistry();
400
+ if (!registry || typeof registry !== 'object') return '';
401
+
402
+ const entry =
403
+ (id ? registry[id] || registry['.' + id] : null) ||
404
+ (tag ? registry[tag] : null);
405
+
406
+ if (!entry) return '';
407
+
408
+ if (typeof entry === 'string') {
409
+ return resolveStr(entry, dataScope);
410
+ }
411
+
412
+ if (typeof entry !== 'object' || entry === null) return '';
413
+
414
+ const classes = [];
415
+
416
+ // Base utility class string
417
+ if (typeof entry.base === 'string' && entry.base) {
418
+ classes.push(resolveStr(entry.base, dataScope));
419
+ } else if (typeof entry.class === 'string' && entry.class) {
420
+ classes.push(resolveStr(entry.class, dataScope));
421
+ }
422
+
423
+ // Active Variant classes
424
+ const activeVariant =
425
+ el?.dataset?.variant ||
426
+ el?.getAttribute?.('data-variant') ||
427
+ this.component.variant?.get?.(id) ||
428
+ entry.defaultVariant ||
429
+ null;
430
+
431
+ if (activeVariant && entry.variants && typeof entry.variants === 'object') {
432
+ const vVal = entry.variants[activeVariant];
433
+ if (typeof vVal === 'string' && vVal) {
434
+ classes.push(resolveStr(vVal, dataScope));
435
+ }
436
+ }
437
+
438
+ // States (e.g. hover, focus)
439
+ if (entry.states && typeof entry.states === 'object') {
440
+ for (const [state, cls] of Object.entries(entry.states)) {
441
+ if (typeof cls === 'string' && cls) {
442
+ const resolved = resolveStr(cls, dataScope);
443
+ const formatted = resolved
444
+ .split(/\s+/)
445
+ .filter(Boolean)
446
+ .map((c) => (c.includes(':') ? c : `${state}:${c}`))
447
+ .join(' ');
448
+ classes.push(formatted);
449
+ }
450
+ }
451
+ }
452
+
453
+ // Breakpoints (e.g. md, lg)
454
+ if (entry.breakpoints && typeof entry.breakpoints === 'object') {
455
+ for (const [bp, cls] of Object.entries(entry.breakpoints)) {
456
+ if (typeof cls === 'string' && cls) {
457
+ const resolved = resolveStr(cls, dataScope);
458
+ const formatted = resolved
459
+ .split(/\s+/)
460
+ .filter(Boolean)
461
+ .map((c) => (c.includes(':') ? c : `${bp}:${c}`))
462
+ .join(' ');
463
+ classes.push(formatted);
464
+ }
465
+ }
466
+ }
467
+
468
+ return classes.filter(Boolean).join(' ');
469
+ }
470
+
471
+ _resolveLoopItems(repeator, dataScope) {
472
+ if (!repeator) return [];
473
+ if (/^\d+$/.test(String(repeator))) {
474
+ const count = parseInt(repeator, 10);
475
+ return Array.from({ length: Math.max(0, count) }, (_, idx) => ({ _index: idx }));
476
+ }
477
+ const resolved = resolvePath(dataScope, repeator);
478
+ if (Array.isArray(resolved)) return resolved;
479
+ if (typeof resolved === 'number') {
480
+ return Array.from({ length: Math.max(0, resolved) }, (_, idx) => ({ _index: idx }));
481
+ }
482
+ return [];
483
+ }
484
+
485
+ _createItemScope(item, index, baseData) {
486
+ const data = baseData || this._getComponentData();
487
+ if (typeof item === 'object' && item !== null) {
488
+ return { ...data, ...item, item, _index: index };
489
+ }
490
+ return { ...data, value: item, item, _index: index };
491
+ }
492
+
198
493
  // -----------------------------------------------------------------------
199
494
  // Initial mount
200
495
  // -----------------------------------------------------------------------
@@ -202,12 +497,18 @@ export class WdlDom {
202
497
  _mount() {
203
498
  this.container.replaceChildren();
204
499
  this.liveMap.clear();
500
+ this.liveNodes.clear();
501
+ this._loopBindings = [];
205
502
 
206
503
  let tree;
207
504
  try {
208
- tree = typeof this.component.layers?.tree === 'function'
209
- ? this.component.layers.tree()
210
- : parseLayersSimple(this.component.layers?.list?.() ?? this.component.getSnapshot?.()?.layers);
505
+ tree =
506
+ typeof this.component.layers?.tree === 'function'
507
+ ? this.component.layers.tree()
508
+ : parseLayersSimple(
509
+ this.component.layers?.list?.() ??
510
+ this.component.getSnapshot?.()?.layers
511
+ );
211
512
  } catch (e) {
212
513
  this._log('warn', 'tree() failed, falling back to simple parse', e);
213
514
  const layers = this.component.getSnapshot?.()?.layers ?? '';
@@ -216,18 +517,10 @@ export class WdlDom {
216
517
 
217
518
  if (!Array.isArray(tree)) tree = [];
218
519
 
219
- for (const node of tree) {
220
- const el = this._createElementFromNode(node);
221
- this.container.appendChild(el);
222
- }
520
+ const compData = this._getComponentData();
223
521
 
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);
522
+ for (const node of tree) {
523
+ this._mountLayerNode(this.container, node, compData);
231
524
  }
232
525
 
233
526
  // Apply variant if present on root
@@ -236,13 +529,50 @@ export class WdlDom {
236
529
  // Registry styles
237
530
  this._syncRegistryStyles();
238
531
 
239
- this._log('mount', `mounted ${this.liveMap.size} nodes`);
532
+ this._log('mount', `mounted ${this.liveMap.size} semantic keys, ${this._loopBindings.length} loops`);
533
+ }
534
+
535
+ /**
536
+ * Mount a layer node into a parent container/element.
537
+ * Handles loop expansion vs single element mounting.
538
+ */
539
+ _mountLayerNode(parentEl, node, dataScope) {
540
+ if (node.repeator) {
541
+ const items = this._resolveLoopItems(node.repeator, dataScope);
542
+ const elements = [];
543
+
544
+ for (let i = 0; i < items.length; i++) {
545
+ const itemScope = this._createItemScope(items[i], i, dataScope);
546
+ const el = this._createSingleElement(node, itemScope, i);
547
+ parentEl.appendChild(el);
548
+ elements.push(el);
549
+ }
550
+
551
+ // Create comment anchor to keep track of loop boundary
552
+ let anchor = null;
553
+ if (typeof document.createComment === 'function') {
554
+ anchor = document.createComment(`wdl-loop:${node.repeator}`);
555
+ parentEl.appendChild(anchor);
556
+ }
557
+
558
+ this._loopBindings.push({
559
+ parentEl,
560
+ node,
561
+ repeator: node.repeator,
562
+ elements,
563
+ anchor,
564
+ });
565
+ return;
566
+ }
567
+
568
+ const el = this._createSingleElement(node, dataScope);
569
+ parentEl.appendChild(el);
240
570
  }
241
571
 
242
572
  /**
243
- * @param {{ tag: string, semanticId?: string, id?: string, children?: any[] }} node
573
+ * Create a single DOM element for a layer node with item scope and attributes.
244
574
  */
245
- _createElementFromNode(node) {
575
+ _createSingleElement(node, dataScope, index) {
246
576
  const tag = (node.tag || 'div').toLowerCase();
247
577
  const id = normalizeId(node.semanticId || node.id || '');
248
578
  const el = document.createElement(tag);
@@ -250,17 +580,146 @@ export class WdlDom {
250
580
  if (id) {
251
581
  el.classList.add(id);
252
582
  el.setAttribute('wdl-comp', id);
253
- this.liveMap.set(id, el);
583
+ this._registerNode(id, el);
254
584
  }
255
585
 
586
+ const effectiveIndex = index !== undefined ? index : dataScope?._index;
587
+ if (effectiveIndex !== undefined) {
588
+ const idxStr = String(effectiveIndex);
589
+ el.setAttribute('data-wdl-index', idxStr);
590
+ if (el.dataset) el.dataset.wdlIndex = idxStr;
591
+ }
592
+
593
+ this._applyNodeAttrs(el, node, dataScope);
594
+
256
595
  if (Array.isArray(node.children)) {
257
596
  for (const child of node.children) {
258
- el.appendChild(this._createElementFromNode(child));
597
+ this._mountLayerNode(el, child, dataScope);
259
598
  }
260
599
  }
600
+
261
601
  return el;
262
602
  }
263
603
 
604
+ /**
605
+ * Apply matching attributes to an element using resolved dataScope.
606
+ */
607
+ _applyNodeAttrs(el, node, dataScope) {
608
+ const attrMap = this._getComponentAttrMap();
609
+ const id = normalizeId(node.semanticId || node.id || '');
610
+ const tag = (node.tag || 'div').toLowerCase();
611
+
612
+ // Priority: tag fallback -> semanticId (.id or id)
613
+ const tagAttrs = attrMap[tag] || {};
614
+ const idDotAttrs = id ? attrMap['.' + id] || {} : {};
615
+ const idAttrs = id ? attrMap[id] || {} : {};
616
+
617
+ const merged = { ...tagAttrs, ...idDotAttrs, ...idAttrs };
618
+ const resolved = resolveAll(merged, dataScope);
619
+
620
+ // Merge semantic ID, registry classes, and attr classes
621
+ const registryClasses = this._getRegistryClasses(id, tag, dataScope, el);
622
+ const attrClasses = typeof resolved.class === 'string' ? resolved.class : '';
623
+
624
+ const combinedClasses = [
625
+ id,
626
+ registryClasses,
627
+ attrClasses,
628
+ ]
629
+ .filter(Boolean)
630
+ .join(' ')
631
+ .split(/\s+/)
632
+ .filter(Boolean);
633
+
634
+ const uniqueClasses = Array.from(new Set(combinedClasses)).join(' ');
635
+
636
+ this._applyPropsToElement(el, id, { ...resolved, class: uniqueClasses });
637
+ }
638
+
639
+ _refreshElementClasses(el, id) {
640
+ if (!el) return;
641
+ const normId = normalizeId(id || el.getAttribute('wdl-comp') || '');
642
+ const compData = this._getComponentData();
643
+ const attrMap = this._getComponentAttrMap();
644
+ const tag = (el.tagName || 'div').toLowerCase();
645
+
646
+ const idx = el.getAttribute('data-wdl-index');
647
+ let scope = compData;
648
+ if (idx !== null && idx !== undefined) {
649
+ const indexNum = parseInt(idx, 10);
650
+ const loop = this._findLoopForElement(el);
651
+ if (loop) {
652
+ const items = resolvePath(compData, loop.repeator);
653
+ if (Array.isArray(items) && items[indexNum] !== undefined) {
654
+ scope = this._createItemScope(items[indexNum], indexNum, compData);
655
+ } else {
656
+ scope = { ...compData, _index: indexNum };
657
+ }
658
+ } else {
659
+ scope = { ...compData, _index: indexNum };
660
+ }
661
+ }
662
+
663
+ const tagAttrs = attrMap[tag] || {};
664
+ const idDotAttrs = normId ? attrMap['.' + normId] || {} : {};
665
+ const idAttrs = normId ? attrMap[normId] || {} : {};
666
+ const merged = { ...tagAttrs, ...idDotAttrs, ...idAttrs };
667
+ const resolved = resolveAll(merged, scope);
668
+
669
+ const registryClasses = this._getRegistryClasses(normId, tag, scope, el);
670
+ const attrClasses = typeof resolved.class === 'string' ? resolved.class : '';
671
+
672
+ const combinedClasses = [
673
+ normId,
674
+ registryClasses,
675
+ attrClasses,
676
+ ]
677
+ .filter(Boolean)
678
+ .join(' ')
679
+ .split(/\s+/)
680
+ .filter(Boolean);
681
+
682
+ el.className = Array.from(new Set(combinedClasses)).join(' ');
683
+ }
684
+
685
+ _refreshAllElementClasses() {
686
+ for (const [id, nodes] of this.liveNodes) {
687
+ for (const el of nodes) {
688
+ this._refreshElementClasses(el, id);
689
+ }
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Directly apply a resolved properties dictionary onto an element.
695
+ */
696
+ _applyPropsToElement(el, id, props) {
697
+ if (!props || typeof props !== 'object') return;
698
+
699
+ if (props.text !== undefined) {
700
+ el.textContent = String(props.text);
701
+ }
702
+ if (props.html !== undefined) {
703
+ el.innerHTML = String(props.html);
704
+ }
705
+ if (props.class !== undefined) {
706
+ el.className = String(props.class).trim();
707
+ }
708
+
709
+ for (const [key, value] of Object.entries(props)) {
710
+ if (ATTR_SKIP.has(key)) continue;
711
+ if (key === 'style' && value && typeof value === 'object') {
712
+ Object.assign(el.style, value);
713
+ continue;
714
+ }
715
+ if (value == null) {
716
+ el.removeAttribute(key);
717
+ } else {
718
+ el.setAttribute(key, String(value));
719
+ }
720
+ }
721
+ }
722
+
264
723
  // -----------------------------------------------------------------------
265
724
  // State event binding
266
725
  // -----------------------------------------------------------------------
@@ -274,7 +733,6 @@ export class WdlDom {
274
733
 
275
734
  const handler = (event) => this._onStateEvent(event);
276
735
 
277
- // All events documented by @ruledwdl/state
278
736
  const types = [
279
737
  'layers:change',
280
738
  'attr:change',
@@ -285,7 +743,6 @@ export class WdlDom {
285
743
 
286
744
  for (const type of types) {
287
745
  const off = c.on(type, handler);
288
- // state.on may return unsubscribe fn or nothing
289
746
  if (typeof off === 'function') this._unsubs.push(off);
290
747
  else this._unsubs.push(() => c.off?.(type, handler));
291
748
  }
@@ -321,68 +778,54 @@ export class WdlDom {
321
778
 
322
779
  // -----------------------------------------------------------------------
323
780
  // layers:change
324
- // actions: set | append | before | after | wrap | remove | update
781
+ // actions: set | append | prepend | before | after | wrap | unwrap | move | remove | update
325
782
  // -----------------------------------------------------------------------
326
783
 
327
784
  _handleLayers(event) {
328
785
  const { action, targetId, payload } = event;
329
786
  const id = normalizeId(targetId);
787
+ const compData = this._getComponentData();
330
788
 
331
789
  switch (action) {
332
790
  case 'set': {
333
- // Full layers replacement → remount
334
791
  this._mount();
335
792
  break;
336
793
  }
337
794
  case 'append': {
338
- // payload = layer expression (string or node-like)
339
- const parentEl = this.liveMap.get(id);
795
+ const parentEl = this.getNode(id);
340
796
  if (!parentEl) {
341
797
  this._log('warn', `append: parent "${id}" not in liveMap — remounting`);
342
798
  this._mount();
343
799
  return;
344
800
  }
345
801
  const token = parseLayerToken(payload);
346
- const el = this._createElementFromNode({
347
- tag: token.tag,
348
- semanticId: token.semanticId,
349
- children: [],
350
- });
351
- parentEl.appendChild(el);
802
+ this._mountLayerNode(parentEl, token, compData);
352
803
  this._log('op', `append <${token.tag}.${token.semanticId}> → #${id}`);
353
804
  break;
354
805
  }
355
806
  case 'prepend': {
356
- const parentEl = this.liveMap.get(id);
807
+ const parentEl = this.getNode(id);
357
808
  if (!parentEl) {
358
809
  this._log('warn', `prepend: parent "${id}" not in liveMap — remounting`);
359
810
  this._mount();
360
811
  return;
361
812
  }
362
813
  const token = parseLayerToken(payload);
363
- const el = this._createElementFromNode({
364
- tag: token.tag,
365
- semanticId: token.semanticId,
366
- children: [],
367
- });
814
+ const el = this._createSingleElement(token, compData);
368
815
  parentEl.insertBefore(el, parentEl.firstChild);
369
816
  this._log('op', `prepend <${token.tag}.${token.semanticId}> → #${id}`);
370
817
  break;
371
818
  }
372
819
  case 'before':
373
820
  case 'after': {
374
- const targetEl = this.liveMap.get(id);
821
+ const targetEl = this.getNode(id);
375
822
  if (!targetEl || !targetEl.parentNode) {
376
823
  this._log('warn', `${action}: target "${id}" missing — remounting`);
377
824
  this._mount();
378
825
  return;
379
826
  }
380
827
  const token = parseLayerToken(payload);
381
- const el = this._createElementFromNode({
382
- tag: token.tag,
383
- semanticId: token.semanticId,
384
- children: [],
385
- });
828
+ const el = this._createSingleElement(token, compData);
386
829
  if (action === 'before') {
387
830
  targetEl.parentNode.insertBefore(el, targetEl);
388
831
  } else {
@@ -392,26 +835,21 @@ export class WdlDom {
392
835
  break;
393
836
  }
394
837
  case 'wrap': {
395
- // payload = wrapper layer expression
396
- const targetEl = this.liveMap.get(id);
838
+ const targetEl = this.getNode(id);
397
839
  if (!targetEl || !targetEl.parentNode) {
398
840
  this._log('warn', `wrap: target "${id}" missing — remounting`);
399
841
  this._mount();
400
842
  return;
401
843
  }
402
844
  const token = parseLayerToken(payload);
403
- const wrapper = this._createElementFromNode({
404
- tag: token.tag,
405
- semanticId: token.semanticId,
406
- children: [],
407
- });
845
+ const wrapper = this._createSingleElement(token, compData);
408
846
  targetEl.parentNode.insertBefore(wrapper, targetEl);
409
847
  wrapper.appendChild(targetEl);
410
848
  this._log('op', `wrap #${id} with <${token.tag}.${token.semanticId}>`);
411
849
  break;
412
850
  }
413
851
  case 'unwrap': {
414
- const wrapperEl = this.liveMap.get(id);
852
+ const wrapperEl = this.getNode(id);
415
853
  if (!wrapperEl || !wrapperEl.parentNode) {
416
854
  this._log('warn', `unwrap: target "${id}" missing — remounting`);
417
855
  this._mount();
@@ -421,16 +859,16 @@ export class WdlDom {
421
859
  while (wrapperEl.firstChild) {
422
860
  parent.insertBefore(wrapperEl.firstChild, wrapperEl);
423
861
  }
862
+ this._unregisterElementHierarchy(wrapperEl);
424
863
  wrapperEl.remove();
425
- this.liveMap.delete(id);
426
864
  this._log('op', `unwrap #${id}`);
427
865
  break;
428
866
  }
429
867
  case 'move': {
430
- const sourceEl = this.liveMap.get(id);
868
+ const sourceEl = this.getNode(id);
431
869
  const { targetSemanticId, position } = payload || {};
432
870
  const targetId = normalizeId(targetSemanticId);
433
- const targetEl = this.liveMap.get(targetId);
871
+ const targetEl = this.getNode(targetId);
434
872
 
435
873
  if (!sourceEl || !targetEl) {
436
874
  this._log('warn', `move: source "${id}" or target "${targetId}" missing — remounting`);
@@ -449,23 +887,22 @@ export class WdlDom {
449
887
  break;
450
888
  }
451
889
  case 'remove': {
452
- const el = this.liveMap.get(id);
453
- if (!el) {
890
+ const nodes = this.getLiveNodes(id);
891
+ if (nodes.length === 0) {
454
892
  this._log('warn', `remove: #${id} not in liveMap`);
455
893
  return;
456
894
  }
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);
895
+ for (const el of nodes) {
896
+ this._unregisterElementHierarchy(el);
897
+ el.remove();
460
898
  }
461
- el.remove();
462
- this.liveMap.delete(id);
899
+ // Also cleanup loop bindings associated with this semantic ID
900
+ this._loopBindings = this._loopBindings.filter((l) => l.node.semanticId !== id);
463
901
  this._log('op', `remove #${id}`);
464
902
  break;
465
903
  }
466
904
  case 'update': {
467
- // payload = { tag?, semanticId? }
468
- const el = this.liveMap.get(id);
905
+ const el = this.getNode(id);
469
906
  if (!el) {
470
907
  this._log('warn', `update: #${id} missing — remounting`);
471
908
  this._mount();
@@ -473,28 +910,25 @@ export class WdlDom {
473
910
  }
474
911
  const patch = payload || {};
475
912
  if (patch.tag && patch.tag.toLowerCase() !== el.tagName.toLowerCase()) {
476
- // Tag change requires recreate
477
913
  const next = document.createElement(String(patch.tag).toLowerCase());
478
- // copy attributes & children
479
914
  for (const attr of el.attributes) next.setAttribute(attr.name, attr.value);
480
915
  while (el.firstChild) next.appendChild(el.firstChild);
481
916
  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);
917
+ this._unregisterNode(id, el);
918
+ const finalId = patch.semanticId ? normalizeId(patch.semanticId) : id;
919
+ if (patch.semanticId && finalId !== id) {
485
920
  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);
921
+ next.classList.add(finalId);
922
+ next.setAttribute('wdl-comp', finalId);
490
923
  }
924
+ this._registerNode(finalId, next);
491
925
  } else if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
492
926
  const newId = normalizeId(patch.semanticId);
493
927
  el.classList.remove(id);
494
928
  el.classList.add(newId);
495
929
  el.setAttribute('wdl-comp', newId);
496
- this.liveMap.delete(id);
497
- this.liveMap.set(newId, el);
930
+ this._unregisterNode(id, el);
931
+ this._registerNode(newId, el);
498
932
  }
499
933
  this._log('op', `update #${id}`, patch);
500
934
  break;
@@ -515,89 +949,114 @@ export class WdlDom {
515
949
  const id = normalizeId(targetId);
516
950
 
517
951
  if (action === 'set' || action === 'update') {
518
- // payload = full attrs object (set) or patch (update)
519
- this._applyAttrs(id, payload || {});
952
+ const nodes = this.getLiveNodes(id);
953
+ if (nodes.length === 0) {
954
+ this._log('warn', `attr: #${id} not in liveMap`);
955
+ return;
956
+ }
957
+ const compData = this._getComponentData();
958
+ for (const el of nodes) {
959
+ const idx = el.getAttribute('data-wdl-index');
960
+ let scope = compData;
961
+ if (idx !== null && idx !== undefined) {
962
+ const indexNum = parseInt(idx, 10);
963
+ const loop = this._findLoopForElement(el);
964
+ if (loop) {
965
+ const items = resolvePath(compData, loop.repeator);
966
+ if (Array.isArray(items) && items[indexNum] !== undefined) {
967
+ scope = this._createItemScope(items[indexNum], indexNum, compData);
968
+ } else {
969
+ scope = { ...compData, _index: indexNum };
970
+ }
971
+ } else {
972
+ scope = { ...compData, _index: indexNum };
973
+ }
974
+ }
975
+ const resolved = resolveAll(payload || {}, scope);
976
+ this._applyPropsToElement(el, id, resolved);
977
+ }
520
978
  this._log('op', `attr.${action} #${id}`, payload);
521
979
  return;
522
980
  }
523
981
 
524
982
  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}`);
983
+ const nodes = this.getLiveNodes(id);
984
+ if (nodes.length === 0) return;
985
+ const attrKey = payload;
986
+ for (const el of nodes) {
987
+ if (attrKey == null || attrKey === '') {
988
+ el.textContent = '';
989
+ el.className = id;
990
+ } else if (attrKey === 'text') {
991
+ el.textContent = '';
992
+ } else if (attrKey === 'class') {
993
+ el.className = id;
994
+ } else if (attrKey === 'html') {
995
+ el.innerHTML = '';
996
+ } else {
997
+ el.removeAttribute(attrKey);
998
+ }
545
999
  }
1000
+ this._log('op', `attr.remove ${attrKey || 'all'} from #${id}`);
546
1001
  }
547
1002
  }
548
1003
 
549
- /**
550
- * Apply attribute object onto live element.
551
- * Supports: text, class, html, data-*, style object, arbitrary attrs.
552
- */
553
1004
  _applyAttrs(id, props) {
554
- if (!props || typeof props !== 'object') return;
555
- const el = this.liveMap.get(id);
556
- if (!el) {
1005
+ const nodes = this.getLiveNodes(id);
1006
+ if (nodes.length === 0) {
557
1007
  this._log('warn', `attr: #${id} not in liveMap`);
558
1008
  return;
559
1009
  }
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));
1010
+ const compData = this._getComponentData();
1011
+ for (const el of nodes) {
1012
+ const idx = el.getAttribute('data-wdl-index');
1013
+ let scope = compData;
1014
+ if (idx !== null && idx !== undefined) {
1015
+ const indexNum = parseInt(idx, 10);
1016
+ const loop = this._findLoopForElement(el);
1017
+ if (loop) {
1018
+ const items = resolvePath(compData, loop.repeator);
1019
+ if (Array.isArray(items) && items[indexNum] !== undefined) {
1020
+ scope = this._createItemScope(items[indexNum], indexNum, compData);
1021
+ } else {
1022
+ scope = { ...compData, _index: indexNum };
1023
+ }
1024
+ }
582
1025
  }
1026
+ const resolved = resolveAll(props || {}, scope);
1027
+ this._applyPropsToElement(el, id, resolved);
583
1028
  }
584
1029
  }
585
1030
 
1031
+ _findLoopForElement(el) {
1032
+ return this._loopBindings.find((l) => l.elements.includes(el)) || null;
1033
+ }
1034
+
586
1035
  // -----------------------------------------------------------------------
587
1036
  // data:change
588
- // actions: set | remove
589
- // Optional: onDataBind callback for custom binding
1037
+ // actions: set | update | remove
1038
+ // Surgically reconciles repeated loop items & per-item template bindings
590
1039
  // -----------------------------------------------------------------------
591
1040
 
592
1041
  _handleData(event) {
593
1042
  const { action, targetId, payload } = event;
594
- // targetId is the path (e.g. "user.name"), payload is the value
595
1043
  const path = targetId || '';
1044
+ const compData = this._getComponentData();
1045
+
1046
+ // 1. Surgically reconcile all matching loop bindings
1047
+ this._reconcileLoops(path, compData);
1048
+
1049
+ // 2. Reconcile non-loop elements bound to this path or full data
1050
+ this._reconcileStaticDataBindings(path, compData);
1051
+
1052
+ // 3. Optional onDataBind callback hook for external consumers
596
1053
  if (typeof this.onDataBind === 'function') {
597
- // Let consumer decide how data maps to DOM
598
- // We pass the root container + path + value
599
1054
  try {
600
- this.onDataBind(this.container, path, action === 'remove' ? undefined : payload);
1055
+ this.onDataBind(
1056
+ this.container,
1057
+ path,
1058
+ action === 'remove' ? undefined : payload
1059
+ );
601
1060
  } catch (e) {
602
1061
  this._log('warn', 'onDataBind error', e);
603
1062
  }
@@ -605,6 +1064,113 @@ export class WdlDom {
605
1064
  this._log('op', `data.${action} ${path}`, action === 'remove' ? undefined : payload);
606
1065
  }
607
1066
 
1067
+ /**
1068
+ * Reconcile loop children surgically on data change.
1069
+ */
1070
+ _reconcileLoops(path, compData) {
1071
+ for (const loop of this._loopBindings) {
1072
+ const matches =
1073
+ !path ||
1074
+ loop.repeator === path ||
1075
+ path.startsWith(loop.repeator + '.') ||
1076
+ loop.repeator.startsWith(path + '.');
1077
+
1078
+ if (!matches) continue;
1079
+
1080
+ const items = this._resolveLoopItems(loop.repeator, compData);
1081
+ const nextCount = items.length;
1082
+ const prevCount = loop.elements.length;
1083
+ const minCount = Math.min(prevCount, nextCount);
1084
+
1085
+ // 1. Update retained rows in place
1086
+ for (let i = 0; i < minCount; i++) {
1087
+ const el = loop.elements[i];
1088
+ const itemScope = this._createItemScope(items[i], i, compData);
1089
+ el.setAttribute('data-wdl-index', String(i));
1090
+ if (el.dataset) el.dataset.wdlIndex = String(i);
1091
+ this._updateElementSubtree(el, loop.node, itemScope, i);
1092
+ }
1093
+
1094
+ // 2. Insert newly added rows
1095
+ if (nextCount > prevCount) {
1096
+ for (let i = prevCount; i < nextCount; i++) {
1097
+ const itemScope = this._createItemScope(items[i], i, compData);
1098
+ const newEl = this._createSingleElement(loop.node, itemScope, i);
1099
+ if (loop.anchor && loop.anchor.parentNode === loop.parentEl) {
1100
+ loop.parentEl.insertBefore(newEl, loop.anchor);
1101
+ } else if (loop.elements.length > 0) {
1102
+ const lastEl = loop.elements[loop.elements.length - 1];
1103
+ loop.parentEl.insertBefore(newEl, lastEl.nextSibling);
1104
+ } else {
1105
+ loop.parentEl.appendChild(newEl);
1106
+ }
1107
+ loop.elements.push(newEl);
1108
+ }
1109
+ }
1110
+
1111
+ // 3. Remove deleted rows
1112
+ if (nextCount < prevCount) {
1113
+ for (let i = prevCount - 1; i >= nextCount; i--) {
1114
+ const delEl = loop.elements[i];
1115
+ this._unregisterElementHierarchy(delEl);
1116
+ delEl.remove();
1117
+ loop.elements.pop();
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Update an element and its non-loop children with new itemScope.
1125
+ */
1126
+ _updateElementSubtree(el, node, itemScope, index) {
1127
+ this._applyNodeAttrs(el, node, itemScope);
1128
+
1129
+ if (Array.isArray(node.children) && Array.isArray(el.children)) {
1130
+ let childElIdx = 0;
1131
+ for (const childNode of node.children) {
1132
+ if (childNode.repeator) {
1133
+ // Nested loop: find its binding and reconcile
1134
+ const childLoop = this._loopBindings.find(
1135
+ (l) => l.parentEl === el && l.node === childNode
1136
+ );
1137
+ if (childLoop) {
1138
+ this._reconcileLoops(childLoop.repeator, itemScope);
1139
+ }
1140
+ } else if (el.children[childElIdx]) {
1141
+ this._updateElementSubtree(
1142
+ el.children[childElIdx],
1143
+ childNode,
1144
+ itemScope,
1145
+ index
1146
+ );
1147
+ childElIdx++;
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Update non-loop live elements that reference template data bindings.
1155
+ */
1156
+ _reconcileStaticDataBindings(path, compData) {
1157
+ const attrMap = this._getComponentAttrMap();
1158
+ for (const [key, props] of Object.entries(attrMap)) {
1159
+ const id = normalizeId(key);
1160
+ const str = JSON.stringify(props);
1161
+ if (!str.includes('${') && !str.includes('{{')) continue;
1162
+ if (path && !str.includes(path)) continue;
1163
+
1164
+ const nodes = this.getLiveNodes(id);
1165
+ for (const el of nodes) {
1166
+ // Skip elements governed by loops as they are reconciled separately
1167
+ if (el.hasAttribute('data-wdl-index') || this._findLoopForElement(el)) continue;
1168
+ const resolved = resolveAll(props, compData);
1169
+ this._applyPropsToElement(el, id, resolved);
1170
+ }
1171
+ }
1172
+ }
1173
+
608
1174
  // -----------------------------------------------------------------------
609
1175
  // variant:change
610
1176
  // action: set targetId = semanticId (or component id) payload = variantName
@@ -613,29 +1179,26 @@ export class WdlDom {
613
1179
  _handleVariant(event) {
614
1180
  const { targetId, payload } = event;
615
1181
  const id = normalizeId(targetId) || this._rootSemanticId();
616
- const el = this.liveMap.get(id);
1182
+ const el = this.getNode(id);
617
1183
  if (!el) {
618
- // try root
619
1184
  const rootId = this._rootSemanticId();
620
- const rootEl = rootId ? this.liveMap.get(rootId) : null;
1185
+ const rootEl = rootId ? this.getNode(rootId) : null;
621
1186
  if (rootEl) {
622
1187
  if (payload) rootEl.dataset.variant = String(payload);
623
1188
  else delete rootEl.dataset.variant;
1189
+ this._refreshElementClasses(rootEl, rootId);
624
1190
  this._log('op', `variant → ${payload || '(none)'} on #${rootId}`);
625
1191
  }
626
1192
  return;
627
1193
  }
628
1194
  if (payload) el.dataset.variant = String(payload);
629
1195
  else delete el.dataset.variant;
1196
+ this._refreshElementClasses(el, id);
630
1197
  this._log('op', `variant → ${payload || '(none)'} on #${id}`);
631
1198
  }
632
1199
 
633
1200
  _applyRootVariant() {
634
- // Read current variant from attr if present
635
- const attr =
636
- this.component.attr?.list?.() ??
637
- this.component.getSnapshot?.()?.attr ??
638
- {};
1201
+ const attr = this._getComponentAttrMap();
639
1202
  const rootId = this._rootSemanticId();
640
1203
  if (!rootId) return;
641
1204
  const rootKey = '.' + rootId;
@@ -644,12 +1207,15 @@ export class WdlDom {
644
1207
  attr['.']?.['data-variant'] ??
645
1208
  null;
646
1209
  if (variant && this.liveMap.has(rootId)) {
647
- this.liveMap.get(rootId).dataset.variant = String(variant);
1210
+ const el = this.getNode(rootId);
1211
+ if (el && el.dataset) {
1212
+ el.dataset.variant = String(variant);
1213
+ this._refreshElementClasses(el, rootId);
1214
+ }
648
1215
  }
649
1216
  }
650
1217
 
651
1218
  _rootSemanticId() {
652
- // First entry in liveMap that is a direct child of container, or first key
653
1219
  for (const [id, el] of this.liveMap) {
654
1220
  if (el.parentNode === this.container) return id;
655
1221
  }
@@ -665,6 +1231,7 @@ export class WdlDom {
665
1231
 
666
1232
  _handleRegistry(event) {
667
1233
  this._syncRegistryStyles();
1234
+ this._refreshAllElementClasses();
668
1235
  this._log('op', `registry.${event.action}`);
669
1236
  }
670
1237
 
@@ -751,3 +1318,4 @@ export function createWdlDom(options) {
751
1318
  }
752
1319
 
753
1320
  export default createWdlDom;
1321
+