@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.
- package/CHANGELOG.md +22 -0
- package/USAGE.md +68 -0
- package/components/command/USAGE.md +160 -0
- package/components/command/class.js +23 -6
- package/components/command/command.a2ui.json +5 -1
- package/components/command/command.d.ts +2 -0
- package/components/command/command.test.js +99 -0
- package/components/command/command.yaml +9 -1
- package/components/description-list/description-list.a2ui.json +3 -2
- package/components/description-list/description-list.css +16 -0
- package/components/description-list/description-list.d.ts +3 -2
- package/components/description-list/description-list.test.js +72 -0
- package/components/description-list/description-list.yaml +10 -1
- package/components/swatch/class.js +109 -14
- package/components/swatch/swatch.test.js +242 -10
- package/components/tree/class.js +15 -4
- package/components/tree/tree.a2ui.json +13 -1
- package/components/tree/tree.d.ts +6 -0
- package/components/tree/tree.test.js +103 -0
- package/components/tree/tree.yaml +14 -1
- package/core/template.test.js +101 -1
- package/package.json +1 -1
|
@@ -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):
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
//
|
|
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(
|
|
203
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
267
|
-
expect(
|
|
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
|
+
});
|
package/components/tree/class.js
CHANGED
|
@@ -53,13 +53,24 @@ export class UITree extends UIElement {
|
|
|
53
53
|
return this.querySelector('tree-item-ui[selected]');
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
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: {
|
|
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
|
+
});
|