@adia-ai/web-components 0.6.7 → 0.6.8
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 +14 -0
- package/USAGE.md +186 -0
- package/core/template.js +236 -7
- package/core/template.test.js +294 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog — @adia-ai/web-components
|
|
2
2
|
|
|
3
|
+
## [0.6.8] — 2026-05-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`core/template.js` — `.camelCaseProp=${expr}` property bindings now route to the canonical setter (FB-55, P1).** Pre-fix: the HTML parser lowercases attribute names inside `<template>.innerHTML` per HTML5 §13.2.5.32, so `.className=${expr}` arrived at the binding scanner as `.classname`. The binding then wrote `element['classname'] = v` — an enumerable expando — never invoking the `HTMLElement.prototype.className` setter. Classes never applied; no warning, no error. Trap affected every camelCase DOM property surface (`.className`, `.innerText`, `.tabIndex`, `.ariaLabel`, `.contentEditable`, `.readOnly`, `.maxLength`, `.minLength`, `.colSpan`, `.rowSpan`) AND every camelCase property declared via `UIElement.static properties` (80+ across primitives — `<color-picker>` `.maxChroma`, `<grid>` `.columnGap`, `<breadcrumb>` `.collapseKeepLeading`, etc.). Even the canonical `<my-panel .minL=${minL}>` example from `README.md:212` silently failed. **Fix: two-layer name resolution** — (1) `PROP_CASE_FIX` static map at `scan()` time covers built-in DOM camelCase (zero-cost lookup, no prototype walk for the common case); (2) lazy prototype-walk fallback at `applyValue()` time finds canonical camelCase property names on the element's instance + prototype chain via case-insensitive match, caching resolution on the part after first hit. Catches UIElement custom-element props installed per-instance in `installProps()` (those don't exist at `scan()` time because the element hasn't been upgraded yet). Backward-compatible: when neither layer finds a case-insensitive match, the original lowercase name is preserved (consumers writing to lowercase expandos unaffected). 10 NEW vitest cases in `core/template.test.js`'s `FB-55` describe block pin the contract. Closes RESPONSE-55 (reviewer-#A expansion to UIElement primitives explicitly covered).
|
|
7
|
+
- **`core/template.js` — nested `${html\`<svg-child/>\`}` interpolations now render in SVG namespace (FB-57, P1).** Pre-fix: each nested `html\`\`` is a separate cached template whose `tpl.innerHTML = m` is parsed at document level without SVG insertion-mode context, so `<circle>`/`<line>`/etc. arrived as `HTMLUnknownElement` (NS `http://www.w3.org/1999/xhtml`). The `<span style="display:contents">` wrapper from `wrap()` didn't fix it because `display: contents` is layout-level, not namespace-level; SVG layout is namespace-strict → present-but-invisible output, zero bounding rect, no error. **Fix: namespace-aware `mount()`** — discriminate via `container.closest('svg, math, foreignObject, annotation-xml')` at mount time; when stamping into an SVG/MathML subtree, recursively re-namespace the cloned fragment via `createElementNS`, transferring attributes via `setAttributeNS` to preserve attr-namespaces (e.g., legacy `xlink:href`). `<foreignObject>` (SVG) and `<annotation-xml encoding="text/html"|"application/xhtml+xml">` (MathML) descendants correctly REVERT to HTML namespace per HTML5 §12.2.5 foreign-content insertion mode. Implementation site: `mount()` rather than `getTemplate()` — keeps the template cache coherent (same `html\`\`` source can stamp into either context without doubling cache entries). 6 NEW vitest cases in `core/template.test.js`'s `FB-57` describe block (inline-SVG regression, nested-SVG fix, direct-stamp into SVG container, foreignObject boundary, MathML, HTML-container no-op). Closes RESPONSE-57 (Option A accepted with implementation-site refinement; Option B rejected as half-measure).
|
|
8
|
+
- **`core/template.js` — partial-interp warning text recommends `class="${expr}"` first (FB-55 #2).** The v0.5.5 §184 warn text put `.className=${expression}` first as the recommended fix for partial class interpolation; that path now works post-FB-55 fix above, but `class="${expr}"` (full-attribute interpolation) is the more discoverable form that doesn't require knowing the camelCase property name. Promoting it first. The `.classList=${{foo: true, bar: false}}` aspirational line is removed — classList is a read-only DOMTokenList accessor, no `PROP_CASE_FIX` entry can make it assignable.
|
|
9
|
+
|
|
10
|
+
### Docs
|
|
11
|
+
- **`USAGE.md` — three new template-related sections** documenting the FB-55/56/57 cluster:
|
|
12
|
+
- **§-TBD (v0.6.8) — Host element display defaults (FB-56)** — surfaces the HTML-spec inline default for custom elements + the `:host(:where(*))` zero-specificity recipe used across the in-tree primitive set (30+ flex-container primitives). Documents why `UIElement` doesn't bake a `display` rule into the host (container vs leaf vs passthrough is a consumer-layout decision). Worked symptom + diagnostic recipe ("inspect the host directly — `getComputedStyle(host).display === 'inline'`").
|
|
13
|
+
- **§-TBD (v0.6.8) — SVG composition + namespacing (FB-57)** — documents three modes (inline, nested-interpolations, imperative-`.innerHTML`) with the `<foreignObject>` boundary semantics and the MathML parallel. Migration is zero — pre-fix code that worked keeps working; pre-fix code that was silently broken (nested SVG interpolations) starts producing visible output.
|
|
14
|
+
- **§-TBD (v0.6.8) — `.camelCaseProp=${expr}` property binding (FB-55)** — explains the two-layer name resolution, lists affected camelCase property surfaces, documents backward-compatibility for genuine expando consumers, and notes the discoverability trade-off (the simpler `class="${expr}"` doesn't require knowing the camelCase property name).
|
|
15
|
+
- **USAGE.md `§-TBD (v0.6.8) — UIElement lifecycle hooks (FB-59 + FB-43/44/47/58 cluster)`** — new section documenting the canonical hook order: `adoptStyles` → `prepareParts` → **`connected()`** (line 122, inside `untracked()`) → effect first-run with **stamp at line 127** → `render()` → `updated()`. Source-cited from `core/element.js:107-142`. Resolves a cluster-wide misunderstanding about when `connected()` fires relative to the template stamp: it runs BEFORE the stamp wipes consumer-supplied light-DOM children, NOT after. Includes the "Container UIElement pattern" example showing `connected()` → capture children → arm MutationObserver, plus the defensive `if (present.length > 0) keep` cache-fallback pattern (consumer-discovered) that makes capture robust to ordering. Distinguishes `connected()` (recommended subclass hook) vs `connectedCallback()` (rarely overridden W3C hook). Closes FB-59 P3 docs ask + corrects the §B mechanism walkthrough in RESPONSE-58 (sibling followup-RESPONSE retracts the §B order claim).
|
|
16
|
+
|
|
3
17
|
## [0.6.7] — 2026-05-19
|
|
4
18
|
|
|
5
19
|
### Added
|
package/USAGE.md
CHANGED
|
@@ -1181,6 +1181,115 @@ Why AdiaUI doesn't implement `?attr=`: custom elements declare their reflective
|
|
|
1181
1181
|
- HTML comments containing quoted attributes (`<!-- attr="value" -->`) — same fix (v0.5.3 §155).
|
|
1182
1182
|
- Backticks inside HTML comments — see §221i above (JS-spec footgun; not a parser bug).
|
|
1183
1183
|
|
|
1184
|
+
### §-TBD (v0.6.8) — Host element display defaults (FB-56)
|
|
1185
|
+
|
|
1186
|
+
Per the HTML spec, **all custom elements default to `display: inline`** — and `UIElement` subclasses are no exception. When you use a `UIElement` subclass as a layout container (filling a flex parent, holding an SVG canvas, wrapping a fluid grid), the inline default collapses the host to zero height and any `height: 100%` descendant collapses with it.
|
|
1187
|
+
|
|
1188
|
+
**Symptom you're hitting this trap:** content renders in DevTools with the expected DOM, but its computed height is 0 because the custom-element host between it and a layout parent has `display: inline`. Inspect the host element directly — `getComputedStyle(host).display === 'inline'` confirms.
|
|
1189
|
+
|
|
1190
|
+
If you author a `UIElement` to be a layout container, set an explicit `display` on the host tag — the in-tree convention is `:host(:where(*))` so consumer-app CSS can still override without `!important`:
|
|
1191
|
+
|
|
1192
|
+
```js
|
|
1193
|
+
import { css } from '@adia-ai/web-components/core/element';
|
|
1194
|
+
|
|
1195
|
+
class MyContainer extends UIElement {
|
|
1196
|
+
static styles = css`
|
|
1197
|
+
:host(:where(*)) { /* zero-specificity wrap; consumers override at (0,0,1) */
|
|
1198
|
+
display: flex;
|
|
1199
|
+
flex-direction: column;
|
|
1200
|
+
flex: 1 1 auto;
|
|
1201
|
+
min-height: 0;
|
|
1202
|
+
}
|
|
1203
|
+
`;
|
|
1204
|
+
static template = (host) => html`<slot></slot>`;
|
|
1205
|
+
}
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
Or, if the host should be transparent to layout (its children inherit the parent's grid/flex context directly):
|
|
1209
|
+
|
|
1210
|
+
```css
|
|
1211
|
+
my-passthrough-element { display: contents; }
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
See also: the slot-routing pass-through pattern that's the natural consumer of `display: contents` (cross-referenced from §345's `.map()` vs `repeat()` discussion where `wrap()` spans use the same mechanism).
|
|
1215
|
+
|
|
1216
|
+
**`UIElement` does not bake a `display` rule into the host** because container vs leaf vs passthrough is a consumer-layout decision, not a framework one. Per primitive, a global default would risk breaking layouts where the consumer explicitly wants `inline` semantics (e.g. inline badge inside flowing text, `<icon>` rendered alongside surrounding `<text-ui>`, `<kbd-ui>` keystroke marker). The in-tree primitive set spans `inline` / `inline-block` / `block` / `flex` / `grid` / `contents` — no single default fits.
|
|
1217
|
+
|
|
1218
|
+
**In-tree convention reference:** scan `packages/web-components/components/*/<name>.css` for the `:host(:where(*))` pattern. The 30+ flex-container primitives (`<editor-shell>`, `<nav>`, `<pane>`, `<page>`, `<card>`, etc.) all use this shape; copy from the closest-fit primitive.
|
|
1219
|
+
|
|
1220
|
+
### §-TBD (v0.6.8) — SVG composition + namespacing (FB-57)
|
|
1221
|
+
|
|
1222
|
+
`html\`\`` handles SVG content in three modes, with one historical trap closed in v0.6.8.
|
|
1223
|
+
|
|
1224
|
+
**Mode 1 — inline SVG in a single template (always worked):**
|
|
1225
|
+
|
|
1226
|
+
```js
|
|
1227
|
+
html`
|
|
1228
|
+
<svg viewBox="0 0 100 100">
|
|
1229
|
+
<circle cx="50" cy="50" r="10"/>
|
|
1230
|
+
<path d="M 0 0 L 100 100"/>
|
|
1231
|
+
</svg>
|
|
1232
|
+
`
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
When the parser sees `<svg>` in `tpl.innerHTML`, it enters SVG insertion mode for its descendants. Children arrive as proper `SVGElement` instances.
|
|
1236
|
+
|
|
1237
|
+
**Mode 2 — nested template interpolations (now works, post-v0.6.8 FB-57 fix):**
|
|
1238
|
+
|
|
1239
|
+
```js
|
|
1240
|
+
html`
|
|
1241
|
+
<svg viewBox="0 0 100 100">
|
|
1242
|
+
${dots.map(d => html`<circle cx="${d.x}" cy="${d.y}" r="3"/>`)}
|
|
1243
|
+
</svg>
|
|
1244
|
+
`
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
Each nested `html\`<circle/>\`` is a separate template parsed at document level (no SVG context), so pre-v0.6.8 the circles arrived as `HTMLUnknownElement` — present in the DOM but invisible to SVG layout (zero bounding rect, no stroke, no fill, no error). v0.6.8 `mount()` detects when the container is inside an SVG (or MathML) subtree and re-namespaces the cloned fragment via `createElementNS`. The natural composition pattern works.
|
|
1248
|
+
|
|
1249
|
+
**Mode 3 — imperative `.innerHTML` commit (escape hatch for very large SVG):**
|
|
1250
|
+
|
|
1251
|
+
For SVG content too large to template cleanly (thousands of nodes, dynamic shape strings):
|
|
1252
|
+
|
|
1253
|
+
```js
|
|
1254
|
+
class MyChart extends UIElement {
|
|
1255
|
+
static template = (host) => html`<svg viewBox="0 0 ${host.w} ${host.h}"></svg>`;
|
|
1256
|
+
updated() {
|
|
1257
|
+
const svg = this.querySelector('svg');
|
|
1258
|
+
svg.innerHTML = host._buildSvgString(); // SVG-namespaced via SVGSVGElement.innerHTML
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
`.innerHTML=${str}` as a template binding also works for this (the property name is already lowercase, so the FB-55 PROP_CASE_FIX entry just maps `innerhtml` → `innerHTML`, and the assignment routes through the SVGSVGElement's `innerHTML` setter which DOES enter SVG insertion mode).
|
|
1264
|
+
|
|
1265
|
+
**`<foreignObject>` boundary:** content inside `<foreignObject>` returns to HTML namespace per HTML5 §12.2.5 foreign-content insertion mode. The template engine respects this — interpolations inside `<foreignObject>` are HTML-namespaced even when the outer container is SVG. Use it as the documented escape hatch for HTML widgets embedded in SVG graphs (tooltips, popovers, foreign Cells in dataviz).
|
|
1266
|
+
|
|
1267
|
+
**MathML** uses the same mechanism: nested `${html\`<mi/>\`}` inside `<math>` gets re-namespaced to MathML NS. `<annotation-xml encoding="text/html">` is the MathML equivalent of `<foreignObject>` for HTML re-entry.
|
|
1268
|
+
|
|
1269
|
+
**Migration:** zero. Pre-v0.6.8 code that already worked (inline SVG + `.innerHTML` commits) keeps working. Code that was silently broken (nested SVG interpolations) starts producing visible output.
|
|
1270
|
+
|
|
1271
|
+
### §-TBD (v0.6.8) — `.camelCaseProp=${expr}` property binding (FB-55)
|
|
1272
|
+
|
|
1273
|
+
Pre-v0.6.8 trap: the HTML parser lowercases attribute names inside `<template>.innerHTML` per HTML5 §13.2.5.32, so `.className=${expr}` arrived at the binding scanner as `.classname`. The binding then wrote `element['classname'] = v` — a lowercase enumerable **expando** — never invoking the `HTMLElement.prototype.className` setter. CSS classes never applied; no warning, no error.
|
|
1274
|
+
|
|
1275
|
+
Same trap affected every camelCase DOM property surface: `.className`, `.innerText`, `.tabIndex`, `.ariaLabel`, `.contentEditable`, `.readOnly`, `.maxLength`, `.minLength`, `.colSpan`, `.rowSpan` — AND every camelCase property declared via `UIElement.static properties` (e.g. `<color-picker>`'s `.maxChroma`, `<grid>`'s `.columnGap`, `<breadcrumb>`'s `.collapseKeepLeading`, plus 80+ others across primitives). Even the canonical reactive-binding example from `README.md` (`<my-panel .minL=${minL}>`) silently failed.
|
|
1276
|
+
|
|
1277
|
+
v0.6.8 fix: a two-layer name resolution in `core/template.js`:
|
|
1278
|
+
|
|
1279
|
+
1. **`PROP_CASE_FIX` static map** at scan time covers built-in DOM camelCase properties (zero-cost lookup, no prototype walk for the common case).
|
|
1280
|
+
2. **Prototype-walk fallback** at `applyValue()` time finds canonical camelCase property names on the element's instance + prototype chain via case-insensitive match — caches the resolution on the part after first hit. Catches UIElement custom-element props installed per-instance in `installProps()`.
|
|
1281
|
+
|
|
1282
|
+
```js
|
|
1283
|
+
// ✅ Works post-v0.6.8 — both forms route to the canonical setter
|
|
1284
|
+
html`<span .className=${'foo bar'}>text</span>`;
|
|
1285
|
+
html`<color-picker .maxChroma=${0.3}></color-picker>`;
|
|
1286
|
+
html`<my-panel .minL=${minL}></my-panel>`;
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
**Backward compatibility:** when neither the map nor the prototype walk finds a case-insensitive match, the original lowercase name is preserved. Consumers deliberately writing to lowercase expando properties (`.myexpando=${v}`) are unaffected.
|
|
1290
|
+
|
|
1291
|
+
**Discoverability note:** the simpler form `class="${expr}"` (full-attribute interpolation) works without needing to know the camelCase property name and is the v0.6.8 partial-interp warn-text's first recommendation. Reach for `.className=` when you specifically want the property-binding semantics (e.g., when the value carries non-string types, or for performance in tight inner loops).
|
|
1292
|
+
|
|
1184
1293
|
### §345 (v0.5.19) — `.map()` vs `repeat()` for reactive lists (FB-47)
|
|
1185
1294
|
|
|
1186
1295
|
Use **`repeat(items, keyFn, tplFn)`** for any list whose items are signal-driven and should preserve identity across re-renders. Plain `.map()` returns an `Array` of template results, which the template engine materializes via `container.replaceChildren()` on every parent update — correct, but per-item DOM is re-created from scratch.
|
|
@@ -1277,6 +1386,83 @@ Use the descendant combinator (a single space) instead, which traverses any dept
|
|
|
1277
1386
|
|
|
1278
1387
|
Same caveat applies to `:nth-child()` / `:nth-of-type()` / `:first-child` / `:last-child` against the parent — those count actual children (the wrapper-spans), not the items inside. If you need nth-item styling, key it via a data attribute on the item itself rather than relying on positional pseudo-classes against the wrapper layer.
|
|
1279
1388
|
|
|
1389
|
+
### §-TBD (v0.6.8) — UIElement lifecycle hooks (FB-59 + FB-43/44/47/58 cluster)
|
|
1390
|
+
|
|
1391
|
+
UIElement orchestrates the custom-element lifecycle and exposes two extension points: the W3C standard `connectedCallback()` (rarely overridden) and the convenience **`connected()` hook (the recommended subclass extension point)**. Same pattern for `disconnected()`.
|
|
1392
|
+
|
|
1393
|
+
#### Canonical hook order
|
|
1394
|
+
|
|
1395
|
+
For a UIElement subclass with `static template = () => html\`<x/>\`` mounted into the DOM with consumer-supplied light-DOM children:
|
|
1396
|
+
|
|
1397
|
+
1. **`adoptStyles(ctor)`** — adopted stylesheets attached to the document
|
|
1398
|
+
2. **`prepareParts(ctor)`** — `static template` markup parsed into the part registry
|
|
1399
|
+
3. **`this.connected()`** — subclass hook fires here, inside `untracked(...)`. **Light-DOM children supplied by the parent template are intact at this point.**
|
|
1400
|
+
4. **Effect first-run** — `stamp(result, this)` runs here if `ctor.template(this)` returns non-null. This is the call that **wipes consumer-supplied light-DOM children** (via `mount()` → `container.replaceChildren(f)`).
|
|
1401
|
+
5. **`this.render()`** — explicit render hook for imperative DOM updates
|
|
1402
|
+
6. **`this.updated(changedProps)`** — change-set hook for property-specific reactions
|
|
1403
|
+
|
|
1404
|
+
**The key insight for container components: `connected()` runs BEFORE the stamp.** If your container UIElement has both a non-null `static template` AND wants to capture consumer-supplied light-DOM children, do the capture in `connected()`. The wipe runs in step 4, which is after step 3.
|
|
1405
|
+
|
|
1406
|
+
Source: `packages/web-components/core/element.js:107-142` — `UIElement.connectedCallback()`. `connected()` fires at line 122 inside `untracked(...)`; the stamp runs at line 127 inside the effect registered at lines 123-137.
|
|
1407
|
+
|
|
1408
|
+
#### Container UIElement pattern (FB-43/44/47/58 cluster — the right shape)
|
|
1409
|
+
|
|
1410
|
+
When your UIElement is a container for consumer-supplied light-DOM children AND has its own internal `static template`, capture the children in `connected()`:
|
|
1411
|
+
|
|
1412
|
+
```js
|
|
1413
|
+
class DtsGraph extends UIElement {
|
|
1414
|
+
static template = () => html`<div class="dts-graph-root"></div>`;
|
|
1415
|
+
|
|
1416
|
+
// ✅ connected() runs BEFORE the stamp wipes children — capture here
|
|
1417
|
+
connected() {
|
|
1418
|
+
this._series = [...this.querySelectorAll('dts-graph-band, dts-graph-curve, …')];
|
|
1419
|
+
|
|
1420
|
+
// Optional: MutationObserver for late-arriving children (e.g. parent template
|
|
1421
|
+
// toggles `${cond ? html\`<band/>\` : ''}` and the new band lands later)
|
|
1422
|
+
this._mo = new MutationObserver(() => this._captureSeries());
|
|
1423
|
+
this._mo.observe(this, { childList: true, subtree: true });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
_captureSeries() {
|
|
1427
|
+
const present = [...this.querySelectorAll('dts-graph-band, …')];
|
|
1428
|
+
// Defensive cache-fallback: post-stamp wipe makes `present` empty, but the
|
|
1429
|
+
// initial capture from connected() above already saved the refs. Keep them.
|
|
1430
|
+
if (present.length > 0) this._series = present;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
disconnected() {
|
|
1434
|
+
this._mo?.disconnect();
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
The `if (present.length > 0) keep` cache-fallback is the architectural pattern that makes the capture robust to either ordering. It works in any lifecycle environment with no specification dependency.
|
|
1440
|
+
|
|
1441
|
+
#### `connected()` vs `connectedCallback()` reference
|
|
1442
|
+
|
|
1443
|
+
| Hook | Who calls it | When (relative to step list above) | Use case |
|
|
1444
|
+
|---|---|---|---|
|
|
1445
|
+
| `connectedCallback()` | The browser, per W3C custom-elements spec | Orchestrates the whole sequence | Almost never override — UIElement runs the lifecycle for you |
|
|
1446
|
+
| **`connected()`** | UIElement, from within `connectedCallback()` at line 122 | Step 3 — after `prepareParts()`, BEFORE the template stamp | **Subclass setup, light-DOM capture, observer arming, signal effects** |
|
|
1447
|
+
| `render()` | The effect, after every signal change | Step 5 — after `stamp()` if the template returned non-null | Imperative DOM updates that don't fit the template engine |
|
|
1448
|
+
| `updated(changedProps)` | The effect | Step 6 — after `render()`, when properties changed | React to specific prop changes |
|
|
1449
|
+
| `disconnected()` | UIElement, from `disconnectedCallback()` | Element leaves the DOM | Teardown observers, timers, listeners |
|
|
1450
|
+
|
|
1451
|
+
#### When NOT to override `connectedCallback()` directly
|
|
1452
|
+
|
|
1453
|
+
`UIElement.connectedCallback()` orchestrates the entire lifecycle. Overriding it directly requires re-invoking `super.connectedCallback()` correctly, and you'd have to choose whether your work goes before or after super:
|
|
1454
|
+
|
|
1455
|
+
- **BEFORE super**: you miss the `prepareParts(ctor)` call that initializes the part registry. `static template` won't stamp. Other downstream initialization breaks.
|
|
1456
|
+
- **AFTER super**: your work runs after step 6 (`updated()`), which means after the stamp wiped your light-DOM children. You'd need to capture in `constructor()` instead — but `constructor()` runs before the element is in the DOM, so light-DOM children aren't appended yet.
|
|
1457
|
+
|
|
1458
|
+
`connected()` is the right hook for 99% of consumer code. It runs at exactly the right time: children present, stamp not yet run.
|
|
1459
|
+
|
|
1460
|
+
#### Why `connected()` is wrapped in `untracked()`
|
|
1461
|
+
|
|
1462
|
+
Per the comment in `element.js:112-121`: `connected()` commonly reads reactive properties via template-literal interpolation (`${this.label}` etc.) when stamping inner DOM. If the outer call path is inside another effect (e.g. a parent's render loop `appendChild`'ing this node), those reads would subscribe the outer effect to this element's signals — invisible cross-element coupling that looks like spooky re-runs on unrelated state changes. The `untracked()` wrapper prevents that subscription leak. The element's own effect (registered at lines 123-137) re-reads the same signals in its own tracking context, so reactivity within the element is unaffected.
|
|
1463
|
+
|
|
1464
|
+
This means: inside `connected()`, you CAN read signals/props for setup logic, but those reads won't establish reactive subscriptions. Use the element's own `render()` or signal effects for reactive work; use `connected()` for one-shot setup.
|
|
1465
|
+
|
|
1280
1466
|
### §221j — Typography token cheatsheet
|
|
1281
1467
|
|
|
1282
1468
|
Quick-reference for component-CSS authoring. Cross-reference [`styles/typography.css`](./styles/typography.css):
|
package/core/template.js
CHANGED
|
@@ -91,6 +91,106 @@ function getTemplate(strings) {
|
|
|
91
91
|
return tpl;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// §-TBD (FB-55, P1): the HTML parser lowercases attribute names inside
|
|
95
|
+
// <template>.innerHTML per HTML5 §13.2.5.32, so `.className=${expr}`
|
|
96
|
+
// arrives at scan() as `.classname`. The `name.slice(1)` strips the dot
|
|
97
|
+
// and the binding writes to a lowercase enumerable expando instead of
|
|
98
|
+
// invoking the camelCase property setter. Net: classes never apply, no
|
|
99
|
+
// warning, no error.
|
|
100
|
+
//
|
|
101
|
+
// The trap affects every camelCase DOM property surface — `.className`,
|
|
102
|
+
// `.innerText`, `.tabIndex`, `.ariaLabel`, `.contentEditable`,
|
|
103
|
+
// `.readOnly`, `.maxLength`, `.minLength`, `.colSpan`, `.rowSpan` — AND
|
|
104
|
+
// every camelCase property declared via `UIElement.static properties`
|
|
105
|
+
// (e.g. `.minL`, `.maxChroma`, `.hueDriftMax`, `.colorScheme`,
|
|
106
|
+
// `.strokeWidth`, `.collapseKeepLeading`). Even the canonical example
|
|
107
|
+
// `<my-panel .minL=${minL}>` from packages/web-components/README.md:212
|
|
108
|
+
// silently failed pre-fix.
|
|
109
|
+
//
|
|
110
|
+
// Two-layer fix:
|
|
111
|
+
//
|
|
112
|
+
// 1. Static PROP_CASE_FIX map covers built-in DOM camelCase property
|
|
113
|
+
// names (zero-cost lookup, no prototype walk per binding for the
|
|
114
|
+
// common case).
|
|
115
|
+
//
|
|
116
|
+
// 2. Prototype-walk fallback finds the canonical camelCase property
|
|
117
|
+
// name on the element's prototype chain via case-insensitive
|
|
118
|
+
// match. Cost is O(prototype-chain-depth × prototype-key-count)
|
|
119
|
+
// per UNIQUE template binding (cached in parts[i].name, not
|
|
120
|
+
// re-walked per update() tick). Custom-element prototype chains
|
|
121
|
+
// are short (subclass → UIElement → HTMLElement → Element → Node
|
|
122
|
+
// → EventTarget); cost is negligible.
|
|
123
|
+
//
|
|
124
|
+
// Backward-compatible: when the prototype walk finds no case-insensitive
|
|
125
|
+
// match (genuine lowercase-expando case — rare but possible), the
|
|
126
|
+
// original lowercase name is preserved. No consumer that was
|
|
127
|
+
// deliberately writing to a lowercase expando regresses.
|
|
128
|
+
//
|
|
129
|
+
// Verification: 8-test matrix in core/template.test.js's FB-55 describe
|
|
130
|
+
// block. RESPONSE-55 documents the trap end-to-end.
|
|
131
|
+
const PROP_CASE_FIX = {
|
|
132
|
+
classname: 'className',
|
|
133
|
+
innertext: 'innerText',
|
|
134
|
+
innerhtml: 'innerHTML',
|
|
135
|
+
outerhtml: 'outerHTML',
|
|
136
|
+
textcontent: 'textContent',
|
|
137
|
+
tabindex: 'tabIndex',
|
|
138
|
+
arialabel: 'ariaLabel',
|
|
139
|
+
ariadescribedby: 'ariaDescribedBy',
|
|
140
|
+
arialabelledby: 'ariaLabelledBy',
|
|
141
|
+
ariarole: 'role',
|
|
142
|
+
contenteditable: 'contentEditable',
|
|
143
|
+
readonly: 'readOnly',
|
|
144
|
+
maxlength: 'maxLength',
|
|
145
|
+
minlength: 'minLength',
|
|
146
|
+
colspan: 'colSpan',
|
|
147
|
+
rowspan: 'rowSpan',
|
|
148
|
+
cellpadding: 'cellPadding',
|
|
149
|
+
cellspacing: 'cellSpacing',
|
|
150
|
+
usemap: 'useMap',
|
|
151
|
+
ismap: 'isMap',
|
|
152
|
+
accesskey: 'accessKey',
|
|
153
|
+
defaultchecked: 'defaultChecked',
|
|
154
|
+
defaultvalue: 'defaultValue',
|
|
155
|
+
defaultselected: 'defaultSelected',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Resolve a lowercase property name to its canonical camelCase form on
|
|
159
|
+
// the given element. Returns the original `lower` name when no
|
|
160
|
+
// case-insensitive match exists (preserves expando semantics). Walks
|
|
161
|
+
// instance own-properties first (UIElement installs camelCase props via
|
|
162
|
+
// `Object.defineProperty(this, ...)` in the constructor, so they live
|
|
163
|
+
// on the instance, not the prototype), then prototype chain. Skips
|
|
164
|
+
// Object.prototype to avoid noisy matches on `toString` etc.
|
|
165
|
+
//
|
|
166
|
+
// IMPORTANT: at scan() time the custom element has typically NOT yet
|
|
167
|
+
// been constructed (template fragment is cloned + scanned before
|
|
168
|
+
// insertion → upgrade), so instance-defined props aren't visible yet.
|
|
169
|
+
// PROP_CASE_FIX covers built-in DOM camelCase props at scan() time;
|
|
170
|
+
// the per-element walk runs ALSO at applyValue() time (lazy resolve,
|
|
171
|
+
// cached on the part after first hit) to catch UIElement instance props
|
|
172
|
+
// once the element has been upgraded.
|
|
173
|
+
function resolvePropName(el, lower) {
|
|
174
|
+
if (PROP_CASE_FIX[lower]) return PROP_CASE_FIX[lower];
|
|
175
|
+
// Instance own-properties (UIElement installProps lives here).
|
|
176
|
+
const ownNames = Object.getOwnPropertyNames(el);
|
|
177
|
+
for (let i = 0; i < ownNames.length; i++) {
|
|
178
|
+
const n = ownNames[i];
|
|
179
|
+
if (n !== lower && n.toLowerCase() === lower) return n;
|
|
180
|
+
}
|
|
181
|
+
// Prototype chain (built-in DOM camelCase + class-defined accessors).
|
|
182
|
+
let proto = Object.getPrototypeOf(el);
|
|
183
|
+
while (proto && proto !== Object.prototype) {
|
|
184
|
+
const names = Object.getOwnPropertyNames(proto);
|
|
185
|
+
for (let i = 0; i < names.length; i++) {
|
|
186
|
+
const n = names[i];
|
|
187
|
+
if (n !== lower && n.toLowerCase() === lower) return n;
|
|
188
|
+
}
|
|
189
|
+
proto = Object.getPrototypeOf(proto);
|
|
190
|
+
}
|
|
191
|
+
return lower;
|
|
192
|
+
}
|
|
193
|
+
|
|
94
194
|
export function stamp(result, container) {
|
|
95
195
|
let inst = container._i;
|
|
96
196
|
if (!inst || inst.s !== result.strings) {
|
|
@@ -104,12 +204,118 @@ export function stamp(result, container) {
|
|
|
104
204
|
function mount(result, container) {
|
|
105
205
|
const { strings } = result;
|
|
106
206
|
const tpl = getTemplate(strings);
|
|
107
|
-
|
|
207
|
+
let f = tpl.content.cloneNode(true);
|
|
208
|
+
|
|
209
|
+
// §-TBD (FB-57, P1): when stamping into an SVG or MathML context, the
|
|
210
|
+
// fragment's Elements arrive HTML-namespaced because `tpl.innerHTML =
|
|
211
|
+
// m` is parsed at document level without foreign-content context. SVG
|
|
212
|
+
// and MathML layout are namespace-strict — HTMLUnknownElement nodes
|
|
213
|
+
// inside an SVG render to zero-bound-rect invisible. Re-namespace the
|
|
214
|
+
// fragment before insertion, respecting `<foreignObject>` and
|
|
215
|
+
// `<annotation-xml>` HTML boundaries per the HTML5 foreign-content
|
|
216
|
+
// insertion-mode spec.
|
|
217
|
+
//
|
|
218
|
+
// Discriminator runs once per mount() call (no per-update cost).
|
|
219
|
+
// Re-namespacing walks the fragment recursively (O(node-count)),
|
|
220
|
+
// cheap for typical interpolation arrays (16 lines + 11 stops in
|
|
221
|
+
// the canonical curve-preview case = 27 createElementNS calls).
|
|
222
|
+
//
|
|
223
|
+
// Implementation site choice: mount() rather than getTemplate(),
|
|
224
|
+
// because:
|
|
225
|
+
// - container reference is in hand only at mount()
|
|
226
|
+
// - the same cached template may be stamped into either context;
|
|
227
|
+
// namespacing per-cache-entry would double the WeakMap size
|
|
228
|
+
// and require a cache-key extension
|
|
229
|
+
// - cloning is already happening here; re-namespacing piggybacks
|
|
230
|
+
// on the existing fragment walk
|
|
231
|
+
//
|
|
232
|
+
// RESPONSE-57 documents the trap end-to-end. Regression tests pin
|
|
233
|
+
// the inline-SVG path (must keep working), nested-SVG path (would
|
|
234
|
+
// fail pre-fix), foreignObject boundary (HTML inside SVG stays
|
|
235
|
+
// HTML), and MathML parallel.
|
|
236
|
+
const ns = foreignContentNS(container);
|
|
237
|
+
if (ns) f = renamespaceFragment(f, ns);
|
|
238
|
+
|
|
108
239
|
const parts = scan(f, result.values.length);
|
|
109
240
|
container.replaceChildren(f);
|
|
110
241
|
return { s: strings, p: parts };
|
|
111
242
|
}
|
|
112
243
|
|
|
244
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
245
|
+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
|
|
246
|
+
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
247
|
+
|
|
248
|
+
// Determine whether `container` is inside an SVG or MathML subtree, and
|
|
249
|
+
// return the namespace to re-namespace stamped fragments into. Returns
|
|
250
|
+
// null when the container is in HTML context (the no-op case — vast
|
|
251
|
+
// majority of templates). Respects `<foreignObject>` (SVG) and
|
|
252
|
+
// `<annotation-xml encoding=text/html|application/xhtml+xml>` (MathML)
|
|
253
|
+
// HTML boundaries per HTML5 §12.2.5 foreign-content insertion mode.
|
|
254
|
+
function foreignContentNS(container) {
|
|
255
|
+
if (!container || container.nodeType !== 1) return null;
|
|
256
|
+
// Direct check — container's own namespace is a fast-path.
|
|
257
|
+
if (container.namespaceURI === SVG_NS) return SVG_NS;
|
|
258
|
+
if (container.namespaceURI === MATHML_NS) return MATHML_NS;
|
|
259
|
+
// Walk ancestors to find a foreign-content root. `closest()` matches
|
|
260
|
+
// the element itself + ancestors, so this catches the wrap()-span
|
|
261
|
+
// case (HTML span inside an SVG subtree). The first match wins —
|
|
262
|
+
// foreignObject/annotation-xml inside SVG short-circuits before we
|
|
263
|
+
// see the outer <svg>, correctly returning null (HTML context).
|
|
264
|
+
if (typeof container.closest !== 'function') return null;
|
|
265
|
+
const anchor = container.closest('svg, math, foreignObject, annotation-xml');
|
|
266
|
+
if (!anchor) return null;
|
|
267
|
+
const tag = anchor.localName;
|
|
268
|
+
if (tag === 'foreignobject' || tag === 'foreignObject') return null;
|
|
269
|
+
if (tag === 'annotation-xml') {
|
|
270
|
+
const enc = (anchor.getAttribute('encoding') || '').toLowerCase();
|
|
271
|
+
if (enc === 'text/html' || enc === 'application/xhtml+xml') return null;
|
|
272
|
+
return MATHML_NS;
|
|
273
|
+
}
|
|
274
|
+
if (tag === 'svg') return SVG_NS;
|
|
275
|
+
if (tag === 'math') return MATHML_NS;
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Recursively transfer fragment children into a new fragment with each
|
|
280
|
+
// Element recreated in the target namespace. Text/comment nodes clone
|
|
281
|
+
// unchanged. `<foreignObject>` and `<annotation-xml encoding=text/html>`
|
|
282
|
+
// stay in the target namespace themselves, but their descendants revert
|
|
283
|
+
// to HTML (matches the browser parser's foreign-content insertion mode).
|
|
284
|
+
function renamespaceFragment(fragment, ns) {
|
|
285
|
+
const out = document.createDocumentFragment();
|
|
286
|
+
transferChildren(fragment, out, ns);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function transferChildren(src, dstParent, ns) {
|
|
291
|
+
for (const child of [...src.childNodes]) {
|
|
292
|
+
const t = child.nodeType;
|
|
293
|
+
if (t === 1 /* Element */) {
|
|
294
|
+
const dst = document.createElementNS(ns, child.localName);
|
|
295
|
+
for (const a of child.attributes) {
|
|
296
|
+
if (a.namespaceURI) dst.setAttributeNS(a.namespaceURI, a.name, a.value);
|
|
297
|
+
else dst.setAttribute(a.name, a.value);
|
|
298
|
+
}
|
|
299
|
+
dstParent.appendChild(dst);
|
|
300
|
+
// Boundary handling: <foreignObject> (SVG) and
|
|
301
|
+
// <annotation-xml encoding=text/html> (MathML) revert to HTML.
|
|
302
|
+
const local = child.localName;
|
|
303
|
+
let childNs = ns;
|
|
304
|
+
if (ns === SVG_NS && (local === 'foreignObject' || local === 'foreignobject')) {
|
|
305
|
+
childNs = XHTML_NS;
|
|
306
|
+
} else if (ns === MATHML_NS && local === 'annotation-xml') {
|
|
307
|
+
const enc = (child.getAttribute('encoding') || '').toLowerCase();
|
|
308
|
+
if (enc === 'text/html' || enc === 'application/xhtml+xml') childNs = XHTML_NS;
|
|
309
|
+
}
|
|
310
|
+
transferChildren(child, dst, childNs);
|
|
311
|
+
} else {
|
|
312
|
+
// Text, comment, etc. — clone unchanged. Comments are load-bearing
|
|
313
|
+
// (the `<!--p:N-->` placeholders scan() looks for).
|
|
314
|
+
dstParent.appendChild(child.cloneNode(true));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
113
319
|
function scan(fragment, count) {
|
|
114
320
|
const parts = new Array(count);
|
|
115
321
|
const w = document.createTreeWalker(fragment, 129);
|
|
@@ -146,14 +352,21 @@ function scan(fragment, count) {
|
|
|
146
352
|
// canonical style-object path; for everything else keep
|
|
147
353
|
// the generic property-assignment recipe.
|
|
148
354
|
const a = attr.name;
|
|
355
|
+
// §-TBD (FB-55 #2, P1): updated post-fix. The v0.5.5 §184
|
|
356
|
+
// recommendation had `.className=${expression}` first;
|
|
357
|
+
// that path now works (§-TBD PROP_CASE_FIX above) but
|
|
358
|
+
// `class="${expression}"` is the more discoverable form
|
|
359
|
+
// that doesn't require knowing the camelCase property
|
|
360
|
+
// name. Promoting it first. The `.classList=` line is
|
|
361
|
+
// removed entirely — classList is a read-only getter, no
|
|
362
|
+
// PROP_CASE_FIX entry can make it assignable.
|
|
149
363
|
const specific =
|
|
150
364
|
a === 'class'
|
|
151
|
-
? `
|
|
152
|
-
`
|
|
153
|
-
` .classList=\${{foo: true, bar: false}} ← if/when implemented; verify in USAGE.md\n`
|
|
365
|
+
? ` class="\${expression}" ← full replacement (whole class string is the expression)\n` +
|
|
366
|
+
` .className=\${expression} ← writes to the className property (resolved camelCase since FB-55 fix)\n`
|
|
154
367
|
: a === 'style'
|
|
155
|
-
? `
|
|
156
|
-
` style
|
|
368
|
+
? ` style="\${expression}" ← full replacement of the style string\n` +
|
|
369
|
+
` .style.cssText=\${expression} ← write CSS text via the style.cssText accessor\n`
|
|
157
370
|
: ` ${a}="\${expression}" ← full replacement (whole attr is the placeholder)\n` +
|
|
158
371
|
` .${a}=\${expression} ← property assignment (preferred for objects/functions)\n`;
|
|
159
372
|
// eslint-disable-next-line no-console
|
|
@@ -173,8 +386,12 @@ function scan(fragment, count) {
|
|
|
173
386
|
n.removeAttribute(name);
|
|
174
387
|
parts[i] = { t: 'e', n, name: name.slice(1), c: undefined, _fx: null };
|
|
175
388
|
} else if (name[0] === '.') {
|
|
389
|
+
// §-TBD (FB-55, P1): resolve the parser-lowercased property
|
|
390
|
+
// name back to its canonical camelCase via PROP_CASE_FIX +
|
|
391
|
+
// prototype walk. See PROP_CASE_FIX comment above for context.
|
|
392
|
+
// Lazy-resolve in applyValue() too — see _resolved guard there.
|
|
176
393
|
n.removeAttribute(name);
|
|
177
|
-
parts[i] = { t: 'p', n, name: name.slice(1), c: undefined, _fx: null };
|
|
394
|
+
parts[i] = { t: 'p', n, name: resolvePropName(n, name.slice(1)), c: undefined, _fx: null, _resolved: false };
|
|
178
395
|
} else if (name[0] === '?') {
|
|
179
396
|
// §250 (v0.5.11, FEEDBACK-27): Lit-style boolean attribute syntax
|
|
180
397
|
// (`?attr=${bool}`) is NOT supported. Without this branch, the
|
|
@@ -256,6 +473,18 @@ function applyValue(p, v) {
|
|
|
256
473
|
if (v == null || v === false) p.n.removeAttribute(p.name);
|
|
257
474
|
else p.n.setAttribute(p.name, v === true ? '' : v);
|
|
258
475
|
} else if (p.t === 'p') {
|
|
476
|
+
// §-TBD (FB-55, P1): two-phase property-name resolution. PROP_CASE_FIX
|
|
477
|
+
// resolved at scan() time for zero-cost built-in DOM camelCase (the
|
|
478
|
+
// common case). For UIElement custom-element props installed on the
|
|
479
|
+
// INSTANCE via the constructor's installProps(), the property doesn't
|
|
480
|
+
// exist at scan() time (element not yet upgraded), so a lazy lookup
|
|
481
|
+
// here catches them. Resolution is cached back into p.name on first
|
|
482
|
+
// hit — subsequent updates skip the walk.
|
|
483
|
+
if (!p._resolved) {
|
|
484
|
+
const resolved = resolvePropName(p.n, p.name);
|
|
485
|
+
if (resolved !== p.name) p.name = resolved;
|
|
486
|
+
p._resolved = true;
|
|
487
|
+
}
|
|
259
488
|
p.n[p.name] = v;
|
|
260
489
|
} else if (p.t === 'e') {
|
|
261
490
|
if (isHandler(p.c)) p.n.removeEventListener(p.name, p.c);
|
package/core/template.test.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.6.8",
|
|
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",
|