@adia-ai/web-components 0.7.2 → 0.7.3

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.3] — 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - **Parametric named gap scale `xs`–`xl` (`--a-gap-k`)** — the named gap vocabulary is now a complete, parametric 5-step scale: `--a-gap-{xs,sm,md,lg,xl}` = `half + half·--a-gap-k` (a fixed floor + a k-scaled half). The new `--a-gap-k` knob (`@property`, default 1) is a root/provider-level scale control with the same semantics as `--a-density` (subtree override is inert — set it at `:root`/a top provider). At k=1: **4/8/12/16/20px**. `gap="xs"` and `gap="xl"` now resolve (they were documented in yaml but unimplemented — silently did nothing in both the CSS `[gap]` path and the JS `@bp` responsive path). At k=1 the named scale coincides with the integer scale: xs=1, sm=2, md=3, lg=4, xl=5. (spec GS-P2-SPEC-2 §5.2)
8
+
9
+ ### Changed
10
+
11
+ - **Behavior change (MINOR): explicit `[gap="sm|md|lg"]` resolves smaller** — `gap="sm|md|lg"` on layout primitives (col/row/grid) now resolves to `--a-gap-{sm,md,lg}` = **8/12/16px** (was the hardcoded `--a-space-4/6/8` = 16/24/32px). This unifies the three gap paths that previously disagreed — explicit `[gap="md"]` was 24px while the responsive (`gap="md@bp"`, JS `_gapToCss`) and ambient (`[size]`) paths were 12px; all three are now `--a-gap-md`. Integer `gap="N"` is unchanged (literal `--a-space-N`, k-independent). `[size]` / `<card-ui size="…">` unaffected. (audit C1/C2)
12
+ - **`[gap]` no longer leaks into nested layout primitives (audit C3)** — `[gap="N"]` now sets a NON-inheriting `--a-gap-self` (`@property{syntax:"*";inherits:false}`); the inheriting `--a-gap` is reserved for the *ambient* `[size]`/`[prose]`/`:root` cascade. The six gap-readers (col/row/grid/card/swiper/stat) resolve `var(--a-gap-self, var(--a-gap))` — explicit-on-self wins, else the inherited ambient. Previously a parent's explicit `gap="8"` bled into a nested `<row-ui>` that declared no gap of its own. Verified via `scripts/qa/cascade-gap-self-probe.mjs` (5 truth-table rows + parametric + coincidence, all green).
13
+ - **`col`/`row`/`grid` yaml gap docs + layout demo reconciled to impl** — documented the two grammars (named parametric + integer 0–16), fixed grid's range claim (`0–12`→`0–16`), dropped the phantom-value framing (`xs`/`xl` real now). `layout.examples.html`: Properties tables corrected, broken `gap="none"` demo → `gap="0"`, col demo filled out to the complete `xs`→`xl` scale. Sidecars regenerated.
14
+
15
+ ### Fixed
16
+
17
+ - **`<tree-item-ui>` adopts interpolated `[slot="actions"]`/`[slot="caret"]` children (FB-96)** — the FB-89 actions-adoption used a `this.querySelectorAll(':scope > [slot="actions"]')` direct-child query that ran once at `#stamp()` time, so a `<button-ui slot="actions">` rendered through the template engine (`.map()`/`repeat()`/conditional) was missed twice over: it arrives in a *later* reactive tick (after `#stamp`) **and** nested in a `display:contents` wrapper span. The un-adopted button stayed a bare `role="button"` child of the `group`/`tree`, tripping axe `aria-required-children` even after the FB-94 `role="presentation"` fix (which addresses role-flattening, not structural adoption). `#stamp()` now stamps an empty placeholder and a re-entrancy-guarded `MutationObserver` adopts the real `[slot="actions"]`/`[slot="caret"]` into the row whenever they appear, via a wrapper-piercing "logical children" walk that stops at nested `tree-item-ui` boundaries (so a child item's actions aren't mis-adopted) and drops the placeholder once a real child lands. +2 regression tests; verified with axe-core (`.map()` tree + interpolated actions button → 0 `aria-required-children`). The structural-adoption sibling of the FB-92 `menu #show()` fix — same `:scope >` + `display:contents` trap. (Tokens Studio / color-app FB-96)
18
+
19
+ ### Changed — styles + build
20
+
21
+ - **Gap scale + grammar span `styles/` → `components/` → `patterns/` → `dist/`** — the parametric `--a-gap-k` scale is defined in `styles/` (foundation tokens); the `[gap]` grammar applies on `components/col` / `components/row` / `components/grid`; `patterns/` demos were updated to the named scale; and the `dist/` CDN bundles (`web-components.min.css` + `web-components.min.js`) were regenerated.
22
+
3
23
  ## [0.7.2] — 2026-06-01
4
24
 
5
25
  ### Fixed
@@ -8,7 +8,7 @@
8
8
  :where(:scope) {
9
9
  /* ── Spacing ── */
10
10
  --card-inset-default: var(--a-inset);
11
- --card-gap-default: var(--a-gap);
11
+ --card-gap-default: var(--a-gap-self, var(--a-gap));
12
12
  --card-header-gap-default: var(--a-space-2);
13
13
  --card-footer-gap-default: var(--a-space-2);
14
14
 
@@ -22,7 +22,7 @@
22
22
  "const": "Col"
23
23
  },
24
24
  "gap": {
25
- "description": "Gap between children. Named scale (xs/sm/md/lg/xl) or numeric spacing rung (\"1\"…\"16\", mapped to --a-space-N). Accepts `@bp` notation: gap=\"2 4@md\" = 2 below md, 4 from md upward.",
25
+ "description": "Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl) and an integer space-rung (\"0\"…\"10\", \"12\", \"16\", mapped to --a-space-N). The named scale is parametric (half + half·--a-gap-k, = 4/8/12/16/20px at the default k=1); the integer rungs are literal / k-independent. At k=1 the two coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap=\"2 4@md\" = 2 below md, 4 from md upward.",
26
26
  "type": "string",
27
27
  "default": "md"
28
28
  },
@@ -1,6 +1,6 @@
1
1
  @scope (col-ui) {
2
2
  :where(:scope) {
3
- --col-gap-default: var(--a-gap);
3
+ --col-gap-default: var(--a-gap-self, var(--a-gap));
4
4
  --col-justify-default: flex-start;
5
5
  --col-align-default: stretch;
6
6
  }
@@ -23,7 +23,7 @@ import { UIElement } from '../../core/element.js';
23
23
  export class UICol extends UIElement {
24
24
  /** Cross-axis alignment (start/center/end/stretch). Accepts `@bp` notation: align="stretch center@sm" applies stretch below sm, center from sm up. */
25
25
  align: string;
26
- /** Gap between children. Named scale (xs/sm/md/lg/xl) or numeric spacing rung ("1"…"16", mapped to --a-space-N). Accepts `@bp` notation: gap="2 4@md" = 2 below md, 4 from md upward. */
26
+ /** Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl) and an integer space-rung ("0"…"10", "12", "16", mapped to --a-space-N). The named scale is parametric (half + half·--a-gap-k, = 4/8/12/16/20px at the default k=1); the integer rungs are literal / k-independent. At k=1 the two coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap="2 4@md" = 2 below md, 4 from md upward. */
27
27
  gap: string;
28
28
  /** Fills remaining space in a flex parent (e.g. inside a Row). CSS-only attribute via :scope[grow] in col.css. */
29
29
  grow: boolean;
@@ -25,9 +25,12 @@ props:
25
25
  default: stretch
26
26
  gap:
27
27
  description: >-
28
- Gap between children. Named scale (xs/sm/md/lg/xl) or numeric spacing
29
- rung ("1"…"16", mapped to --a-space-N). Accepts `@bp` notation:
30
- gap="2 4@md" = 2 below md, 4 from md upward.
28
+ Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl) and an
29
+ integer space-rung ("0"…"10", "12", "16", mapped to --a-space-N). The named
30
+ scale is parametric (half + half·--a-gap-k, = 4/8/12/16/20px at the default
31
+ k=1); the integer rungs are literal / k-independent. At k=1 the two coincide:
32
+ xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap="2 4@md" = 2 below
33
+ md, 4 from md upward.
31
34
  type: string
32
35
  default: md
33
36
  grow:
@@ -27,7 +27,7 @@
27
27
  "const": "Grid"
28
28
  },
29
29
  "gap": {
30
- "description": "Grid gap. Accepts numeric space-scale values (0–12) or named sizes (xs/sm/md/lg/xl). Responsive notation supported: \"2 4@md\" = 2 below md, 4 from md upward.",
30
+ "description": "Grid gap (row + column). Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung (\"0\"…\"10\", \"12\", \"16\", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Responsive `@bp` notation supported: \"2 4@md\" = 2 below md, 4 from md upward.",
31
31
  "type": "string",
32
32
  "default": "md"
33
33
  },
@@ -1,8 +1,9 @@
1
1
  @scope (grid-ui) {
2
2
  :where(:scope) {
3
- /* Read `--a-gap` so the universal `[gap="N"]` rules (tokens.css)
4
- reach the grid; defaults to `--a-gap-md` at :root. */
5
- --grid-gap-default: var(--a-gap);
3
+ /* Explicit-then-ambient: `[gap="N"]` sets non-inheriting
4
+ `--a-gap-self`; `[size]`/`:root` set inheriting `--a-gap` (base
5
+ `--a-gap-md`). See styles/api/sizing.css + spec §5.2. */
6
+ --grid-gap-default: var(--a-gap-self, var(--a-gap));
6
7
  --grid-column-gap-default: var(--grid-gap, var(--grid-gap-default));
7
8
  --grid-row-gap-default: var(--grid-gap, var(--grid-gap-default));
8
9
  }
@@ -17,7 +17,7 @@ export class UIGrid extends UIElement {
17
17
  columnGap: string;
18
18
  /** Number of equal columns (1–6), "auto-fill", or "auto-fit". Accepts responsive `@bp` notation: "1 2@sm 4@lg" = 1 on xs, 2 from sm, 4 from lg/xl. Unannotated value is the mobile-first base. */
19
19
  columns: string;
20
- /** Grid gap. Accepts numeric space-scale values (0–12) or named sizes (xs/sm/md/lg/xl). Responsive notation supported: "2 4@md" = 2 below md, 4 from md upward. */
20
+ /** Grid gap (row + column). Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung ("0"…"10", "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Responsive `@bp` notation supported: "2 4@md" = 2 below md, 4 from md upward. */
21
21
  gap: string;
22
22
  /** Minimum track width for columns="auto-fit"/"auto-fill" (any CSS length, e.g. "240px", "16rem"). Sets the minmax() floor so cards don't shrink below it before wrapping; unset uses the 12rem default. No effect on numeric columns. */
23
23
  minColumnWidth: string;
@@ -30,9 +30,11 @@ props:
30
30
  default: "3"
31
31
  gap:
32
32
  description: >-
33
- Grid gap. Accepts numeric space-scale values (0–12) or named sizes
34
- (xs/sm/md/lg/xl). Responsive notation supported: "2 4@md" = 2 below
35
- md, 4 from md upward.
33
+ Grid gap (row + column). Two grammars: a NAMED scale (xs/sm/md/lg/xl,
34
+ parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung
35
+ ("0"…"10", "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4,
36
+ xl=5. Responsive `@bp` notation supported: "2 4@md" = 2 below md, 4 from md
37
+ upward.
36
38
  type: string
37
39
  default: md
38
40
  minColumnWidth:
@@ -27,7 +27,7 @@
27
27
  "default": false
28
28
  },
29
29
  "gap": {
30
- "description": "Gap between children. Named scale (xs/sm/md/lg/xl) or numeric spacing rung (\"1\"…\"16\"). Accepts `@bp` notation: gap=\"2 4@md\".",
30
+ "description": "Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung (\"0\"…\"10\", \"12\", \"16\", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap=\"2 4@md\".",
31
31
  "type": "string",
32
32
  "default": "md"
33
33
  },
@@ -1,9 +1,11 @@
1
1
  @scope (row-ui) {
2
2
  :where(:scope) {
3
- /* `--a-gap` defaults to `--a-gap-md` at :root in tokens.css; the
4
- universal `[gap="N"]` rules override it on the element, so reading
5
- `--a-gap` here picks up `<row-ui gap="…">` automatically. */
6
- --row-gap-default: var(--a-gap);
3
+ /* Gap resolves explicit-then-ambient: `[gap="N"]` sets the
4
+ non-inheriting `--a-gap-self`; `[size]`/`:root` set the inheriting
5
+ ambient `--a-gap` (base `--a-gap-md`). Reading
6
+ `var(--a-gap-self, var(--a-gap))` picks up `<row-ui gap="…">` on
7
+ this element, else falls through to the inherited ambient. */
8
+ --row-gap-default: var(--a-gap-self, var(--a-gap));
7
9
  --row-justify-default: flex-start;
8
10
  --row-align-default: center;
9
11
  --row-drag-bg-active-default: var(--a-accent-muted);
@@ -24,7 +24,7 @@ export class UIRow extends UIElement {
24
24
  align: string;
25
25
  /** Enables drag handle + cursor:grab. Wires the draggable trait; dispatches drag-end. */
26
26
  draggable: boolean;
27
- /** Gap between children. Named scale (xs/sm/md/lg/xl) or numeric spacing rung ("1"…"16"). Accepts `@bp` notation: gap="2 4@md". */
27
+ /** Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung ("0"…"10", "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap="2 4@md". */
28
28
  gap: string;
29
29
  /** Fills remaining space in a flex parent. CSS-only attribute via :scope[grow] in row.css. */
30
30
  grow: boolean;
@@ -29,8 +29,10 @@ props:
29
29
  reflect: true
30
30
  gap:
31
31
  description: >-
32
- Gap between children. Named scale (xs/sm/md/lg/xl) or numeric spacing
33
- rung ("1"…"16"). Accepts `@bp` notation: gap="2 4@md".
32
+ Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric
33
+ via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung ("0"…"10",
34
+ "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5.
35
+ Accepts `@bp` notation: gap="2 4@md".
34
36
  type: string
35
37
  default: md
36
38
  grow:
@@ -13,7 +13,7 @@
13
13
  --stat-icon-fg-default: var(--a-fg-muted);
14
14
 
15
15
  /* ── Spacing ── */
16
- --stat-column-gap-default: var(--a-gap);
16
+ --stat-column-gap-default: var(--a-gap-self, var(--a-gap));
17
17
  --stat-row-gap-default: var(--a-gap-sm);
18
18
  text-align: start; /* §text-align-reset — blocks inheritance from centered ancestors */
19
19
  }
@@ -21,7 +21,7 @@
21
21
  @scope (swiper-ui) {
22
22
  :where(:scope) {
23
23
  /* ── Layout ── */
24
- --swiper-gap-default: var(--a-gap);
24
+ --swiper-gap-default: var(--a-gap-self, var(--a-gap));
25
25
  --swiper-peek-default: var(--a-space-8);
26
26
  --swiper-snap-default: start;
27
27
  --swiper-columns-default: 1;
@@ -266,6 +266,11 @@ export class UITreeItem extends UIElement {
266
266
 
267
267
  static template = () => null;
268
268
 
269
+ // FB-96: late-adoption of interpolated [slot="actions"]/[slot="caret"]
270
+ // children — see connected() + #adoptSlotted().
271
+ #slotObserver = null;
272
+ #adopting = false;
273
+
269
274
  get hasChildren() {
270
275
  return this.querySelector(':scope > tree-item-ui') !== null;
271
276
  }
@@ -303,6 +308,17 @@ export class UITreeItem extends UIElement {
303
308
  row.setAttribute('role', 'treeitem');
304
309
  if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '0');
305
310
  }
311
+
312
+ // FB-96: declarative [slot="actions"]/[slot="caret"] children rendered via
313
+ // the template engine (`.map()`/`repeat()`/conditional) arrive in a LATER
314
+ // reactive tick — AFTER #stamp() — nested in the engine's display:contents
315
+ // wrapper spans. So neither a connect-time read nor a `:scope >` direct-child
316
+ // query sees them, and e.g. a `<button-ui slot="actions">` is left as a bare
317
+ // role=button child of the group/tree (axe `aria-required-children`). Adopt
318
+ // what's present now, then observe for the late arrivals.
319
+ this.#adoptSlotted();
320
+ this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
321
+ this.#slotObserver.observe(this, { childList: true, subtree: true });
306
322
  }
307
323
 
308
324
  #stamp() {
@@ -310,15 +326,18 @@ export class UITreeItem extends UIElement {
310
326
  row.setAttribute('slot', 'row');
311
327
  row.setAttribute('tabindex', '0');
312
328
 
313
- // Caret — adopt a declarative [slot="caret"] child if the consumer supplied
314
- // one, else stamp the default. (Adopt-or-stamp; same pattern as actions.)
315
- const declaredCaret = this.querySelector(':scope > [slot="caret"]');
329
+ // Caret — adopt a declarative [slot="caret"] child if one is already present
330
+ // (incl. inside the engine's display:contents wrappers, FB-96), else stamp
331
+ // the default, marked so #adoptSlotted() can drop it if a declared caret
332
+ // arrives late.
333
+ const declaredCaret = this.#logicalSlotChildren('caret')[0];
316
334
  if (declaredCaret) {
317
335
  row.appendChild(declaredCaret);
318
336
  } else {
319
337
  const caret = document.createElement('icon-ui');
320
338
  caret.setAttribute('slot', 'caret');
321
339
  caret.setAttribute('name', 'caret-right');
340
+ caret.dataset.treeCaretDefault = '1';
322
341
  row.appendChild(caret);
323
342
  }
324
343
 
@@ -343,19 +362,16 @@ export class UITreeItem extends UIElement {
343
362
  if (this.badge) badgeEl.textContent = this.badge;
344
363
  row.appendChild(badgeEl);
345
364
 
346
- // Actions — adopt pre-existing declarative [slot="actions"] children into
347
- // the row (FEEDBACK-89) so per-row action buttons land in the styled,
348
- // hover-revealed actions area; else stamp an empty placeholder. The host
349
- // #onClick already excludes [slot="actions"] * from row selection, so
350
- // adoption is click-safe.
351
- const declaredActions = this.querySelectorAll(':scope > [slot="actions"]');
352
- if (declaredActions.length) {
353
- for (const a of declaredActions) row.appendChild(a);
354
- } else {
355
- const actions = document.createElement('span');
356
- actions.setAttribute('slot', 'actions');
357
- row.appendChild(actions);
358
- }
365
+ // Actions — stamp an empty placeholder; the real declarative [slot="actions"]
366
+ // children (FEEDBACK-89) are moved into the row by #adoptSlotted(), which
367
+ // runs in connected() AND on later mutations, so interpolated buttons that
368
+ // arrive after #stamp (nested in the engine's display:contents wrappers,
369
+ // FB-96) get adopted too. The placeholder is dropped once a real one lands.
370
+ // (#onClick excludes [slot="actions"] * from row selection — adoption-safe.)
371
+ const actions = document.createElement('span');
372
+ actions.setAttribute('slot', 'actions');
373
+ actions.dataset.treeActionsPlaceholder = '1';
374
+ row.appendChild(actions);
359
375
 
360
376
  this.prepend(row);
361
377
  }
@@ -385,4 +401,61 @@ export class UITreeItem extends UIElement {
385
401
  const badgeEl = row.querySelector('[slot="badge"]');
386
402
  if (badgeEl) badgeEl.textContent = this.badge || '';
387
403
  }
404
+
405
+ // ── FB-96: wrapper-piercing, late adoption of slotted row children ──
406
+
407
+ /**
408
+ * Collect this item's logical `[slot="<slot>"]` children — direct, or nested
409
+ * inside the template engine's `display:contents` wrapper spans (which carry
410
+ * `role="presentation"` since 0.7.2) — but NOT inside a nested `<tree-item-ui>`
411
+ * (a plain descendant query would wrongly grab a child item's actions/caret).
412
+ */
413
+ #logicalSlotChildren(slot) {
414
+ const out = [];
415
+ const walk = (parent) => {
416
+ for (const ch of parent.children) {
417
+ if (ch.matches('tree-item-ui')) continue; // nested-item boundary
418
+ if (ch.getAttribute('slot') === slot) { out.push(ch); continue; }
419
+ // pierce the engine's transparent wrapper spans
420
+ if (ch.matches('[role="presentation"]') || ch.style?.display === 'contents') walk(ch);
421
+ }
422
+ };
423
+ walk(this);
424
+ return out;
425
+ }
426
+
427
+ /**
428
+ * Move declarative `[slot="actions"]`/`[slot="caret"]` children into the
429
+ * stamped row. Idempotent + re-entrancy-guarded (it mutates `this`, which
430
+ * re-fires #slotObserver). Drops the auto-stamped placeholder/default once a
431
+ * real child is adopted. (FEEDBACK-89 + FB-96.)
432
+ */
433
+ #adoptSlotted() {
434
+ if (this.#adopting) return;
435
+ const row = this.querySelector(':scope > [slot="row"]');
436
+ if (!row) return;
437
+ this.#adopting = true;
438
+ try {
439
+ const actions = this.#logicalSlotChildren('actions')
440
+ .filter((el) => !el.dataset.treeActionsPlaceholder && el.parentElement !== row);
441
+ if (actions.length) {
442
+ row.querySelector(':scope > [slot="actions"][data-tree-actions-placeholder]')?.remove();
443
+ for (const el of actions) row.appendChild(el);
444
+ }
445
+ const caret = this.#logicalSlotChildren('caret')
446
+ .find((el) => !el.dataset.treeCaretDefault && el.parentElement !== row);
447
+ if (caret) {
448
+ row.querySelector(':scope > [slot="caret"][data-tree-caret-default]')?.remove();
449
+ row.prepend(caret);
450
+ }
451
+ } finally {
452
+ this.#slotObserver?.takeRecords(); // drain self-mutations — no re-entrant pass
453
+ this.#adopting = false;
454
+ }
455
+ }
456
+
457
+ disconnected() {
458
+ this.#slotObserver?.disconnect();
459
+ this.#slotObserver = null;
460
+ }
388
461
  }
@@ -11,6 +11,7 @@ import { html, stamp } from '../../core/template.js';
11
11
 
12
12
  beforeAll(async () => {
13
13
  await import('./tree.js');
14
+ await import('../button/button.js');
14
15
  });
15
16
 
16
17
  describe('<tree-ui> tree-select forwards modifier keys (FB-46)', () => {
@@ -261,3 +262,51 @@ describe('<tree-item-ui> ARIA tree containment (FEEDBACK-91)', () => {
261
262
  expect(parent.querySelector(':scope > [slot="row"]').hasAttribute('aria-selected')).toBe(false);
262
263
  });
263
264
  });
265
+
266
+ describe('<tree-item-ui> adopts interpolated [slot="actions"] into the row (FEEDBACK-96)', () => {
267
+ let host;
268
+ const settle = () => new Promise((r) => setTimeout(r, 40));
269
+ beforeEach(() => { host = document.createElement('div'); document.body.appendChild(host); });
270
+ afterEach(() => host.remove());
271
+
272
+ it('moves a `.map()`-rendered actions button into the treeitem row (not a stray child of the group)', async () => {
273
+ const PALS = [{ id: 'n', name: 'Neutral' }, { id: 'b', name: 'Brand' }];
274
+ stamp(html`
275
+ <tree-ui>
276
+ <tree-item-ui text="Colors" value="colors" open>
277
+ ${html`<button-ui slot="actions" icon="plus" title="Add"></button-ui>`}
278
+ ${PALS.map((p) => html`<tree-item-ui text="${p.name}" value="${p.id}"></tree-item-ui>`)}
279
+ </tree-item-ui>
280
+ </tree-ui>
281
+ `, host);
282
+ await settle();
283
+
284
+ const colors = host.querySelector('tree-item-ui[value="colors"]');
285
+ const row = colors.querySelector(':scope > [slot="row"]');
286
+ const btn = colors.querySelector('button-ui[slot="actions"]');
287
+ expect(btn).not.toBeNull();
288
+ // The fix: the interpolated button (wrapped in a display:contents span) is
289
+ // adopted INTO the row. Pre-fix, #stamp's `:scope >` query missed it (the
290
+ // button arrives after #stamp, inside a wrapper span) and it stayed a bare
291
+ // child of the group/tree → axe aria-required-children.
292
+ expect(btn.parentElement).toBe(row);
293
+ // Auto-stamped empty placeholder dropped — exactly one actions slot in the row.
294
+ expect(row.querySelectorAll(':scope > [slot="actions"]').length).toBe(1);
295
+ // Nested items NOT mis-adopted as actions (the tree-item-ui boundary holds).
296
+ expect(host.querySelectorAll('tree-item-ui').length).toBe(3);
297
+ });
298
+
299
+ it('is idempotent — no double-adopt across settles', async () => {
300
+ stamp(html`
301
+ <tree-ui>
302
+ <tree-item-ui text="Colors" value="colors" open>
303
+ ${html`<button-ui slot="actions" icon="plus"></button-ui>`}
304
+ </tree-item-ui>
305
+ </tree-ui>
306
+ `, host);
307
+ await settle();
308
+ await settle();
309
+ const row = host.querySelector('tree-item-ui[value="colors"] > [slot="row"]');
310
+ expect(row.querySelectorAll(':scope > [slot="actions"]').length).toBe(1);
311
+ });
312
+ });