@adia-ai/web-components 0.7.0 → 0.7.1

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,19 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.1] — 2026-05-31
4
+
5
+ ### Fixed
6
+
7
+ - **`<input-ui>` leading/trailing affordance vertical centering** — the affordance slots used `align-self: stretch`, which top-aligned any child carrying a definite `height` (a `<kbd-ui slot="trailing">` ⌘K hint sat ~4px high in a 30px chrome) and also overrode a `<button-ui>` child's own `--button-height`. Changed to `align-self: center` so each affordance keeps its token height and sits on the field's vertical center. Affects every intrinsic-height affordance (kbd / icon / badge); button affordances are unchanged (they already filled the chrome). (bug-60)
8
+ - **`<tree-ui>` nested items now satisfy the WAI-ARIA tree pattern** — the `tree-item-ui` host is now `role="group"` and its focusable `[slot="row"]` is the `role="treeitem"` (with `aria-expanded`/`aria-selected` moved onto the row). Previously the host was the `treeitem` and nested items had no `group`/`tree` required parent, tripping axe-core `aria-required-parent` (critical) on every nested node. Done without re-homing children, so it holds for static markup AND template-engine `.map()` trees; no CSS/yaml change. Validated with real axe-core (0 violations). (FEEDBACK-91)
9
+ - **`<menu-ui>` renders dynamically-generated items** — `#show()` collected items with a direct-child `:scope >` query, which missed `menu-item-ui` rendered via `.map()`/`repeat()` (the template engine nests them in `display:contents` wrapper spans) → the popover opened empty. Now uses a descendant query (skipping items already relocated into the popover), matching `#hide()` and resolving the show/hide asymmetry. Static children unaffected. (FEEDBACK-92)
10
+ - **`<input-ui>` reactive `prefix=`/`suffix=` bindings resolve** — a reactive attribute binding (`suffix="${expr}"`) leaked the literal template placeholder `{{p:N}}` as affix text, because the shell was snapshotted once in `connected()` before the binding resolved and never re-synced. `render()` now re-resolves the prefix/suffix slots each cycle (handling text↔icon affixes), mirroring the slider-ui FB-45 fix. Literal and `.property` affixes are unchanged. (FEEDBACK-93)
11
+ - **`dist/` CDN bundles regenerated** (`web-components.min.css` + `web-components.min.js`) to carry the input/tree/menu fixes above.
12
+
13
+ ### Changed — demos
14
+
15
+ - **`<field-ui>` inline-layout demo** — the "Results: 12" count moved from a field-level `<span slot="trailing">` (beside the input) into the input's `suffix` prop, so it renders **in-chrome, left of the Clear button** (DOM order value → `suffix` → `trailing`). The demo now shows all three affordances inside the input chrome and the note explains the suffix-vs-trailing split. (bug-60)
16
+
3
17
  ## [0.7.0] — 2026-05-31
4
18
 
5
19
  ### Added — component features from the docs-QA pass
@@ -327,6 +327,41 @@ export class UIInput extends UIFormElement {
327
327
  }
328
328
  }
329
329
 
330
+ /**
331
+ * FEEDBACK-93 — re-resolve the prefix/suffix slots from the live property.
332
+ *
333
+ * `connected()` builds the shell ONCE (`this.innerHTML = #shellHTML()`),
334
+ * reading `this.prefix` / `this.suffix` at that moment. For a reactive
335
+ * attribute binding (`suffix="${expr}"`) the framework writes the literal
336
+ * placeholder marker (`{{p:N}}`) into the attribute first and resolves it on
337
+ * a later reactive tick — so the marker gets baked into the slot as text and,
338
+ * since `render()` never rebuilt the affixes, it stuck. This mirrors the
339
+ * slider-ui FEEDBACK-45 fix, but input affixes can be icon names
340
+ * (`renderAffix` → `<icon-ui>`), so we handle text↔icon transitions here.
341
+ * The static-deploy icon-registry race is still owned by `#promoteAffixes()`.
342
+ */
343
+ #syncAffixSlots() {
344
+ for (const which of ['prefix', 'suffix']) {
345
+ const el = this.querySelector(`:scope > [slot="field"] > [slot="${which}"]`);
346
+ if (!el) continue;
347
+ const value = this[which] || '';
348
+ const icon = el.querySelector(':scope > icon-ui');
349
+ if (value && isIconName(value)) {
350
+ if (icon) {
351
+ if (icon.getAttribute('name') !== value) icon.setAttribute('name', value);
352
+ } else {
353
+ el.replaceChildren();
354
+ const created = document.createElement('icon-ui');
355
+ created.setAttribute('name', value);
356
+ el.appendChild(created);
357
+ }
358
+ } else {
359
+ if (icon) el.replaceChildren();
360
+ if (el.textContent !== value) el.textContent = value;
361
+ }
362
+ }
363
+ }
364
+
330
365
  render() {
331
366
  if (!this.#textEl) return;
332
367
 
@@ -360,6 +395,9 @@ export class UIInput extends UIFormElement {
360
395
 
361
396
  if (this.#labelEl) this.#labelEl.textContent = this.label || '';
362
397
 
398
+ // Re-resolve prefix/suffix from the live property each render (FEEDBACK-93).
399
+ this.#syncAffixSlots();
400
+
363
401
  if (this.label) {
364
402
  this.removeAttribute('aria-label');
365
403
  } else if (this.placeholder) {
@@ -304,10 +304,14 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
304
304
  [slot="field"] > [slot="leading"],
305
305
  [slot="field"] > [slot="trailing"] {
306
306
  flex-shrink: 0;
307
- /* Sized to chrome height per yaml contract. The button-ui or icon-ui
308
- child receives the sizing tokens; we just constrain the slot box
309
- and align it to the field's baseline. */
310
- align-self: stretch;
307
+ /* Vertically CENTER the affordance in the chrome do not `stretch`.
308
+ Stretch forces the slot box to the full chrome height; for a button-ui
309
+ child that overrode its own `--button-height` (chrome − 4px), and for a
310
+ fixed-height affordance like <kbd-ui> (definite `height`) stretch can't
311
+ grow the box so it pins to flex-start (top) instead — the ⌘K hint sat
312
+ ~4px high (bug-60). `center` lets each child keep its own token height
313
+ and sit on the field's vertical center. */
314
+ align-self: center;
311
315
  display: inline-flex;
312
316
  align-items: center;
313
317
  /* Inline padding moves from [slot="text"] (handled by the field's px)
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url';
15
15
  import { dirname, resolve } from 'node:path';
16
16
  import '../../core/element.js';
17
17
  import './input.js';
18
+ import { html, stamp } from '../../core/template.js';
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const INPUT_CSS = readFileSync(resolve(__dirname, 'input.css'), 'utf8');
@@ -260,3 +261,59 @@ describe('input-ui — CSS source contract: placeholder pseudo is out of flow',
260
261
  );
261
262
  });
262
263
  });
264
+
265
+ describe('input-ui — FEEDBACK-93 reactive prefix/suffix re-sync', () => {
266
+ const settle = () => new Promise((r) => setTimeout(r, 30));
267
+
268
+ beforeEach(() => { document.body.innerHTML = ''; });
269
+
270
+ function suffixOf(el) { return el.querySelector(':scope > [slot="field"] > [slot="suffix"]'); }
271
+ function prefixOf(el) { return el.querySelector(':scope > [slot="field"] > [slot="prefix"]'); }
272
+
273
+ it('resolves a reactive suffix binding instead of leaking the {{p:N}} marker (the bug)', async () => {
274
+ const container = document.createElement('div');
275
+ document.body.appendChild(container);
276
+ // Interpolated attribute → the engine writes {{p:0}} into `suffix` before
277
+ // connected() reads it, then resolves on the update() tick. Pre-fix the
278
+ // baked marker stuck; post-fix render() re-resolves it.
279
+ stamp(html`<input-ui type="number" suffix="${'px'}" value="2"></input-ui>`, container);
280
+ await settle();
281
+
282
+ const el = container.querySelector('input-ui');
283
+ const suffix = suffixOf(el);
284
+ expect(suffix).not.toBeNull();
285
+ expect(suffix.textContent).toBe('px');
286
+ expect(suffix.textContent).not.toMatch(/\{\{p:/);
287
+ });
288
+
289
+ it('resolves a reactive prefix binding', async () => {
290
+ const container = document.createElement('div');
291
+ document.body.appendChild(container);
292
+ stamp(html`<input-ui prefix="${'$'}" value="9.99"></input-ui>`, container);
293
+ await settle();
294
+
295
+ const el = container.querySelector('input-ui');
296
+ expect(prefixOf(el).textContent).toBe('$');
297
+ expect(prefixOf(el).textContent).not.toMatch(/\{\{p:/);
298
+ });
299
+
300
+ it('re-resolves when the bound value later changes', async () => {
301
+ const container = document.createElement('div');
302
+ document.body.appendChild(container);
303
+ stamp(html`<input-ui type="number" suffix="${'px'}" value="2"></input-ui>`, container);
304
+ await settle();
305
+ const el = container.querySelector('input-ui');
306
+ expect(suffixOf(el).textContent).toBe('px');
307
+
308
+ // Simulate the binding resolving to a new value on a later reactive tick.
309
+ el.setAttribute('suffix', 'rem');
310
+ await settle();
311
+ expect(suffixOf(el).textContent).toBe('rem');
312
+ });
313
+
314
+ it('literal suffix still renders (no regression)', async () => {
315
+ const el = mount('<input-ui suffix="%"></input-ui>');
316
+ await settle();
317
+ expect(suffixOf(el).textContent).toBe('%');
318
+ });
319
+ });
@@ -85,9 +85,18 @@ export class UIMenu extends UIElement {
85
85
  if (!trigger) return;
86
86
  const pop = this.#ensurePopover();
87
87
 
88
- // Move menu items into popover (so they render in the top layer).
89
- const items = this.querySelectorAll(':scope > menu-item-ui, :scope > menu-divider-ui');
90
- for (const item of items) pop.appendChild(item);
88
+ // Move menu items into the popover (so they render in the top layer).
89
+ // Use a DESCENDANT query not `:scope >` — so items rendered via the
90
+ // template engine's `.map()` / `repeat()` are collected too: those wrap
91
+ // every interpolated child in a `display:contents` <span> (core/template.js
92
+ // wrap()), making the items grandchildren that a direct-child query skips
93
+ // → an empty popover (FEEDBACK-92). Skip anything already relocated into
94
+ // the popover so a re-entrant #show() (reactive re-render while open)
95
+ // doesn't reorder items. Mirrors the descendant query #hide() already uses.
96
+ const items = this.querySelectorAll('menu-item-ui, menu-divider-ui');
97
+ for (const item of items) {
98
+ if (!pop.contains(item)) pop.appendChild(item);
99
+ }
91
100
 
92
101
  if (!pop.matches(':popover-open')) pop.showPopover?.();
93
102
 
@@ -0,0 +1,130 @@
1
+ /**
2
+ * <menu-ui> behavioral tests.
3
+ *
4
+ * FEEDBACK-92 (2026-05-31): `#show()` collected items with a direct-child
5
+ * `:scope >` query, so items rendered through the template engine's
6
+ * `.map()` / `repeat()` (each wrapped in a `display:contents` <span>) were
7
+ * skipped → the popover opened empty. The fix uses a descendant query
8
+ * (mirroring `#hide()`). These tests pin the dynamic + static paths.
9
+ */
10
+
11
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
12
+ import { html, stamp, repeat } from '../../core/template.js';
13
+
14
+ beforeAll(async () => {
15
+ await import('../../core/element.js');
16
+ await import('./menu.js');
17
+ await import('../button/button.js');
18
+ });
19
+
20
+ const settle = () => new Promise((r) => setTimeout(r, 30));
21
+
22
+ describe('<menu-ui> collects dynamically-rendered items (FEEDBACK-92)', () => {
23
+ let host;
24
+
25
+ beforeEach(() => {
26
+ host = document.createElement('div');
27
+ document.body.appendChild(host);
28
+ });
29
+ afterEach(() => host.remove());
30
+
31
+ function popItems(menu) {
32
+ const pop = menu.querySelector('[data-menu-popover]');
33
+ return pop ? pop.querySelectorAll('menu-item-ui') : [];
34
+ }
35
+
36
+ it('populates the popover from `.map()`-rendered items (the bug)', async () => {
37
+ const ITEMS = [{ id: 'a', text: 'Alpha' }, { id: 'b', text: 'Beta' }];
38
+ // Render the menu (closed) the documented way — items via `.map()`. The
39
+ // consumer opens it later via interaction, after the template has rendered.
40
+ stamp(html`
41
+ <menu-ui>
42
+ <button-ui slot="trigger" text="Open"></button-ui>
43
+ ${ITEMS.map((i) => html`<menu-item-ui .text=${i.text} .value=${i.id}></menu-item-ui>`)}
44
+ </menu-ui>
45
+ `, host);
46
+ await settle();
47
+
48
+ const menu = host.querySelector('menu-ui');
49
+ // The exact bug condition: items exist under the host but are NOT direct
50
+ // children — the template engine nested them inside display:contents spans,
51
+ // which the old `:scope >` query in #show() could not see.
52
+ expect(menu.querySelectorAll('menu-item-ui').length).toBe(2);
53
+ expect(menu.querySelector(':scope > menu-item-ui')).toBeNull();
54
+
55
+ menu.open = true; // user opens the menu post-render
56
+ await settle();
57
+
58
+ const items = popItems(menu);
59
+ expect(items.length).toBe(2);
60
+ expect([...items].map((el) => el.value)).toEqual(['a', 'b']);
61
+ });
62
+
63
+ it('populates the popover from `repeat()`-rendered items', async () => {
64
+ const ITEMS = [{ id: 'x', text: 'Ex' }, { id: 'y', text: 'Why' }, { id: 'z', text: 'Zee' }];
65
+ stamp(html`
66
+ <menu-ui>
67
+ <button-ui slot="trigger" text="Open"></button-ui>
68
+ ${repeat(ITEMS, (i) => i.id, (i) => html`<menu-item-ui .text=${i.text} .value=${i.id}></menu-item-ui>`)}
69
+ </menu-ui>
70
+ `, host);
71
+ await settle();
72
+
73
+ const menu = host.querySelector('menu-ui');
74
+ expect(menu.querySelector(':scope > menu-item-ui')).toBeNull(); // behind wrappers
75
+ menu.open = true;
76
+ await settle();
77
+ expect(popItems(menu).length).toBe(3);
78
+ });
79
+
80
+ it('still populates from static literal children (no regression)', async () => {
81
+ const menu = document.createElement('menu-ui');
82
+ menu.innerHTML = `
83
+ <button-ui slot="trigger" text="Open"></button-ui>
84
+ <menu-item-ui text="Edit" value="edit"></menu-item-ui>
85
+ <menu-divider-ui></menu-divider-ui>
86
+ <menu-item-ui text="Delete" value="delete"></menu-item-ui>`;
87
+ host.appendChild(menu);
88
+ await settle();
89
+ menu.open = true;
90
+ await settle();
91
+
92
+ expect(popItems(menu).length).toBe(2);
93
+ const pop = menu.querySelector('[data-menu-popover]');
94
+ expect(pop.querySelectorAll('menu-divider-ui').length).toBe(1);
95
+ });
96
+
97
+ it('does not absorb the trigger into the popover', async () => {
98
+ const menu = document.createElement('menu-ui');
99
+ menu.innerHTML = `
100
+ <button-ui slot="trigger" text="Open"></button-ui>
101
+ <menu-item-ui text="Edit" value="edit"></menu-item-ui>`;
102
+ host.appendChild(menu);
103
+ await settle();
104
+ menu.open = true;
105
+ await settle();
106
+
107
+ const pop = menu.querySelector('[data-menu-popover]');
108
+ expect(pop.querySelector('[slot="trigger"]')).toBeNull();
109
+ expect(menu.querySelector(':scope > [slot="trigger"]')).not.toBeNull();
110
+ });
111
+
112
+ it('does not reorder items on a re-entrant #show() (open → re-render → still open)', async () => {
113
+ const menu = document.createElement('menu-ui');
114
+ menu.innerHTML = `
115
+ <button-ui slot="trigger" text="Open"></button-ui>
116
+ <menu-item-ui text="One" value="1"></menu-item-ui>
117
+ <menu-item-ui text="Two" value="2"></menu-item-ui>`;
118
+ host.appendChild(menu);
119
+ await settle();
120
+ menu.open = true;
121
+ await settle();
122
+ // Force another render() pass while still open (re-invokes #show()).
123
+ menu.placement = 'top-start';
124
+ await settle();
125
+
126
+ const pop = menu.querySelector('[data-menu-popover]');
127
+ const order = [...pop.querySelectorAll('menu-item-ui')].map((el) => el.value);
128
+ expect(order).toEqual(['1', '2']);
129
+ });
130
+ });
@@ -281,11 +281,28 @@ export class UITreeItem extends UIElement {
281
281
  }
282
282
 
283
283
  connected() {
284
- this.setAttribute('role', 'treeitem');
284
+ // The host is the structural GROUP container; the focusable [slot="row"]
285
+ // is the treeitem (FEEDBACK-91). The WAI-ARIA tree pattern requires every
286
+ // treeitem to be contained by a `group` or `tree`. Roling the host as
287
+ // `group` — rather than wrapping nested children in a new element — keeps
288
+ // the light-DOM structure intact, so it works for BOTH static markup AND
289
+ // template-engine `.map()` children (whose display:contents wrappers must
290
+ // not be re-homed) and sidesteps the parent-before-child upgrade race.
291
+ // axe-core: `tree` accepts `group` as a required child, `group` has no
292
+ // required parent, and the row's required `group` parent is the host.
293
+ this.setAttribute('role', 'group');
285
294
 
286
295
  if (!this.querySelector(':scope > [slot="row"]')) {
287
296
  this.#stamp();
288
297
  }
298
+
299
+ // Mark the row as the treeitem (whether we stamped it or the consumer
300
+ // supplied a declarative [slot="row"]) and ensure it stays focusable.
301
+ const row = this.querySelector(':scope > [slot="row"]');
302
+ if (row) {
303
+ row.setAttribute('role', 'treeitem');
304
+ if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '0');
305
+ }
289
306
  }
290
307
 
291
308
  #stamp() {
@@ -350,12 +367,15 @@ export class UITreeItem extends UIElement {
350
367
  // Update depth-based indent
351
368
  row.style.paddingInlineStart = `${this.depth * 16 + 4}px`;
352
369
 
353
- // Update aria
370
+ // Update aria — expanded/selected belong on the treeitem (the row), not
371
+ // the host group (FEEDBACK-91).
354
372
  if (this.hasChildren) {
355
- this.setAttribute('aria-expanded', String(this.open));
373
+ row.setAttribute('aria-expanded', String(this.open));
356
374
  } else {
357
- this.removeAttribute('aria-expanded');
375
+ row.removeAttribute('aria-expanded');
358
376
  }
377
+ if (this.selected) row.setAttribute('aria-selected', 'true');
378
+ else row.removeAttribute('aria-selected');
359
379
 
360
380
  // Update text
361
381
  const textEl = row.querySelector('[slot="text"]');
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
10
+ import { html, stamp } from '../../core/template.js';
10
11
 
11
12
  beforeAll(async () => {
12
13
  await import('./tree.js');
@@ -153,3 +154,110 @@ describe('<tree-item-ui> caret slot + actions adoption (caret convention + FB-89
153
154
  expect(carets[0].getAttribute('name')).toBe('folder');
154
155
  });
155
156
  });
157
+
158
+ describe('<tree-item-ui> ARIA tree containment (FEEDBACK-91)', () => {
159
+ let host;
160
+ const settle = () => new Promise((r) => setTimeout(r, 30));
161
+
162
+ beforeEach(() => {
163
+ host = document.createElement('div');
164
+ document.body.appendChild(host);
165
+ });
166
+ afterEach(() => host.remove());
167
+
168
+ // Mirrors axe-core's `aria-required-parent` check for `treeitem`: walking up
169
+ // from the treeitem, the first ancestor carrying a *non-generic* role must be
170
+ // `group` or `tree`. Roleless / presentation / display:contents wrapper spans
171
+ // are transparent pass-throughs (exactly the consumer's `.map()` case).
172
+ function requiredParentSatisfied(treeitem, root) {
173
+ let p = treeitem.parentElement;
174
+ while (p && p !== root.parentElement) {
175
+ const role = p.getAttribute('role');
176
+ if (role === 'group' || role === 'tree') return true;
177
+ if (role && role !== 'presentation' && role !== 'none') return false;
178
+ p = p.parentElement;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ function buildStaticTree() {
184
+ const tree = document.createElement('tree-ui');
185
+ const parent = document.createElement('tree-item-ui');
186
+ parent.setAttribute('text', 'Colors');
187
+ parent.setAttribute('open', '');
188
+ for (const t of ['Neutral', 'Brand']) {
189
+ const c = document.createElement('tree-item-ui');
190
+ c.setAttribute('text', t);
191
+ parent.appendChild(c);
192
+ }
193
+ tree.appendChild(parent);
194
+ host.appendChild(tree);
195
+ return tree;
196
+ }
197
+
198
+ it('roles the host as group and the [slot="row"] as treeitem', async () => {
199
+ const tree = buildStaticTree();
200
+ await settle();
201
+ expect(tree.getAttribute('role')).toBe('tree');
202
+ for (const item of tree.querySelectorAll('tree-item-ui')) {
203
+ expect(item.getAttribute('role')).toBe('group');
204
+ const row = item.querySelector(':scope > [slot="row"]');
205
+ expect(row.getAttribute('role')).toBe('treeitem');
206
+ expect(row.getAttribute('tabindex')).toBe('0');
207
+ }
208
+ });
209
+
210
+ it('every treeitem row has a group/tree required parent (static markup)', async () => {
211
+ const tree = buildStaticTree();
212
+ await settle();
213
+ const rows = tree.querySelectorAll('[role="treeitem"]');
214
+ expect(rows.length).toBe(3); // parent + 2 children — was the axe-critical case
215
+ for (const row of rows) expect(requiredParentSatisfied(row, tree)).toBe(true);
216
+ });
217
+
218
+ it('nested treeitems satisfy required-parent when rendered via the template engine .map() (consumer repro)', async () => {
219
+ const ITEMS = [{ id: 'neutral', text: 'Neutral' }, { id: 'brand', text: 'Brand' }];
220
+ stamp(html`
221
+ <tree-ui>
222
+ <tree-item-ui text="Colors" open>
223
+ ${ITEMS.map((i) => html`<tree-item-ui .text=${i.text} .value=${i.id}></tree-item-ui>`)}
224
+ </tree-item-ui>
225
+ </tree-ui>
226
+ `, host);
227
+ await settle();
228
+
229
+ const tree = host.querySelector('tree-ui');
230
+ const rows = tree.querySelectorAll('[role="treeitem"]');
231
+ expect(rows.length).toBe(3);
232
+ for (const row of rows) expect(requiredParentSatisfied(row, tree)).toBe(true);
233
+
234
+ // The nested items sit BEHIND the template engine's display:contents wrapper
235
+ // span — proving the fix works without re-homing engine-owned nodes.
236
+ const child = tree.querySelector('tree-item-ui[value="neutral"]');
237
+ expect(child.parentElement.style.display).toBe('contents');
238
+ expect(child.getAttribute('role')).toBe('group');
239
+ });
240
+
241
+ it('aria-expanded lives on the row (treeitem), not the host group', async () => {
242
+ const tree = buildStaticTree();
243
+ await settle();
244
+ const parent = tree.querySelector('tree-item-ui');
245
+ const parentRow = parent.querySelector(':scope > [slot="row"]');
246
+ expect(parentRow.getAttribute('aria-expanded')).toBe('true');
247
+ expect(parent.hasAttribute('aria-expanded')).toBe(false); // not stranded on the host
248
+
249
+ const leaf = tree.querySelectorAll('tree-item-ui')[1]; // a child leaf
250
+ const leafRow = leaf.querySelector(':scope > [slot="row"]');
251
+ expect(leafRow.hasAttribute('aria-expanded')).toBe(false); // leaves carry none
252
+ });
253
+
254
+ it('aria-selected reflects onto the selected row only', async () => {
255
+ const tree = buildStaticTree();
256
+ await settle();
257
+ const [parent, neutral] = tree.querySelectorAll('tree-item-ui');
258
+ tree.select(neutral);
259
+ await settle();
260
+ expect(neutral.querySelector(':scope > [slot="row"]').getAttribute('aria-selected')).toBe('true');
261
+ expect(parent.querySelector(':scope > [slot="row"]').hasAttribute('aria-selected')).toBe(false);
262
+ });
263
+ });