@adia-ai/web-components 0.5.17 → 0.5.19

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.
@@ -192,10 +192,47 @@ export class UISwatch extends UIElement {
192
192
  #onHostKey = null;
193
193
  #onCopyClick = null;
194
194
  #copyResetTimer = null;
195
+ #chromeObserver = null;
195
196
 
196
197
  connected() {
197
198
  this.#stamp();
198
199
  this.#wireInteraction();
200
+ // §-TBD (v0.5.19, FB-44): observe late-arriving slot="chrome" children
201
+ // and re-run #absorbChromeSlot(). The parent template's update() walks
202
+ // attribute/property parts BEFORE child node parts (per core/template.js
203
+ // scan(): TreeWalker visits element.attributes before descending), so
204
+ // property assignments fire render() → #syncCore() → #absorbChromeSlot()
205
+ // while chrome interpolations are still empty placeholder text nodes.
206
+ // The once-only absorb pass therefore sees an empty set and the chrome
207
+ // — arriving moments later — sits at its arrival position rather than
208
+ // the canonical post-tile region. MutationObserver closes this gap for
209
+ // every arrival path: single-template ordering skew, separate render
210
+ // cycle, or imperative host.appendChild().
211
+ this.#chromeObserver = new MutationObserver((mutations) => {
212
+ let hasChrome = false;
213
+ for (const m of mutations) {
214
+ for (const n of m.addedNodes) {
215
+ if (n.nodeType !== 1) continue;
216
+ // Direct chrome child OR a display:contents wrapper-span carrying chrome.
217
+ if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
218
+ if (
219
+ n.tagName === 'SPAN' &&
220
+ n.style?.display === 'contents' &&
221
+ n.querySelector?.('[slot="chrome"]')
222
+ ) { hasChrome = true; break; }
223
+ }
224
+ if (hasChrome) break;
225
+ }
226
+ if (!hasChrome) return;
227
+ // Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
228
+ // generates childList mutations on the host. Without this guard, every
229
+ // absorb pass would re-trigger the observer indefinitely. takeRecords()
230
+ // drains the queue without dispatch so the next observation cycle starts
231
+ // clean.
232
+ this.#absorbChromeSlot();
233
+ this.#chromeObserver.takeRecords();
234
+ });
235
+ this.#chromeObserver.observe(this, { childList: true });
199
236
  }
200
237
 
201
238
  render() {
@@ -234,14 +271,45 @@ export class UISwatch extends UIElement {
234
271
  const chromeSlot = Array.from(this.children).filter(
235
272
  n => n.getAttribute && n.getAttribute('slot') === 'chrome'
236
273
  );
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
- }
243
274
  for (const n of chromeSlot) n.remove();
244
275
 
276
+ // §341 (v0.5.x, FB-43): preserve display:contents wrapper-spans that
277
+ // already contain chrome at #stamp() time (rare — typically when a
278
+ // setup script pre-built the subtree before connect). AdiaUI's `html\`\``
279
+ // template engine wraps each `${...}` interpolation in a `<span
280
+ // style="display: contents">` (per `core/template.js:212` `wrap()`);
281
+ // re-renders update wrapper contents via `part.n`. Detach + re-attach
282
+ // at canonical position so they survive innerHTML='' below.
283
+ const chromeWrappers = Array.from(this.children).filter(n =>
284
+ n.tagName === 'SPAN' &&
285
+ n.style?.display === 'contents' &&
286
+ n.querySelector?.('[slot="chrome"]')
287
+ );
288
+ for (const w of chromeWrappers) w.remove();
289
+
290
+ // §341 (FB-43): preserve template-engine text-node + comment
291
+ // placeholders. AdiaUI's `scan()` (template.js:113) replaces
292
+ // `<!--p:N-->` comments with empty text nodes; `wrap()` later
293
+ // creates `<span style="display: contents">` and replaces the text
294
+ // node in-place via `replaceWith(part.n, wrapper)`. For that
295
+ // replaceWith to work, the text node MUST still be attached when
296
+ // parent's update() runs (it runs AFTER our connectedCallback
297
+ // synchronously, so AFTER #stamp()). Pre-§341, innerHTML='' below
298
+ // wiped the text node → replaceWith no-op → wrapper detached →
299
+ // chrome stamped into a detached subtree (never reaches DOM).
300
+ //
301
+ // Fix: extract empty text + comment nodes before the wipe, re-attach
302
+ // at the canonical chrome region (between tile and badge). Parent's
303
+ // update() then replaces them with live wrapper-spans in place;
304
+ // subsequent re-renders update the wrappers in-place too.
305
+ const placeholders = [];
306
+ for (const n of Array.from(this.childNodes)) {
307
+ if ((n.nodeType === 3 && !n.data) || n.nodeType === 8) {
308
+ placeholders.push(n);
309
+ }
310
+ }
311
+ for (const p of placeholders) p.remove();
312
+
245
313
  // Capture pre-existing default-slot content so consumer-authored
246
314
  // children (e.g. <swatch-ui>Forecast</swatch-ui>) survive stamping.
247
315
  // `[slot="chrome"]` children are already removed above, so won't appear here.
@@ -279,6 +347,19 @@ export class UISwatch extends UIElement {
279
347
  // `position: absolute; top: 2px; right: 2px` and anchor against the host's
280
348
  // padding-box, which matches the tile's geometry in the block layout.
281
349
  for (const n of chromeSlot) this.appendChild(n);
350
+ // §341 (FB-43): re-attach chrome-bearing wrapper-spans at canonical
351
+ // position. Wrapper preserves the template-engine's `part.n`
352
+ // reference so parent re-renders can update chrome content in-place
353
+ // without detaching the wrapper.
354
+ for (const w of chromeWrappers) this.appendChild(w);
355
+ // §341 (FB-43): re-attach template-engine placeholders at canonical
356
+ // chrome region. Parent's pending update() will replace each
357
+ // placeholder with a live wrapper-span (via `wrap(part)`'s
358
+ // `replaceWith`), landing chrome content visually between tile +
359
+ // badge. Without this re-attach, the placeholders would have been
360
+ // wiped by `innerHTML = ''` and the parent's `replaceWith` would
361
+ // no-op against a detached node.
362
+ for (const p of placeholders) this.appendChild(p);
282
363
 
283
364
  // Badge container — holds one or more <span data-badge-variant="..."> children.
284
365
  // Multi-badge support added in v0.4.9 §92 (FEEDBACK-04 follow-up).
@@ -350,9 +431,18 @@ export class UISwatch extends UIElement {
350
431
  this.insertBefore(child, this.#badgeEl);
351
432
  }
352
433
  }
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.
434
+ // §331 (v0.5.16, FB-39): handle wrapper-spanned chrome children that
435
+ // arrive AFTER #stamp() (parent template's late update() pass).
436
+ //
437
+ // §341 (FB-43) refinement: move the WRAPPER to canonical position
438
+ // rather than hoisting chrome OUT of it. The wrapper is the parent
439
+ // template engine's `part.n` reference; if we extract chrome, the
440
+ // next parent re-render lands new chrome inside the now-empty
441
+ // wrapper at the wrapper's original (non-canonical) position. By
442
+ // moving the wrapper itself to canonical position, future parent
443
+ // updates land at the right visual location automatically.
444
+ // Wrapper is `display: contents` → no layout box → chrome anchors
445
+ // against the swatch's padding-box correctly.
356
446
  for (const child of Array.from(this.children)) {
357
447
  if (
358
448
  child === this.#tileEl ||
@@ -361,11 +451,12 @@ export class UISwatch extends UIElement {
361
451
  child === this.#detailEl ||
362
452
  child === this.#copyEl
363
453
  ) 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
- }
454
+ // Only move display:contents wrappers that actually carry chrome.
455
+ const isWrapper = child.tagName === 'SPAN' && child.style?.display === 'contents';
456
+ if (!isWrapper) continue;
457
+ const hasNested = child.querySelector?.('[slot="chrome"]');
458
+ if (!hasNested) continue;
459
+ this.insertBefore(child, this.#badgeEl);
369
460
  }
370
461
  }
371
462
 
@@ -584,6 +675,10 @@ export class UISwatch extends UIElement {
584
675
  clearTimeout(this.#copyResetTimer);
585
676
  this.#copyResetTimer = null;
586
677
  }
678
+ if (this.#chromeObserver) {
679
+ this.#chromeObserver.disconnect();
680
+ this.#chromeObserver = null;
681
+ }
587
682
  if (this.#onHostClick) this.removeEventListener('click', this.#onHostClick);
588
683
  if (this.#onHostKey) this.removeEventListener('keydown', this.#onHostKey);
589
684
  if (this.#copyEl && this.#onCopyClick) this.#copyEl.removeEventListener('click', this.#onCopyClick);
@@ -175,7 +175,10 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
175
175
  // <span style="display: contents"> (per core/template.js:212 `wrap()`).
176
176
  // When consumer writes `html`<swatch-ui>${chromeFrag}</swatch-ui>``,
177
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.
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).
179
182
  const swatch = document.createElement('swatch-ui');
180
183
  swatch.setAttribute('shape', 'block');
181
184
  swatch.setAttribute('label-position', 'overlay');
@@ -196,18 +199,22 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
196
199
 
197
200
  const found = swatch.querySelector('#wrapped-chrome');
198
201
  expect(found).not.toBeNull();
199
- // Chrome child should be a direct host sibling positioned between tile + badge.
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);
200
207
  const tile = swatch.querySelector('[data-tile]');
201
208
  const badge = swatch.querySelector('[data-badge]');
202
- expect(found.parentElement).toBe(swatch);
203
- expect(found.previousElementSibling).toBe(tile);
204
- expect(found.nextElementSibling).toBe(badge);
209
+ expect(wrapper.previousElementSibling).toBe(tile);
210
+ expect(wrapper.nextElementSibling).toBe(badge);
205
211
  });
206
212
 
207
213
  it('handles MULTIPLE chrome children nested in separate wrapper spans', async () => {
208
214
  // Each interpolated fragment in html`` gets its own wrapper span,
209
215
  // so multi-chrome usage (`${badge}${dot1}${dot2}`) produces multiple
210
- // wrapper spans, each containing one chrome child.
216
+ // wrapper spans, each containing one chrome child. §341: wrappers
217
+ // stay intact, chrome remains inside them.
211
218
  const swatch = document.createElement('swatch-ui');
212
219
  swatch.setAttribute('shape', 'block');
213
220
  swatch.setAttribute('label-position', 'overlay');
@@ -230,7 +237,10 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
230
237
  for (const id of ids) {
231
238
  const found = swatch.querySelector(`#${id}`);
232
239
  expect(found).not.toBeNull();
233
- expect(found.parentElement).toBe(swatch);
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);
234
244
  }
235
245
  });
236
246
 
@@ -260,11 +270,15 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
260
270
 
261
271
  const found = swatch.querySelector('#late-wrapped');
262
272
  expect(found).not.toBeNull();
263
- expect(found.parentElement).toBe(swatch);
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);
264
278
  const tile = swatch.querySelector('[data-tile]');
265
279
  const badge = swatch.querySelector('[data-badge]');
266
- expect(found.previousElementSibling).toBe(tile);
267
- expect(found.nextElementSibling).toBe(badge);
280
+ expect(wrapper.previousElementSibling).toBe(tile);
281
+ expect(wrapper.nextElementSibling).toBe(badge);
268
282
  });
269
283
  });
270
284
 
@@ -329,3 +343,221 @@ describe('<swatch-ui> [auto-contrast] does not override [label-position="overlay
329
343
  expect(block).toMatch(/position\s*:\s*absolute/);
330
344
  });
331
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
+ });
@@ -53,13 +53,24 @@ export class UITree extends UIElement {
53
53
  return this.querySelector('tree-item-ui[selected]');
54
54
  }
55
55
 
56
- select(item) {
56
+ // §-TBD (v0.5.19, FB-46): originatingEvent forwards Mouse/KeyboardEvent
57
+ // modifier state into the dispatched tree-select detail so consumers can
58
+ // implement Ctrl/Cmd+click multi-select cleanly. Programmatic select()
59
+ // calls (no event) surface ctrlKey/metaKey/shiftKey as false.
60
+ select(item, originatingEvent = null) {
57
61
  const prev = this.selectedItem;
58
62
  if (prev && prev !== item) prev.removeAttribute('selected');
59
63
  item.setAttribute('selected', '');
60
64
  this.dispatchEvent(new CustomEvent('tree-select', {
61
65
  bubbles: true,
62
- detail: { item, text: item.text, value: item.value },
66
+ detail: {
67
+ item,
68
+ text: item.text,
69
+ value: item.value,
70
+ ctrlKey: !!originatingEvent?.ctrlKey,
71
+ metaKey: !!originatingEvent?.metaKey,
72
+ shiftKey: !!originatingEvent?.shiftKey,
73
+ },
63
74
  }));
64
75
  }
65
76
 
@@ -150,7 +161,7 @@ export class UITree extends UIElement {
150
161
  if (!row || !(e.target === row || row.contains(e.target))) return;
151
162
 
152
163
  // Single click: select + toggle if has children
153
- this.select(item);
164
+ this.select(item, e);
154
165
  if (item.hasChildren) {
155
166
  item.open = !item.open;
156
167
  }
@@ -198,7 +209,7 @@ export class UITree extends UIElement {
198
209
  case 'Enter':
199
210
  case ' ':
200
211
  e.preventDefault();
201
- this.select(item);
212
+ this.select(item, e);
202
213
  break;
203
214
  }
204
215
  };
@@ -29,12 +29,24 @@
29
29
  ],
30
30
  "events": {
31
31
  "tree-select": {
32
- "description": "Fired when an item is selected. detail: { item, text, value }",
32
+ "description": "Fired when an item is selected. detail: { item, text, value, ctrlKey, metaKey, shiftKey }. Modifier flags mirror the originating MouseEvent / KeyboardEvent; all default false for programmatic select() calls.\n",
33
33
  "detail": {
34
+ "ctrlKey": {
35
+ "description": "Ctrl key held during activation (false for programmatic select()).",
36
+ "type": "boolean"
37
+ },
34
38
  "item": {
35
39
  "description": "Selected tree-item element.",
36
40
  "type": "object"
37
41
  },
42
+ "metaKey": {
43
+ "description": "Meta (Cmd) key held during activation (false for programmatic select()).",
44
+ "type": "boolean"
45
+ },
46
+ "shiftKey": {
47
+ "description": "Shift key held during activation (false for programmatic select()).",
48
+ "type": "boolean"
49
+ },
38
50
  "text": {
39
51
  "description": "Item text content.",
40
52
  "type": "string"
@@ -13,8 +13,14 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export interface TreeSelectEventDetail {
16
+ /** Ctrl key held during activation (false for programmatic select()). */
17
+ ctrlKey: boolean;
16
18
  /** Selected tree-item element. */
17
19
  item: Record<string, unknown>;
20
+ /** Meta (Cmd) key held during activation (false for programmatic select()). */
21
+ metaKey: boolean;
22
+ /** Shift key held during activation (false for programmatic select()). */
23
+ shiftKey: boolean;
18
24
  /** Item text content. */
19
25
  text: string;
20
26
  /** Item value attribute. */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * <tree-ui> behavioral tests.
3
+ *
4
+ * FB-46 (v0.5.19): tree-select detail now forwards ctrlKey/metaKey/shiftKey
5
+ * from the originating MouseEvent or KeyboardEvent. Programmatic select()
6
+ * calls (no event) surface all three as false.
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
10
+
11
+ beforeAll(async () => {
12
+ await import('./tree.js');
13
+ });
14
+
15
+ describe('<tree-ui> tree-select forwards modifier keys (FB-46)', () => {
16
+ let host;
17
+ let tree;
18
+
19
+ beforeEach(async () => {
20
+ host = document.createElement('div');
21
+ document.body.appendChild(host);
22
+
23
+ tree = document.createElement('tree-ui');
24
+ const a = document.createElement('tree-item-ui');
25
+ a.setAttribute('text', 'Alpha');
26
+ a.setAttribute('value', 'a');
27
+ const b = document.createElement('tree-item-ui');
28
+ b.setAttribute('text', 'Beta');
29
+ b.setAttribute('value', 'b');
30
+ tree.appendChild(a);
31
+ tree.appendChild(b);
32
+ host.appendChild(tree);
33
+
34
+ // Let connectedCallback + #stamp() settle.
35
+ await new Promise((r) => setTimeout(r, 30));
36
+ });
37
+
38
+ afterEach(() => {
39
+ host.remove();
40
+ });
41
+
42
+ it('programmatic select() emits ctrlKey/metaKey/shiftKey = false', () => {
43
+ let received = null;
44
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
45
+
46
+ const alpha = tree.querySelector('tree-item-ui[value="a"]');
47
+ tree.select(alpha);
48
+
49
+ expect(received).not.toBeNull();
50
+ expect(received.item).toBe(alpha);
51
+ expect(received.text).toBe('Alpha');
52
+ expect(received.value).toBe('a');
53
+ expect(received.ctrlKey).toBe(false);
54
+ expect(received.metaKey).toBe(false);
55
+ expect(received.shiftKey).toBe(false);
56
+ });
57
+
58
+ it('select(item, event) forwards Mouse/KeyboardEvent modifier flags into detail', () => {
59
+ let received = null;
60
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
61
+
62
+ const alpha = tree.querySelector('tree-item-ui[value="a"]');
63
+ // Synthesize an event with all three modifiers held.
64
+ const fakeEvent = new MouseEvent('click', {
65
+ ctrlKey: true,
66
+ metaKey: true,
67
+ shiftKey: true,
68
+ });
69
+ tree.select(alpha, fakeEvent);
70
+
71
+ expect(received.ctrlKey).toBe(true);
72
+ expect(received.metaKey).toBe(true);
73
+ expect(received.shiftKey).toBe(true);
74
+ });
75
+
76
+ it('detail shape stays backward-compatible (existing fields unchanged)', () => {
77
+ let received = null;
78
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
79
+
80
+ const beta = tree.querySelector('tree-item-ui[value="b"]');
81
+ tree.select(beta);
82
+
83
+ // Existing consumers destructure { item, text, value } — those keys
84
+ // MUST remain present + carry the expected values.
85
+ const { item, text, value } = received;
86
+ expect(item).toBe(beta);
87
+ expect(text).toBe('Beta');
88
+ expect(value).toBe('b');
89
+ });
90
+
91
+ it('partial modifier state passes through (single key held)', () => {
92
+ let received = null;
93
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
94
+
95
+ const alpha = tree.querySelector('tree-item-ui[value="a"]');
96
+ const onlyMeta = new MouseEvent('click', { metaKey: true });
97
+ tree.select(alpha, onlyMeta);
98
+
99
+ expect(received.ctrlKey).toBe(false);
100
+ expect(received.metaKey).toBe(true);
101
+ expect(received.shiftKey).toBe(false);
102
+ });
103
+ });