@adia-ai/web-components 0.6.7 → 0.6.9

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.
@@ -279,3 +279,297 @@ describe('html template — FB-47 (v0.5.19) stamp() cache + repeat() keyed reuse
279
279
  expect(secondDiv.textContent).toBe('second');
280
280
  });
281
281
  });
282
+
283
+ describe('html template — FB-55 (v0.6.8) `.camelCase=${expr}` property-binding lowercase trap close', () => {
284
+ // FEEDBACK-55: the HTML parser lowercases attribute names inside
285
+ // <template>.innerHTML per HTML5 §13.2.5.32. Pre-fix, `.className=` arrived
286
+ // at scan() as `.classname`, then `name.slice(1)` yielded `"classname"`,
287
+ // and `applyValue()` wrote `p.n["classname"] = v` — an enumerable expando,
288
+ // never invoking the camelCase property setter. Classes never applied; no
289
+ // warn, no error.
290
+ //
291
+ // Fix: PROP_CASE_FIX static map (for built-in DOM camelCase props) +
292
+ // prototype-walk fallback (for UIElement-defined custom-element props).
293
+ // Backward-compatible: when no case-insensitive match exists, the
294
+ // original lowercase name is preserved (genuine expando consumers
295
+ // unaffected).
296
+ //
297
+ // RESPONSE-55 documents the trap end-to-end + reviewer-#A scope
298
+ // expansion to UIElement primitives.
299
+
300
+ let container;
301
+ let warnSpy;
302
+
303
+ beforeEach(() => {
304
+ container = document.createElement('div');
305
+ document.body.appendChild(container);
306
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
307
+ });
308
+
309
+ afterEach(() => {
310
+ container.remove();
311
+ warnSpy.mockRestore();
312
+ });
313
+
314
+ it('.className=${expr} writes to the real className property (not lowercase expando)', () => {
315
+ const tpl = html`<span .className=${'foo bar'}>x</span>`;
316
+ stamp(tpl, container);
317
+ const span = container.querySelector('span');
318
+ expect(span.className).toBe('foo bar'); // ✅ real className set
319
+ expect(span.getAttribute('class')).toBe('foo bar'); // ✅ reflected to DOM attr
320
+ expect(span.classname).toBeUndefined(); // ✅ NO lowercase expando
321
+ });
322
+
323
+ it('.tabIndex=${n} writes to the real tabIndex property', () => {
324
+ const tpl = html`<div .tabIndex=${3}></div>`;
325
+ stamp(tpl, container);
326
+ const div = container.querySelector('div');
327
+ expect(div.tabIndex).toBe(3);
328
+ expect(div.getAttribute('tabindex')).toBe('3');
329
+ });
330
+
331
+ it('.innerHTML=${str} survives unchanged (already-lowercase name)', () => {
332
+ // .innerHTML is one of the few camelCase-looking names whose
333
+ // canonical form is already lowercase (well, it's `innerHTML` but
334
+ // the parser lowercases it to `innerhtml` AND PROP_CASE_FIX maps
335
+ // back to `innerHTML`). Verify it lands on the property regardless.
336
+ const tpl = html`<div .innerHTML=${'<span class="inner">payload</span>'}></div>`;
337
+ stamp(tpl, container);
338
+ const div = container.querySelector('div');
339
+ expect(div.querySelector('.inner')?.textContent).toBe('payload');
340
+ });
341
+
342
+ it('.lowercaseProp=${v} on a stock element preserves expando semantics (backward compat)', () => {
343
+ // The fallback prototype walk skips Object.prototype to avoid noisy
344
+ // matches. A genuinely-lowercase property name with no matching
345
+ // camelCase pair on the chain (e.g. `.myexpando`) lands on the
346
+ // element as a lowercase expando — the documented escape hatch
347
+ // for arbitrary data attachment. Verify no regression.
348
+ const tpl = html`<div .myexpando=${'attached'}></div>`;
349
+ stamp(tpl, container);
350
+ const div = container.querySelector('div');
351
+ expect(div.myexpando).toBe('attached');
352
+ });
353
+
354
+ it('.camelCaseProp=${v} on a UIElement-style custom element invokes the canonical setter', async () => {
355
+ // Reviewer-addition #A from RESPONSE-55: UIElement defines
356
+ // properties under camelCase keys via Object.defineProperty. The
357
+ // pre-fix trap wrote `el['camelprop'] = v` (expando) instead of
358
+ // invoking the setter. Verify the fix routes correctly through
359
+ // the prototype-walk fallback.
360
+ class FB55El extends HTMLElement {
361
+ constructor() {
362
+ super();
363
+ let _camelProp = '';
364
+ Object.defineProperty(this, 'camelProp', {
365
+ get() { return _camelProp; },
366
+ set(v) { _camelProp = v; this.dataset.lastSet = v; },
367
+ configurable: true,
368
+ enumerable: true,
369
+ });
370
+ }
371
+ }
372
+ if (!customElements.get('fb55-el')) customElements.define('fb55-el', FB55El);
373
+
374
+ const tpl = html`<fb55-el .camelProp=${'via-setter'}></fb55-el>`;
375
+ stamp(tpl, container);
376
+ const el = container.querySelector('fb55-el');
377
+ expect(el.camelProp).toBe('via-setter'); // ✅ getter returns set value
378
+ expect(el.dataset.lastSet).toBe('via-setter'); // ✅ setter side-effect ran
379
+ expect(el.camelprop).toBeUndefined(); // ✅ NO lowercase expando
380
+ });
381
+
382
+ it('.maxChroma=${n} on a UIElement-style ce reaches the camelCase property', () => {
383
+ // Real-world surface: <color-picker max-chroma> and <color-input
384
+ // max-chroma> declare `maxChroma` as a static property. Consumers
385
+ // reaching for `.maxChroma=${value}` template binding must invoke
386
+ // the setter, not write an expando.
387
+ class FB55Numeric extends HTMLElement {
388
+ constructor() {
389
+ super();
390
+ let _maxChroma = 0;
391
+ Object.defineProperty(this, 'maxChroma', {
392
+ get() { return _maxChroma; },
393
+ set(v) { _maxChroma = v; },
394
+ configurable: true, enumerable: true,
395
+ });
396
+ }
397
+ }
398
+ if (!customElements.get('fb55-num')) customElements.define('fb55-num', FB55Numeric);
399
+
400
+ const tpl = html`<fb55-num .maxChroma=${0.42}></fb55-num>`;
401
+ stamp(tpl, container);
402
+ const el = container.querySelector('fb55-num');
403
+ expect(el.maxChroma).toBe(0.42);
404
+ });
405
+
406
+ it('updates to .className=${expr} re-route through the setter on every value change', () => {
407
+ // The PROP_CASE_FIX resolution is cached in parts[i].name at scan()
408
+ // time. Subsequent update() ticks must continue to use the resolved
409
+ // camelCase name. Verify by re-stamping with a new value.
410
+ const make = (cls) => html`<span .className=${cls}>x</span>`;
411
+ stamp(make('a'), container);
412
+ let span = container.querySelector('span');
413
+ expect(span.className).toBe('a');
414
+
415
+ stamp(make('b'), container);
416
+ span = container.querySelector('span');
417
+ expect(span.className).toBe('b'); // ✅ second update routes correctly
418
+ expect(span.getAttribute('class')).toBe('b');
419
+ });
420
+
421
+ it('warning text no longer recommends .className= as the FIRST option for class= partial interp', () => {
422
+ // FB-55 #2: pre-fix, the v0.5.5 §184 warn text put `.className=`
423
+ // first; post-fix, `class="${expr}"` (the discoverable form) leads.
424
+ // Both work now (FB-55 #1), but the recommended ordering matters
425
+ // for new consumers reading the warning cold.
426
+ const cls = 'foo';
427
+ const tpl = html`<span class="prefix ${cls}">x</span>`;
428
+ stamp(tpl, container);
429
+ expect(warnSpy).toHaveBeenCalled();
430
+ const msg = warnSpy.mock.calls[0][0];
431
+ const classFullPos = msg.indexOf('class="${expression}"');
432
+ const classNamePos = msg.indexOf('.className=${expression}');
433
+ expect(classFullPos).toBeGreaterThan(-1);
434
+ expect(classNamePos).toBeGreaterThan(-1);
435
+ expect(classFullPos).toBeLessThan(classNamePos); // ✅ class="" first, .className= second
436
+ });
437
+
438
+ it('warning text dropped the .classList= aspirational line (FB-55 #2)', () => {
439
+ // Pre-fix the warn text mentioned `.classList=${{foo: true, bar: false}}`
440
+ // as "if/when implemented". classList is a read-only DOMTokenList
441
+ // accessor — no PROP_CASE_FIX could make it assignable, so the line
442
+ // was misleading. Post-fix it's removed.
443
+ const cls = 'foo';
444
+ const tpl = html`<span class="prefix ${cls}">x</span>`;
445
+ stamp(tpl, container);
446
+ const msg = warnSpy.mock.calls[0][0];
447
+ expect(msg).not.toMatch(/\.classList=/);
448
+ });
449
+
450
+ it('does not regress: canonical .style=${cssText}, .title=${str}, .value=${v} still work', () => {
451
+ // These property names are already lowercase, so they survived
452
+ // the parser pre-fix. Verify PROP_CASE_FIX didn't break them.
453
+ const tpl = html`<input .title=${'tip'} .value=${'val'}>`;
454
+ stamp(tpl, container);
455
+ const input = container.querySelector('input');
456
+ expect(input.title).toBe('tip');
457
+ expect(input.value).toBe('val');
458
+ });
459
+ });
460
+
461
+ describe('html template — FB-57 (v0.6.8) nested SVG/MathML template namespace re-routing', () => {
462
+ // FEEDBACK-57: nested `${html`<svg-child/>`}` interpolations produce
463
+ // HTML-namespaced elements (HTMLUnknownElement) instead of SVGElement.
464
+ // Root cause: each nested html`` is a SEPARATE cached template, and
465
+ // `tpl.innerHTML = m` is parsed at document level without SVG context.
466
+ // The <span style="display:contents"> wrapper from wrap() doesn't fix
467
+ // it because display:contents is layout-level not namespace-level;
468
+ // SVG layout is namespace-strict → invisible-but-present output.
469
+ //
470
+ // Fix: namespace-aware mount() — when the container is inside an SVG
471
+ // (or MathML) subtree, recursively re-namespace the cloned fragment
472
+ // via createElementNS. <foreignObject> and <annotation-xml
473
+ // encoding=text/html> revert to HTML per HTML5 §12.2.5 foreign-content
474
+ // insertion mode.
475
+ //
476
+ // RESPONSE-57 documents the trap end-to-end + the implementation-site
477
+ // choice (mount() not getTemplate(), to keep cache coherent).
478
+
479
+ const SVG_NS = 'http://www.w3.org/2000/svg';
480
+ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
481
+
482
+ let container;
483
+
484
+ beforeEach(() => {
485
+ container = document.createElement('div');
486
+ document.body.appendChild(container);
487
+ });
488
+
489
+ afterEach(() => {
490
+ container.remove();
491
+ });
492
+
493
+ it('inline <svg><circle/></svg> in a single template keeps working (regression)', () => {
494
+ // Pre-fix this case already worked because the HTML parser sees
495
+ // <svg> in tpl.innerHTML and enters SVG insertion mode for its
496
+ // descendants. Post-fix it must still work — the container is
497
+ // HTML-namespaced (<div>), so foreignContentNS() returns null,
498
+ // re-namespacing is skipped, and the inline parser's NS survives.
499
+ const tpl = html`<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="3"/></svg>`;
500
+ stamp(tpl, container);
501
+ const svg = container.querySelector('svg');
502
+ const circle = container.querySelector('circle');
503
+ expect(svg.namespaceURI).toBe(SVG_NS);
504
+ expect(circle.namespaceURI).toBe(SVG_NS);
505
+ });
506
+
507
+ it('nested ${html`<circle/>`} inside an SVG container now lands in SVG namespace', () => {
508
+ // The canonical fix surface. Pre-fix, the inner template's <circle>
509
+ // arrived as HTMLUnknownElement (NS xhtml). Post-fix, the wrap()
510
+ // span is HTML-namespaced (correct — it has display:contents) but
511
+ // its children get re-namespaced via the wrap-span's closest('svg')
512
+ // matching the outer SVG.
513
+ const svgRoot = document.createElementNS(SVG_NS, 'svg');
514
+ container.appendChild(svgRoot);
515
+ const dots = [{ x: 1 }, { x: 2 }, { x: 3 }];
516
+ const tpl = html`${dots.map(d => html`<circle cx=${d.x} cy="5" r="2"/>`)}`;
517
+ stamp(tpl, svgRoot);
518
+ const circles = svgRoot.querySelectorAll('circle');
519
+ expect(circles.length).toBe(3);
520
+ for (const c of circles) {
521
+ expect(c.namespaceURI).toBe(SVG_NS); // ✅ SVG-NS
522
+ }
523
+ });
524
+
525
+ it('a directly-stamped html`<circle/>` template into an SVG container is SVG-namespaced', () => {
526
+ // Even without array interpolation, stamping a single SVG-content
527
+ // template into an SVG container must re-namespace.
528
+ const svgRoot = document.createElementNS(SVG_NS, 'svg');
529
+ container.appendChild(svgRoot);
530
+ const tpl = html`<circle cx="5" cy="5" r="3"/>`;
531
+ stamp(tpl, svgRoot);
532
+ const circle = svgRoot.querySelector('circle');
533
+ expect(circle.namespaceURI).toBe(SVG_NS);
534
+ });
535
+
536
+ it('<foreignObject> children REVERT to HTML namespace (per HTML5 spec)', () => {
537
+ // <foreignObject> is the spec-defined HTML escape hatch inside SVG.
538
+ // Re-namespacing must stop at the foreignObject boundary; its
539
+ // children stay HTML-namespaced.
540
+ const svgRoot = document.createElementNS(SVG_NS, 'svg');
541
+ container.appendChild(svgRoot);
542
+ const tpl = html`<foreignObject><div class="html-inside-svg">html content</div></foreignObject>`;
543
+ stamp(tpl, svgRoot);
544
+ const fo = svgRoot.querySelector('foreignObject');
545
+ const innerDiv = svgRoot.querySelector('.html-inside-svg');
546
+ expect(fo.namespaceURI).toBe(SVG_NS); // ✅ foreignObject itself is SVG
547
+ expect(innerDiv.namespaceURI).toBe('http://www.w3.org/1999/xhtml'); // ✅ children HTML
548
+ });
549
+
550
+ it('MathML <math><mi/></math> inline template stays in MathML namespace via the HTML parser', () => {
551
+ // Like inline-SVG: the HTML parser enters MathML insertion mode for
552
+ // <math>, so inline children arrive correctly namespaced even
553
+ // pre-fix. Post-fix this remains true (no re-namespacing because
554
+ // the container is HTML).
555
+ const tpl = html`<math><mi>x</mi></math>`;
556
+ stamp(tpl, container);
557
+ const math = container.querySelector('math');
558
+ const mi = container.querySelector('mi');
559
+ // happy-dom may or may not surface MathML namespace; smoke that the
560
+ // elements at least exist and the math element is acknowledged.
561
+ expect(math).not.toBeNull();
562
+ expect(mi).not.toBeNull();
563
+ expect(math.localName).toBe('math');
564
+ });
565
+
566
+ it('does not re-namespace when stamping into an HTML container (no overhead in the common case)', () => {
567
+ // Probe the discriminator: a plain <div> container should skip the
568
+ // re-namespacing path entirely. Verify by stamping a template
569
+ // whose children are HTML elements and confirming they stay HTML.
570
+ const tpl = html`<p class="probe">html paragraph</p>`;
571
+ stamp(tpl, container);
572
+ const p = container.querySelector('p.probe');
573
+ expect(p.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
574
+ });
575
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",