@adia-ai/web-components 0.5.14 → 0.5.16

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.
@@ -218,14 +218,44 @@ export class UISwatch extends UIElement {
218
218
  // survive stamping as direct host siblings, NOT funnel into #labelEl where
219
219
  // absolute-positioning anchors against the label text bbox. Pull them out
220
220
  // FIRST so the innerHTML='' wipe below doesn't lose them.
221
+ //
222
+ // §331 (v0.5.16, FB-39): also walk `display: contents` wrapper spans
223
+ // inserted by AdiaUI's `html\`\`` template engine (`core/template.js`
224
+ // `wrap()` at line 212). When the parent template interpolates child
225
+ // fragments via `${badgeFrag}` / `${dotFrag}`, each fragment lands
226
+ // INSIDE a wrapper span that's a direct child of <swatch-ui>; the
227
+ // chrome elements end up as GRANDCHILDREN, not direct children. The
228
+ // pre-§331 direct-children-only filter found `chromeSlot = []` →
229
+ // innerHTML='' wiped the wrapper spans + their chrome content →
230
+ // `#absorbChromeSlot()` in `#syncCore()` had nothing left to absorb.
231
+ // Deep-walk via querySelectorAll catches both shapes:
232
+ // - static-HTML / pre-template: direct-child <span slot="chrome">
233
+ // - template-render: <span slot="chrome"> nested in wrapper span
221
234
  const chromeSlot = Array.from(this.children).filter(
222
235
  n => n.getAttribute && n.getAttribute('slot') === 'chrome'
223
236
  );
237
+ // Also collect chrome elements nested inside wrapper-spans (template-render path).
238
+ for (const wrapper of Array.from(this.children)) {
239
+ if (wrapper.getAttribute?.('slot') === 'chrome') continue;
240
+ const nested = wrapper.querySelectorAll?.('[slot="chrome"]');
241
+ if (nested) for (const n of nested) chromeSlot.push(n);
242
+ }
224
243
  for (const n of chromeSlot) n.remove();
225
244
 
226
245
  // Capture pre-existing default-slot content so consumer-authored
227
246
  // children (e.g. <swatch-ui>Forecast</swatch-ui>) survive stamping.
228
247
  // `[slot="chrome"]` children are already removed above, so won't appear here.
248
+ //
249
+ // §325 (v0.5.16, FB-36): filter pure-whitespace text nodes from the
250
+ // capture. Pre-§325, indented multi-line HTML (`<swatch-ui
251
+ // label="500">\n <span>tip</span>\n</swatch-ui>`) captured the
252
+ // leading whitespace text node, which then made #labelEl.firstChild
253
+ // a nodeType=3 text node. #syncCore()'s "stale-text-node" branch
254
+ // matched the whitespace + did textContent = this.label, wiping
255
+ // BOTH the whitespace AND the consumer's <span>. Filter here so
256
+ // #labelEl.firstChild is the actual element/non-whitespace text,
257
+ // and #syncCore's stale-text branch only fires for genuinely
258
+ // attr-driven cases.
229
259
  const slotted = Array.from(this.childNodes).filter(n =>
230
260
  !(n.nodeType === 1 && n.dataset && (
231
261
  n.dataset.tile !== undefined ||
@@ -233,7 +263,7 @@ export class UISwatch extends UIElement {
233
263
  n.dataset.detail !== undefined ||
234
264
  n.dataset.badge !== undefined ||
235
265
  n.dataset.copy !== undefined
236
- ))
266
+ )) && !(n.nodeType === 3 && !n.textContent.trim())
237
267
  );
238
268
  this.innerHTML = '';
239
269
 
@@ -282,9 +312,70 @@ export class UISwatch extends UIElement {
282
312
 
283
313
  // ── Sync ──────────────────────────────────────────────────────────
284
314
 
315
+ /**
316
+ * §326 (v0.5.16, FB-37): Late-arriving `[slot="chrome"]` children may
317
+ * land on the host AFTER `#stamp()` runs — happens in any template
318
+ * engine (lit-html, AdiaUI `html\`\``, etc.) that batches child appends
319
+ * separately from element creation. `#stamp()` is gated by
320
+ * `this.#stamped` and won't re-run; this method picks up the late
321
+ * arrivals on every `render()` pass + moves them to the canonical
322
+ * sibling-of-tile position (between `#tileEl` and `#badgeEl`).
323
+ *
324
+ * Idempotent: chrome children already in the right slot are left
325
+ * alone (the `insertBefore` only moves them if they're not the
326
+ * #badgeEl's immediate previousSibling chain).
327
+ */
328
+ #absorbChromeSlot() {
329
+ if (!this.#tileEl || !this.#badgeEl) return;
330
+ const chromeChildren = [];
331
+ for (const child of this.children) {
332
+ if (child === this.#tileEl) continue;
333
+ if (child === this.#badgeEl) break; // hit the badge — done scanning the head region
334
+ if (child.getAttribute?.('slot') === 'chrome') chromeChildren.push(child);
335
+ }
336
+ // Walk the rest of the children looking for chrome children that landed
337
+ // AFTER the internal structure (the common template-late-append case).
338
+ for (const child of Array.from(this.children)) {
339
+ if (
340
+ child !== this.#tileEl &&
341
+ child !== this.#badgeEl &&
342
+ child !== this.#labelEl &&
343
+ child !== this.#detailEl &&
344
+ child !== this.#copyEl &&
345
+ child.getAttribute?.('slot') === 'chrome'
346
+ ) {
347
+ // Move to the canonical position: immediately after #tileEl.
348
+ // insertBefore is idempotent — moving an already-positioned child
349
+ // to the same spot is a no-op.
350
+ this.insertBefore(child, this.#badgeEl);
351
+ }
352
+ }
353
+ // §331 (v0.5.16, FB-39): also handle wrapper-spanned chrome children
354
+ // that arrive AFTER #stamp() (parent template's late update() pass).
355
+ // Hoist them OUT of any non-internal wrapper + into the canonical slot.
356
+ for (const child of Array.from(this.children)) {
357
+ if (
358
+ child === this.#tileEl ||
359
+ child === this.#badgeEl ||
360
+ child === this.#labelEl ||
361
+ child === this.#detailEl ||
362
+ child === this.#copyEl
363
+ ) continue;
364
+ const nested = child.querySelectorAll?.('[slot="chrome"]');
365
+ if (!nested || !nested.length) continue;
366
+ for (const n of nested) {
367
+ this.insertBefore(n, this.#badgeEl);
368
+ }
369
+ }
370
+ }
371
+
285
372
  #syncCore() {
286
373
  if (!this.#tileEl || !this.#labelEl) return;
287
374
 
375
+ // §326 (v0.5.16, FB-37): pick up `[slot="chrome"]` children that
376
+ // landed after #stamp() ran (template-render path).
377
+ this.#absorbChromeSlot();
378
+
288
379
  // Normalize enum values; fall back silently when a consumer sets a typo.
289
380
  const shape = SHAPES.has(this.shape) ? this.shape : 'square';
290
381
  const size = SIZES.has(this.size) ? this.size : 'md';
@@ -291,7 +291,7 @@
291
291
  line-height: 1;
292
292
  cursor: pointer;
293
293
  border-radius: var(--a-radius-xs);
294
- transition: color 100ms ease-out, background 100ms ease-out;
294
+ transition: color var(--a-duration-fast) var(--a-easing-out), background var(--a-duration-fast) var(--a-easing-out);
295
295
  }
296
296
  :scope > [data-copy]:hover { color: var(--a-fg); background: var(--a-bg-muted); }
297
297
  :scope > [data-copy]:focus-visible {
@@ -308,7 +308,7 @@
308
308
  :scope[selectable] {
309
309
  cursor: pointer;
310
310
  border-radius: var(--a-radius-sm);
311
- transition: box-shadow 120ms ease-out;
311
+ transition: box-shadow var(--a-duration-fast) var(--a-easing-out);
312
312
  }
313
313
  :scope[selectable]:focus { outline: none; }
314
314
  :scope[selectable]:focus-visible {
@@ -0,0 +1,269 @@
1
+ /**
2
+ * <swatch-ui> behavioral tests.
3
+ *
4
+ * Currently scoped to FB-36 / §325 regression coverage. Future tests for
5
+ * #stamp() lifecycle, chrome-slot composition, auto-contrast probe,
6
+ * etc. land here.
7
+ *
8
+ * Test setup note: happy-dom connects custom elements synchronously
9
+ * during innerHTML parsing — BEFORE sibling children inside the same
10
+ * element are appended. Real browsers connect after the full innerHTML
11
+ * is parsed. To exercise the FB-36 path (children present at connect
12
+ * time), tests use `document.createElement` + `appendChild` to build
13
+ * the full subtree FIRST, then attach to the document — guaranteeing
14
+ * the children are in `this.childNodes` when `connectedCallback()` fires.
15
+ */
16
+
17
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
18
+
19
+ beforeAll(async () => {
20
+ await import('./swatch.js');
21
+ });
22
+
23
+ function buildSwatch({ label, defaultSlot, shape = 'block', labelPosition = 'overlay', color = '#3b82f6' }) {
24
+ const swatch = document.createElement('swatch-ui');
25
+ swatch.setAttribute('shape', shape);
26
+ swatch.setAttribute('label-position', labelPosition);
27
+ swatch.setAttribute('color', color);
28
+ if (label !== undefined) swatch.setAttribute('label', label);
29
+ if (defaultSlot) {
30
+ // Mimic indented HTML's whitespace text nodes — the FB-36 trigger.
31
+ swatch.appendChild(document.createTextNode('\n '));
32
+ const child = document.createElement('span');
33
+ child.id = defaultSlot.id;
34
+ child.textContent = defaultSlot.text;
35
+ swatch.appendChild(child);
36
+ swatch.appendChild(document.createTextNode('\n '));
37
+ }
38
+ return swatch;
39
+ }
40
+
41
+ describe('<swatch-ui> default-slot + label-attr precedence (FB-36 / §325)', () => {
42
+ let host;
43
+
44
+ beforeEach(() => {
45
+ host = document.createElement('div');
46
+ document.body.appendChild(host);
47
+ });
48
+
49
+ it('preserves default-slot element when label="X" is also present (FB-36 §325 fix)', async () => {
50
+ const swatch = buildSwatch({
51
+ label: '500',
52
+ defaultSlot: { id: 'probe-default', text: 'richlabel' },
53
+ });
54
+ host.appendChild(swatch);
55
+ await new Promise((r) => setTimeout(r, 80));
56
+
57
+ const label = swatch.querySelector('[data-label]');
58
+ const span = swatch.querySelector('#probe-default');
59
+
60
+ // Pre-§325 the span was wiped + label="500" took precedence.
61
+ // Post-§325 the default-slot wins (yaml spec: "richer content").
62
+ expect(span).not.toBeNull();
63
+ expect(label.textContent.trim()).toBe('richlabel');
64
+ });
65
+
66
+ it('renders label-attr text when no default-slot is present', async () => {
67
+ const swatch = buildSwatch({ label: '500' });
68
+ host.appendChild(swatch);
69
+ await new Promise((r) => setTimeout(r, 80));
70
+ const label = swatch.querySelector('[data-label]');
71
+ expect(label.textContent.trim()).toBe('500');
72
+ });
73
+
74
+ it('renders default-slot text when no label-attr is present', async () => {
75
+ const swatch = buildSwatch({
76
+ defaultSlot: { id: 'probe-only', text: 'defaultonly' },
77
+ });
78
+ host.appendChild(swatch);
79
+ await new Promise((r) => setTimeout(r, 80));
80
+ const label = swatch.querySelector('[data-label]');
81
+ const span = swatch.querySelector('#probe-only');
82
+ expect(span).not.toBeNull();
83
+ expect(label.textContent.trim()).toBe('defaultonly');
84
+ });
85
+
86
+ it('syncs label-attr changes when there is no default-slot (no whitespace regression)', async () => {
87
+ const swatch = buildSwatch({ label: '500' });
88
+ host.appendChild(swatch);
89
+ await new Promise((r) => setTimeout(r, 80));
90
+ swatch.label = '600';
91
+ await new Promise((r) => setTimeout(r, 80));
92
+ const label = swatch.querySelector('[data-label]');
93
+ expect(label.textContent.trim()).toBe('600');
94
+ });
95
+ });
96
+
97
+ describe('<swatch-ui> late-arriving [slot="chrome"] children (FB-37 / §326)', () => {
98
+ let host;
99
+
100
+ beforeEach(() => {
101
+ host = document.createElement('div');
102
+ document.body.appendChild(host);
103
+ });
104
+
105
+ it('absorbs chrome child appended AFTER connectedCallback (template-render path)', async () => {
106
+ // Mimic the template-engine timing: create the swatch + connect it FIRST
107
+ // (children empty), then append the chrome child + trigger a render.
108
+ const swatch = document.createElement('swatch-ui');
109
+ swatch.setAttribute('shape', 'block');
110
+ swatch.setAttribute('label-position', 'overlay');
111
+ swatch.setAttribute('color', '#3b82f6');
112
+ swatch.setAttribute('label', '500');
113
+ host.appendChild(swatch);
114
+ await new Promise((r) => setTimeout(r, 80));
115
+
116
+ // At this point #stamp() has run + chromeSlot was empty.
117
+ // Append a chrome child LATER — this is what template engines do.
118
+ const chrome = document.createElement('span');
119
+ chrome.setAttribute('slot', 'chrome');
120
+ chrome.id = 'late-chrome';
121
+ chrome.textContent = '★';
122
+ swatch.appendChild(chrome);
123
+
124
+ // Trigger a re-render via property mutation (template engines typically
125
+ // set props after appending — this nudge fires render → #syncCore →
126
+ // #absorbChromeSlot picks up the late chrome child). Must use a value
127
+ // DIFFERENT from current to actually fire the signal write.
128
+ swatch.label = '999';
129
+ await new Promise((r) => setTimeout(r, 80));
130
+
131
+ const found = swatch.querySelector('#late-chrome');
132
+ expect(found).not.toBeNull();
133
+ // Chrome child should be a direct host sibling positioned between tile + badge.
134
+ const tile = swatch.querySelector('[data-tile]');
135
+ const badge = swatch.querySelector('[data-badge]');
136
+ expect(found.previousElementSibling).toBe(tile);
137
+ expect(found.nextElementSibling).toBe(badge);
138
+ });
139
+
140
+ it('still handles chrome child appended BEFORE connect (static-HTML path)', async () => {
141
+ // Build swatch with chrome child present at connect time — the canonical
142
+ // FB-34 / §314 path should still work post-§326.
143
+ const swatch = document.createElement('swatch-ui');
144
+ swatch.setAttribute('shape', 'block');
145
+ swatch.setAttribute('label-position', 'overlay');
146
+ swatch.setAttribute('color', '#3b82f6');
147
+ swatch.setAttribute('label', '500');
148
+ const chrome = document.createElement('span');
149
+ chrome.setAttribute('slot', 'chrome');
150
+ chrome.id = 'early-chrome';
151
+ chrome.textContent = '⚑';
152
+ swatch.appendChild(chrome);
153
+ host.appendChild(swatch);
154
+ await new Promise((r) => setTimeout(r, 80));
155
+
156
+ const found = swatch.querySelector('#early-chrome');
157
+ expect(found).not.toBeNull();
158
+ const tile = swatch.querySelector('[data-tile]');
159
+ const badge = swatch.querySelector('[data-badge]');
160
+ expect(found.previousElementSibling).toBe(tile);
161
+ expect(found.nextElementSibling).toBe(badge);
162
+ });
163
+ });
164
+
165
+ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 / §331)', () => {
166
+ let host;
167
+
168
+ beforeEach(() => {
169
+ host = document.createElement('div');
170
+ document.body.appendChild(host);
171
+ });
172
+
173
+ it('hoists chrome child from inside wrapper span at #stamp() time (template-interpolation path)', async () => {
174
+ // Simulates AdiaUI's html`` template wrapping interpolated content in
175
+ // <span style="display: contents"> (per core/template.js:212 `wrap()`).
176
+ // When consumer writes `html`<swatch-ui>${chromeFrag}</swatch-ui>``,
177
+ // the chromeFrag's content lands NESTED inside a wrapper span — not
178
+ // as direct children of <swatch-ui>. Pre-§331 the chrome was wiped.
179
+ const swatch = document.createElement('swatch-ui');
180
+ swatch.setAttribute('shape', 'block');
181
+ swatch.setAttribute('label-position', 'overlay');
182
+ swatch.setAttribute('color', '#3b82f6');
183
+ swatch.setAttribute('label', '500');
184
+
185
+ const wrapper = document.createElement('span');
186
+ wrapper.style.display = 'contents';
187
+ const chrome = document.createElement('span');
188
+ chrome.setAttribute('slot', 'chrome');
189
+ chrome.id = 'wrapped-chrome';
190
+ chrome.textContent = 'P3';
191
+ wrapper.appendChild(chrome);
192
+ swatch.appendChild(wrapper);
193
+
194
+ host.appendChild(swatch);
195
+ await new Promise((r) => setTimeout(r, 80));
196
+
197
+ const found = swatch.querySelector('#wrapped-chrome');
198
+ expect(found).not.toBeNull();
199
+ // Chrome child should be a direct host sibling positioned between tile + badge.
200
+ const tile = swatch.querySelector('[data-tile]');
201
+ const badge = swatch.querySelector('[data-badge]');
202
+ expect(found.parentElement).toBe(swatch);
203
+ expect(found.previousElementSibling).toBe(tile);
204
+ expect(found.nextElementSibling).toBe(badge);
205
+ });
206
+
207
+ it('handles MULTIPLE chrome children nested in separate wrapper spans', async () => {
208
+ // Each interpolated fragment in html`` gets its own wrapper span,
209
+ // so multi-chrome usage (`${badge}${dot1}${dot2}`) produces multiple
210
+ // wrapper spans, each containing one chrome child.
211
+ const swatch = document.createElement('swatch-ui');
212
+ swatch.setAttribute('shape', 'block');
213
+ swatch.setAttribute('label-position', 'overlay');
214
+ swatch.setAttribute('color', '#3b82f6');
215
+
216
+ const ids = ['c1', 'c2', 'c3'];
217
+ for (const id of ids) {
218
+ const w = document.createElement('span');
219
+ w.style.display = 'contents';
220
+ const c = document.createElement('span');
221
+ c.setAttribute('slot', 'chrome');
222
+ c.id = id;
223
+ c.textContent = id;
224
+ w.appendChild(c);
225
+ swatch.appendChild(w);
226
+ }
227
+ host.appendChild(swatch);
228
+ await new Promise((r) => setTimeout(r, 80));
229
+
230
+ for (const id of ids) {
231
+ const found = swatch.querySelector(`#${id}`);
232
+ expect(found).not.toBeNull();
233
+ expect(found.parentElement).toBe(swatch);
234
+ }
235
+ });
236
+
237
+ it('hoists chrome from late-arriving wrapper span (post-stamp template re-render)', async () => {
238
+ // Parent template re-render after the swatch is already stamped:
239
+ // a new wrapper span carrying chrome content arrives after #stamp().
240
+ // #absorbChromeSlot() should pick it up on next render pass.
241
+ const swatch = document.createElement('swatch-ui');
242
+ swatch.setAttribute('shape', 'block');
243
+ swatch.setAttribute('color', '#3b82f6');
244
+ swatch.setAttribute('label', '500');
245
+ host.appendChild(swatch);
246
+ await new Promise((r) => setTimeout(r, 80));
247
+
248
+ // Late: append a wrapper-span carrying chrome
249
+ const wrapper = document.createElement('span');
250
+ wrapper.style.display = 'contents';
251
+ const chrome = document.createElement('span');
252
+ chrome.setAttribute('slot', 'chrome');
253
+ chrome.id = 'late-wrapped';
254
+ wrapper.appendChild(chrome);
255
+ swatch.appendChild(wrapper);
256
+
257
+ // Nudge a render via a property change
258
+ swatch.label = '600';
259
+ await new Promise((r) => setTimeout(r, 80));
260
+
261
+ const found = swatch.querySelector('#late-wrapped');
262
+ expect(found).not.toBeNull();
263
+ expect(found.parentElement).toBe(swatch);
264
+ const tile = swatch.querySelector('[data-tile]');
265
+ const badge = swatch.querySelector('[data-badge]');
266
+ expect(found.previousElementSibling).toBe(tile);
267
+ expect(found.nextElementSibling).toBe(badge);
268
+ });
269
+ });