@adia-ai/web-components 0.8.19 → 0.8.21

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/components/card/card.a2ui.json +6 -3
  3. package/components/card/card.css +15 -1
  4. package/components/card/card.yaml +5 -3
  5. package/components/nav-group/nav-group.a2ui.json +5 -0
  6. package/components/nav-group/nav-group.class.js +14 -6
  7. package/components/nav-group/nav-group.css +5 -2
  8. package/components/nav-group/nav-group.d.ts +2 -0
  9. package/components/nav-group/nav-group.yaml +4 -0
  10. package/components/select/select.a2ui.json +3 -2
  11. package/components/select/select.class.js +14 -1
  12. package/components/select/select.css +13 -1
  13. package/components/select/select.yaml +5 -4
  14. package/components/table/table.class.js +7 -3
  15. package/components/table/table.css +7 -5
  16. package/dist/theme-provider.min.js +1 -1
  17. package/dist/web-components.min.css +1 -1
  18. package/dist/web-components.min.js +19 -19
  19. package/dist/web-components.sheet.js +1 -1
  20. package/package.json +2 -1
  21. package/components/adia-mark/adia-mark.test.js +0 -69
  22. package/components/adia-wordmark/adia-wordmark.test.js +0 -69
  23. package/components/agent-artifact/agent-artifact.test.js +0 -61
  24. package/components/alert/alert.test.js +0 -364
  25. package/components/color-picker/color-picker.test.js +0 -96
  26. package/components/combobox/combobox.test.js +0 -256
  27. package/components/command/command.test.js +0 -99
  28. package/components/date-range-picker/date-range-picker.test.js +0 -387
  29. package/components/datetime-picker/datetime-picker.test.js +0 -454
  30. package/components/demo-toggle/demo-toggle.test.js +0 -102
  31. package/components/description-list/description-list.test.js +0 -72
  32. package/components/display-field/display-field.test.js +0 -115
  33. package/components/empty-state/empty-state.test.js +0 -77
  34. package/components/field/field.test.js +0 -260
  35. package/components/fields/fields.test.js +0 -88
  36. package/components/inline-message/inline-message.test.js +0 -203
  37. package/components/input/input.test.js +0 -335
  38. package/components/integration-card/integration-card.test.js +0 -318
  39. package/components/link/link.test.js +0 -143
  40. package/components/list/list.test.js +0 -106
  41. package/components/list-window/list-window.test.js +0 -303
  42. package/components/loading-overlay/loading-overlay.test.js +0 -257
  43. package/components/menu/menu.test.js +0 -176
  44. package/components/nav/nav.test.js +0 -428
  45. package/components/nav-group/nav-group.test.js +0 -67
  46. package/components/option-card/option-card.test.js +0 -32
  47. package/components/pagination/pagination.test.js +0 -195
  48. package/components/select/select.test.js +0 -481
  49. package/components/slider/slider.test.js +0 -202
  50. package/components/spinner/spinner.test.js +0 -272
  51. package/components/stat/stat.test.js +0 -108
  52. package/components/step-progress/step-progress.test.js +0 -129
  53. package/components/swatch/swatch.test.js +0 -563
  54. package/components/table/table.test.js +0 -479
  55. package/components/table-toolbar/table-toolbar.test.js +0 -152
  56. package/components/tag/tag.test.js +0 -137
  57. package/components/tags-input/tags-input.test.js +0 -477
  58. package/components/text/text.test.js +0 -202
  59. package/components/theme-provider/theme-provider.test.js +0 -94
  60. package/components/time-picker/time-picker.test.js +0 -287
  61. package/components/timeline/timeline.test.js +0 -31
  62. package/components/toggle-scheme/toggle-scheme.test.js +0 -110
  63. package/components/tree/tree.test.js +0 -312
  64. package/core/element.test.js +0 -389
  65. package/core/icons.test.js +0 -187
  66. package/core/logical-children.test.js +0 -54
  67. package/core/markdown.test.js +0 -47
  68. package/core/responsive.test.js +0 -121
  69. package/core/template.test.js +0 -611
  70. package/traits/active-state/active-state.test.js +0 -28
  71. package/traits/anchor-positioning/anchor-positioning.test.js +0 -122
  72. package/traits/announcer/announcer.test.js +0 -268
  73. package/traits/arrow-grid-nav/arrow-grid-nav.test.js +0 -375
  74. package/traits/attention-pulse/attention-pulse.test.js +0 -26
  75. package/traits/confetti/confetti.test.js +0 -46
  76. package/traits/confetti-burst/confetti-burst.test.js +0 -46
  77. package/traits/count-up/count-up.test.js +0 -54
  78. package/traits/declarative.test.js +0 -138
  79. package/traits/dirty-state/dirty-state.test.js +0 -45
  80. package/traits/drag-ghost/drag-ghost.test.js +0 -19
  81. package/traits/draggable/draggable.test.js +0 -60
  82. package/traits/draggable-list-item/draggable-list-item.test.js +0 -51
  83. package/traits/drop-target/drop-target.test.js +0 -241
  84. package/traits/droppable/droppable.test.js +0 -54
  85. package/traits/droppable-collection/droppable-collection.test.js +0 -99
  86. package/traits/error-shake/error-shake.test.js +0 -114
  87. package/traits/fade-presence/fade-presence.test.js +0 -20
  88. package/traits/focus-restore/focus-restore.test.js +0 -202
  89. package/traits/focus-trap/focus-trap.test.js +0 -42
  90. package/traits/focusable/focusable.test.js +0 -53
  91. package/traits/glow-focus/glow-focus.test.js +0 -31
  92. package/traits/gradient-shift/gradient-shift.test.js +0 -22
  93. package/traits/haptic-feedback/haptic-feedback.test.js +0 -52
  94. package/traits/hotkey/hotkey.test.js +0 -61
  95. package/traits/hoverable/hoverable.test.js +0 -24
  96. package/traits/inertia-drag/inertia-drag.test.js +0 -33
  97. package/traits/input-mask/input-mask.test.js +0 -151
  98. package/traits/intersection-observer/intersection-observer.test.js +0 -38
  99. package/traits/keyboard-nav/keyboard-nav.test.js +0 -41
  100. package/traits/keyboard-reorderable/keyboard-reorderable.test.js +0 -45
  101. package/traits/layout-animation/layout-animation.test.js +0 -114
  102. package/traits/long-press/long-press.test.js +0 -244
  103. package/traits/magnetic-hover/magnetic-hover.test.js +0 -30
  104. package/traits/noise-texture/noise-texture.test.js +0 -20
  105. package/traits/parallax/parallax.test.js +0 -26
  106. package/traits/portal/portal.test.js +0 -30
  107. package/traits/pressable/pressable.test.js +0 -73
  108. package/traits/resettable/resettable.test.js +0 -100
  109. package/traits/resizable/resizable.test.js +0 -39
  110. package/traits/resize-observer/resize-observer.test.js +0 -38
  111. package/traits/ripple/ripple.test.js +0 -32
  112. package/traits/roving-tabindex/roving-tabindex.test.js +0 -28
  113. package/traits/scale-press/scale-press.test.js +0 -39
  114. package/traits/scroll-lock/scroll-lock.test.js +0 -45
  115. package/traits/scroll-progress/scroll-progress.test.js +0 -182
  116. package/traits/shimmer-loading/shimmer-loading.test.js +0 -43
  117. package/traits/smoke.test.js +0 -111
  118. package/traits/snap-to-grid/snap-to-grid.test.js +0 -40
  119. package/traits/sound-feedback/sound-feedback.test.js +0 -26
  120. package/traits/spring-animate/spring-animate.test.js +0 -28
  121. package/traits/success-checkmark/success-checkmark.test.js +0 -120
  122. package/traits/tilt-hover/tilt-hover.test.js +0 -32
  123. package/traits/tossable/tossable.test.js +0 -31
  124. package/traits/traits-host.test.js +0 -73
  125. package/traits/typeahead/typeahead.test.js +0 -38
  126. package/traits/typewriter/typewriter.test.js +0 -47
  127. package/traits/validation/validation.test.js +0 -93
  128. package/traits/view-transition/view-transition.test.js +0 -268
@@ -1,563 +0,0 @@
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
- // §341 (FB-43) refined to keep chrome INSIDE the wrapper + move the
180
- // wrapper to canonical position (preserves template-engine `part.n`
181
- // identity so parent re-renders update in-place).
182
- const swatch = document.createElement('swatch-ui');
183
- swatch.setAttribute('shape', 'block');
184
- swatch.setAttribute('label-position', 'overlay');
185
- swatch.setAttribute('color', '#3b82f6');
186
- swatch.setAttribute('label', '500');
187
-
188
- const wrapper = document.createElement('span');
189
- wrapper.style.display = 'contents';
190
- const chrome = document.createElement('span');
191
- chrome.setAttribute('slot', 'chrome');
192
- chrome.id = 'wrapped-chrome';
193
- chrome.textContent = 'P3';
194
- wrapper.appendChild(chrome);
195
- swatch.appendChild(wrapper);
196
-
197
- host.appendChild(swatch);
198
- await new Promise((r) => setTimeout(r, 80));
199
-
200
- const found = swatch.querySelector('#wrapped-chrome');
201
- expect(found).not.toBeNull();
202
- // §341: chrome stays inside the display:contents wrapper; wrapper is
203
- // at canonical position (between tile + badge). Visually equivalent
204
- // to direct-child placement because display:contents collapses the box.
205
- expect(found.parentElement).toBe(wrapper);
206
- expect(wrapper.parentElement).toBe(swatch);
207
- const tile = swatch.querySelector('[data-tile]');
208
- const badge = swatch.querySelector('[data-badge]');
209
- expect(wrapper.previousElementSibling).toBe(tile);
210
- expect(wrapper.nextElementSibling).toBe(badge);
211
- });
212
-
213
- it('handles MULTIPLE chrome children nested in separate wrapper spans', async () => {
214
- // Each interpolated fragment in html`` gets its own wrapper span,
215
- // so multi-chrome usage (`${badge}${dot1}${dot2}`) produces multiple
216
- // wrapper spans, each containing one chrome child. §341: wrappers
217
- // stay intact, chrome remains inside them.
218
- const swatch = document.createElement('swatch-ui');
219
- swatch.setAttribute('shape', 'block');
220
- swatch.setAttribute('label-position', 'overlay');
221
- swatch.setAttribute('color', '#3b82f6');
222
-
223
- const ids = ['c1', 'c2', 'c3'];
224
- for (const id of ids) {
225
- const w = document.createElement('span');
226
- w.style.display = 'contents';
227
- const c = document.createElement('span');
228
- c.setAttribute('slot', 'chrome');
229
- c.id = id;
230
- c.textContent = id;
231
- w.appendChild(c);
232
- swatch.appendChild(w);
233
- }
234
- host.appendChild(swatch);
235
- await new Promise((r) => setTimeout(r, 80));
236
-
237
- for (const id of ids) {
238
- const found = swatch.querySelector(`#${id}`);
239
- expect(found).not.toBeNull();
240
- // §341: chrome stays inside its wrapper; wrapper is a direct child of swatch.
241
- expect(found.parentElement.tagName).toBe('SPAN');
242
- expect(found.parentElement.style.display).toBe('contents');
243
- expect(found.parentElement.parentElement).toBe(swatch);
244
- }
245
- });
246
-
247
- it('hoists chrome from late-arriving wrapper span (post-stamp template re-render)', async () => {
248
- // Parent template re-render after the swatch is already stamped:
249
- // a new wrapper span carrying chrome content arrives after #stamp().
250
- // #absorbChromeSlot() should pick it up on next render pass.
251
- const swatch = document.createElement('swatch-ui');
252
- swatch.setAttribute('shape', 'block');
253
- swatch.setAttribute('color', '#3b82f6');
254
- swatch.setAttribute('label', '500');
255
- host.appendChild(swatch);
256
- await new Promise((r) => setTimeout(r, 80));
257
-
258
- // Late: append a wrapper-span carrying chrome
259
- const wrapper = document.createElement('span');
260
- wrapper.style.display = 'contents';
261
- const chrome = document.createElement('span');
262
- chrome.setAttribute('slot', 'chrome');
263
- chrome.id = 'late-wrapped';
264
- wrapper.appendChild(chrome);
265
- swatch.appendChild(wrapper);
266
-
267
- // Nudge a render via a property change
268
- swatch.label = '600';
269
- await new Promise((r) => setTimeout(r, 80));
270
-
271
- const found = swatch.querySelector('#late-wrapped');
272
- expect(found).not.toBeNull();
273
- // §341: chrome stays inside wrapper; wrapper is now at canonical
274
- // position (between tile and badge) after #absorbChromeSlot()'s
275
- // re-positioning of the wrapper.
276
- expect(found.parentElement).toBe(wrapper);
277
- expect(wrapper.parentElement).toBe(swatch);
278
- const tile = swatch.querySelector('[data-tile]');
279
- const badge = swatch.querySelector('[data-badge]');
280
- expect(wrapper.previousElementSibling).toBe(tile);
281
- expect(wrapper.nextElementSibling).toBe(badge);
282
- });
283
- });
284
-
285
- describe('<swatch-ui> [auto-contrast] does not override [label-position="overlay"] position (FB-41 / §339)', () => {
286
- // Structural CSS-source-string assertion: happy-dom does not resolve
287
- // @scope CSS specificity reliably, so we verify the fix by reading
288
- // swatch.css text and asserting the auto-contrast rules don't carry
289
- // `position: relative` (which collided at equal specificity with the
290
- // overlay rule's `position: absolute` via source order).
291
- //
292
- // Pre-§339: `:scope[shape="block"][auto-contrast] > [data-label]`
293
- // declared `position: relative; z-index: 1`. Combined with overlay
294
- // attribute, the later rule won → label dropped out of overlay layout.
295
- //
296
- // Post-§339: only `z-index: 1` remains (z-index is no-op on static
297
- // elements but harmless; for the overlay case it stacks above the
298
- // tile correctly via the overlay rule's `position: absolute`).
299
- it('auto-contrast label rule does NOT declare position: relative (regression for FB-41)', async () => {
300
- const fs = await import('node:fs');
301
- const path = await import('node:path');
302
- const url = await import('node:url');
303
- const here = path.dirname(url.fileURLToPath(import.meta.url));
304
- const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
305
- const match = css.match(
306
- /:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
307
- );
308
- expect(match).not.toBeNull();
309
- const block = match[1];
310
- expect(block).not.toMatch(/position\s*:\s*relative/);
311
- expect(block).toMatch(/z-index\s*:\s*1/);
312
- });
313
-
314
- it('auto-contrast detail rule does NOT declare position: relative (regression for FB-41)', async () => {
315
- const fs = await import('node:fs');
316
- const path = await import('node:path');
317
- const url = await import('node:url');
318
- const here = path.dirname(url.fileURLToPath(import.meta.url));
319
- const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
320
- const match = css.match(
321
- /:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-detail\]\s*\{([^}]*)\}/
322
- );
323
- expect(match).not.toBeNull();
324
- const block = match[1];
325
- expect(block).not.toMatch(/position\s*:\s*relative/);
326
- expect(block).toMatch(/z-index\s*:\s*1/);
327
- });
328
-
329
- it('overlay rule still declares position: absolute (regression for FB-41)', async () => {
330
- // Defensive: also assert the overlay rule's `position: absolute` is
331
- // still in place — if a future refactor drops it, the §253 contract
332
- // breaks regardless of the auto-contrast collision.
333
- const fs = await import('node:fs');
334
- const path = await import('node:path');
335
- const url = await import('node:url');
336
- const here = path.dirname(url.fileURLToPath(import.meta.url));
337
- const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
338
- const match = css.match(
339
- /:scope\[shape="block"\]\[label-position="overlay"\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
340
- );
341
- expect(match).not.toBeNull();
342
- const block = match[1];
343
- expect(block).toMatch(/position\s*:\s*absolute/);
344
- });
345
- });
346
-
347
- describe('<swatch-ui> chrome survives parent template re-render (FB-43 / §341)', () => {
348
- // FB-43 (P1): claim is `template.js` mount() replaceChildren() destroys
349
- // chrome children on every re-render. The real mechanism is more subtle:
350
- // §331's #stamp() does `innerHTML = ''` which wipes the wrapper-span
351
- // created by the parent template engine's wrap() (after collecting
352
- // chrome into chromeSlot). The parent template's `part.n` still points
353
- // to the now-detached wrapper-span. On parent re-render, applyValue()
354
- // stamps the new chrome value INTO the detached wrapper-span — landing
355
- // in memory but not in the DOM. Chrome is effectively destroyed.
356
- //
357
- // This empirical test reproduces the exact scenario.
358
- let host;
359
- let html, stamp;
360
-
361
- beforeAll(async () => {
362
- ({ html, stamp } = await import('../../core/template.js'));
363
- });
364
-
365
- beforeEach(() => {
366
- host = document.createElement('div');
367
- document.body.appendChild(host);
368
- });
369
-
370
- // §341 contract: chrome is queryable from swatch + lives inside a
371
- // display:contents wrapper-span (the template-engine's stable part.n).
372
- // The wrapper sits at canonical chrome position (between tile + badge).
373
- // Visually equivalent to direct-child placement because display:contents
374
- // collapses the wrapper's box.
375
- function assertChromeCanonical(swatch, chrome) {
376
- expect(chrome).not.toBeNull();
377
- const wrapper = chrome.parentElement;
378
- expect(wrapper).not.toBeNull();
379
- // The wrapper is the chrome's direct parent + a display:contents span
380
- // that's a direct child of the swatch (the template-engine's
381
- // wrap()-created stable reference).
382
- expect(wrapper.tagName).toBe('SPAN');
383
- expect(wrapper.style.display).toBe('contents');
384
- expect(wrapper.parentElement).toBe(swatch);
385
- // Wrapper sits between tile and badge (canonical chrome region).
386
- const tile = swatch.querySelector('[data-tile]');
387
- const badge = swatch.querySelector('[data-badge]');
388
- expect(tile).not.toBeNull();
389
- expect(badge).not.toBeNull();
390
- // Walk from tile forward: should hit wrapper before badge.
391
- let cursor = tile.nextElementSibling;
392
- let foundWrapper = false;
393
- while (cursor && cursor !== badge) {
394
- if (cursor === wrapper) { foundWrapper = true; break; }
395
- cursor = cursor.nextElementSibling;
396
- }
397
- expect(foundWrapper).toBe(true);
398
- }
399
-
400
- it('FB-43: chrome interpolated via html`` template survives first render', async () => {
401
- const chromeFrag = html`<span slot="chrome" id="fb43-chrome">P3</span>`;
402
- const tpl = html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
403
- stamp(tpl, host);
404
- await new Promise((r) => setTimeout(r, 80));
405
-
406
- const found = host.querySelector('#fb43-chrome');
407
- expect(found).not.toBeNull();
408
- const swatch = host.querySelector('swatch-ui');
409
- assertChromeCanonical(swatch, found);
410
- });
411
-
412
- it('FB-43: chrome interpolated via html`` template survives second parent re-stamp (same strings)', async () => {
413
- // FB-43's core claim: re-render destroys chrome. Same template strings
414
- // → stamp() cache should hit → update() runs. Chrome content updates
415
- // in-place inside the preserved wrapper-span.
416
- function renderOnce(chromeText) {
417
- const chromeFrag = html`<span slot="chrome" id="fb43-chrome">${chromeText}</span>`;
418
- return html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
419
- }
420
- stamp(renderOnce('P3'), host);
421
- await new Promise((r) => setTimeout(r, 80));
422
- expect(host.querySelector('#fb43-chrome')).not.toBeNull();
423
-
424
- // Re-stamp with same template strings (different value).
425
- stamp(renderOnce('OOG'), host);
426
- await new Promise((r) => setTimeout(r, 80));
427
-
428
- const swatch = host.querySelector('swatch-ui');
429
- const chrome = swatch.querySelector('[slot="chrome"]');
430
- expect(chrome).not.toBeNull();
431
- expect(chrome.textContent).toBe('OOG');
432
- assertChromeCanonical(swatch, chrome);
433
- });
434
-
435
- it('FB-43: chrome survives N consecutive re-renders (regression stress test)', async () => {
436
- function renderOnce(chromeText) {
437
- const chromeFrag = html`<span slot="chrome" id="fb43-chrome">${chromeText}</span>`;
438
- return html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
439
- }
440
- for (let i = 0; i < 5; i++) {
441
- stamp(renderOnce(`label-${i}`), host);
442
- await new Promise((r) => setTimeout(r, 60));
443
- }
444
- const swatch = host.querySelector('swatch-ui');
445
- const chrome = swatch.querySelector('[slot="chrome"]');
446
- expect(chrome).not.toBeNull();
447
- expect(chrome.textContent).toBe('label-4');
448
- assertChromeCanonical(swatch, chrome);
449
- });
450
- });
451
-
452
- describe('<swatch-ui> chrome absorbed on late arrival (FB-44 / §-TBD)', () => {
453
- // FB-44 (P1): #absorbChromeSlot() runs from #syncCore() which only fires
454
- // on attribute/property changes. scan() walks element.attributes BEFORE
455
- // descending to child nodes, so the parent template's update() loop
456
- // assigns properties (→ render() → #syncCore() → #absorbChromeSlot())
457
- // BEFORE interpolating child node parts. The once-only absorb pass sees
458
- // an empty children set; chrome lands moments later at the arrival
459
- // position, not the canonical post-tile region.
460
- //
461
- // §-TBD adds a MutationObserver on host childList that re-runs
462
- // #absorbChromeSlot() when chrome (direct or wrapper-spanned) arrives.
463
- let host;
464
-
465
- beforeEach(() => {
466
- host = document.createElement('div');
467
- document.body.appendChild(host);
468
- });
469
-
470
- // Inline a minimal canonical-position assertion (same shape as the
471
- // FB-43 helper but local to this describe block).
472
- function assertChromePositionedBeforeBadge(swatch, chrome) {
473
- expect(chrome).not.toBeNull();
474
- const badge = swatch.querySelector('[data-badge]');
475
- expect(badge).not.toBeNull();
476
- // The chrome (or its wrapper-span ancestor) should be a child of swatch
477
- // that appears before the badge.
478
- let chromeChild = chrome;
479
- while (chromeChild.parentElement !== swatch && chromeChild.parentElement) {
480
- chromeChild = chromeChild.parentElement;
481
- }
482
- expect(chromeChild.parentElement).toBe(swatch);
483
- // Walk siblings from chromeChild forward — badge must be reachable.
484
- let cursor = chromeChild;
485
- let foundBadge = false;
486
- while (cursor) {
487
- if (cursor === badge) { foundBadge = true; break; }
488
- cursor = cursor.nextElementSibling;
489
- }
490
- expect(foundBadge).toBe(true);
491
- }
492
-
493
- it('FB-44: chrome appended via host.appendChild() after connect lands at canonical position', async () => {
494
- const swatch = document.createElement('swatch-ui');
495
- swatch.setAttribute('shape', 'block');
496
- swatch.setAttribute('color', '#3b82f6');
497
- host.appendChild(swatch);
498
- await new Promise((r) => setTimeout(r, 30));
499
-
500
- // Append chrome AFTER initial #stamp() + #syncCore() pass completed.
501
- const badge = document.createElement('span');
502
- badge.setAttribute('slot', 'chrome');
503
- badge.id = 'fb44-late-chrome';
504
- badge.textContent = 'P3';
505
- swatch.appendChild(badge);
506
- // Wait a microtask for the MutationObserver to fire.
507
- await new Promise((r) => setTimeout(r, 30));
508
-
509
- const found = swatch.querySelector('#fb44-late-chrome');
510
- assertChromePositionedBeforeBadge(swatch, found);
511
- });
512
-
513
- it('FB-44: chrome appended via display:contents wrapper-span after connect lands at canonical position', async () => {
514
- const swatch = document.createElement('swatch-ui');
515
- swatch.setAttribute('shape', 'block');
516
- swatch.setAttribute('color', '#3b82f6');
517
- host.appendChild(swatch);
518
- await new Promise((r) => setTimeout(r, 30));
519
-
520
- // Simulate the template engine's wrap() output: display:contents span
521
- // carrying the chrome child as its only descendant.
522
- const wrapper = document.createElement('span');
523
- wrapper.style.display = 'contents';
524
- const badge = document.createElement('span');
525
- badge.setAttribute('slot', 'chrome');
526
- badge.id = 'fb44-wrapped-chrome';
527
- badge.textContent = 'OOG';
528
- wrapper.appendChild(badge);
529
- swatch.appendChild(wrapper);
530
- await new Promise((r) => setTimeout(r, 30));
531
-
532
- const found = swatch.querySelector('#fb44-wrapped-chrome');
533
- assertChromePositionedBeforeBadge(swatch, found);
534
- });
535
-
536
- it('FB-44: chrome observer is disconnected when host leaves the DOM (leak regression)', async () => {
537
- const swatch = document.createElement('swatch-ui');
538
- swatch.setAttribute('shape', 'block');
539
- swatch.setAttribute('color', '#3b82f6');
540
- host.appendChild(swatch);
541
- await new Promise((r) => setTimeout(r, 30));
542
-
543
- // Remove from DOM — disconnected() should disconnect the observer.
544
- swatch.remove();
545
- await new Promise((r) => setTimeout(r, 30));
546
-
547
- // Subsequent appendChild on the now-orphaned host should not throw,
548
- // and re-attaching the swatch should re-arm a fresh observer.
549
- document.body.appendChild(swatch);
550
- await new Promise((r) => setTimeout(r, 30));
551
-
552
- const badge = document.createElement('span');
553
- badge.setAttribute('slot', 'chrome');
554
- badge.id = 'fb44-reattach-chrome';
555
- swatch.appendChild(badge);
556
- await new Promise((r) => setTimeout(r, 30));
557
-
558
- const found = swatch.querySelector('#fb44-reattach-chrome');
559
- expect(found).not.toBeNull();
560
- assertChromePositionedBeforeBadge(swatch, found);
561
- swatch.remove();
562
- });
563
- });