@adia-ai/web-components 0.7.0 → 0.7.2

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,31 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.2] — 2026-06-01
4
+
5
+ ### Fixed
6
+
7
+ - **template engine — ARIA-transparent `display:contents` wrapper spans (FB-94)** — the interpolation wrapper span the engine emits for `${…}` parts (`.map()` / conditional / `repeat()`) now carries `role="presentation"`. `display:contents` removes the span's box from layout but it still generated a generic accessibility node, which broke `aria-required-children` / `aria-required-parent` when the span sat between a role-bearing parent and role-bearing children rendered via interpolation — e.g. a `.map()`-rendered `<tree-ui>` (the mainstream consumer case) tripped axe `aria-required-children` even after the FB-91 static-markup fix landed. `role="presentation"` promotes the children to the real parent in the a11y tree. Fixed at all three wrapper sites (`wrap()` + array branch + keyed `repeat()`); +3 regression tests assert the wrappers carry `role="presentation"`. This is the consumer-preferred + peer-recommended central fix and is structurally sound (the spurious generic node between `tree`→`treeitem` is removed). **Honest verification note:** `aria-required-children` could not be made to fire on any in-repo tree (shallow *or* deeply-nested, axe-core, `:5300`) — it triggers only on color-app's exact published-package `dts-token-tree` — so local axe reads 0 both before and after; the firing→cleared transition is **pending the consumer's confirmation** on their next `@adia-ai/web-components` upgrade. (Tokens Studio / color-app FB-94)
8
+ - **`<menu-item-ui>` no longer an orphan `menuitem` when the menu is closed (FB-95)** — `UIMenuItem.connected()` set `role="menuitem"` unconditionally, but a closed `<menu-ui>` moves its items back into its own light DOM (no `role="menu"` ancestor), so every closed declarative menu tripped axe `aria-required-parent`. The role (+ `tabindex="-1"`) is now applied only when the item has a `role="menu"`/`menubar` ancestor; the open popover adopts items via `appendChild` (which re-fires `connected()`), so the role re-applies on open and drops on close. **Verified with axe-core**: closed menu → **0** `aria-required-parent` violations (was 3); open menu → items are `role="menuitem"`, 0 violations. (Tokens Studio / color-app FB-95)
9
+
10
+ ### Changed — styles + build
11
+
12
+ - **Tokens cascade-layer completed (`styles/`)** — `typography.css` (`type/scale.css`) + `roles.css` `:root` role-token blocks moved into `@layer(tokens)`, finishing the `@layer` migration's tokens cut (the type-scale + role tokens had stayed unlayered). Behavior-neutral — the `[variant]`/state rules keep their existing precedence; only the base `:root` token declarations join the layer.
13
+ - **`dist/` CDN bundles regenerated** (`web-components.min.css` + `web-components.min.js`) for the template-engine + tokens-layer changes.
14
+
15
+ ## [0.7.1] — 2026-05-31
16
+
17
+ ### Fixed
18
+
19
+ - **`<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)
20
+ - **`<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)
21
+ - **`<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)
22
+ - **`<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)
23
+ - **`dist/` CDN bundles regenerated** (`web-components.min.css` + `web-components.min.js`) to carry the input/tree/menu fixes above.
24
+
25
+ ### Changed — demos
26
+
27
+ - **`<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)
28
+
3
29
  ## [0.7.0] — 2026-05-31
4
30
 
5
31
  ### 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
 
@@ -257,8 +266,20 @@ export class UIMenuItem extends UIElement {
257
266
  static template = () => null;
258
267
 
259
268
  connected() {
260
- this.setAttribute('role', 'menuitem');
261
- if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1');
269
+ // role="menuitem" ONLY when actually inside a menu container. A menu-ui
270
+ // closes by moving its items back into its own light DOM (see #hide()),
271
+ // where there is no role="menu"/menubar ancestor — a bare role="menuitem"
272
+ // there trips axe `aria-required-parent` on every closed menu (FB-95). The
273
+ // open popover (role="menu") adopts items via appendChild, which re-fires
274
+ // connected(), so the role re-applies when the menu opens and is dropped
275
+ // again on close.
276
+ if (this.closest('[role="menu"], [role="menubar"]')) {
277
+ this.setAttribute('role', 'menuitem');
278
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1');
279
+ } else {
280
+ this.removeAttribute('role');
281
+ this.removeAttribute('tabindex');
282
+ }
262
283
  this.#stamp();
263
284
  this.#syncAria();
264
285
  }
@@ -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
+ });
package/core/template.js CHANGED
@@ -430,6 +430,13 @@ function wrap(part) {
430
430
  if (part.n.nodeType === 1 && part.n[PART]) return part.n;
431
431
  const c = document.createElement('span');
432
432
  c.style.display = 'contents';
433
+ // ARIA-transparent: display:contents removes the box from layout, but the
434
+ // span still generates a generic accessibility node — which breaks
435
+ // required-owned / required-parent relationships when it sits between a
436
+ // role-bearing parent and role-bearing children rendered via interpolation
437
+ // (tree→treeitem, menu→menuitem via `.map()`/`repeat()`). role="presentation"
438
+ // promotes the children to the real parent in the a11y tree. (FB-94 / FB-95)
439
+ c.setAttribute('role', 'presentation');
433
440
  c[PART] = true;
434
441
  part.n.replaceWith(c);
435
442
  part.n = c;
@@ -453,6 +460,7 @@ function applyValue(p, v) {
453
460
  if (isResult(item)) {
454
461
  const el = document.createElement('span');
455
462
  el.style.display = 'contents';
463
+ el.setAttribute('role', 'presentation'); // ARIA-transparent — see wrap()
456
464
  c.appendChild(el);
457
465
  stamp(item, el);
458
466
  } else {
@@ -526,6 +534,7 @@ export function repeat(items, keyFn, tplFn) {
526
534
  } else {
527
535
  const el = document.createElement('span');
528
536
  el.style.display = 'contents';
537
+ el.setAttribute('role', 'presentation'); // ARIA-transparent — see wrap()
529
538
  stamp(result, el);
530
539
  newMap.set(key, el);
531
540
  }
@@ -573,3 +573,39 @@ describe('html template — FB-57 (v0.6.8) nested SVG/MathML template namespace
573
573
  expect(p.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
574
574
  });
575
575
  });
576
+
577
+ describe('html template — FB-94/FB-95 (v0.7.x) ARIA-transparent wrapper spans', () => {
578
+ let container;
579
+ beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); });
580
+ afterEach(() => container.remove());
581
+
582
+ // The display:contents wrapper span the engine emits for interpolated parts
583
+ // must carry role="presentation" — display:contents removes the box from
584
+ // layout but the span still generates a generic a11y node, which breaks
585
+ // required-owned / required-parent relationships when it sits between a
586
+ // role-bearing parent and role-bearing children rendered via .map()/repeat()
587
+ // (tree→treeitem = FB-94, menu→menuitem = FB-95). Verified end-to-end with
588
+ // axe-core; these lock the substrate behaviour against regression.
589
+ const isContentsSpan = (s) => /display:\s*contents/.test(s.getAttribute('style') || '');
590
+
591
+ it('a `.map()` of templates wraps each item in a role="presentation" display:contents span', () => {
592
+ stamp(html`<ul>${['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)}</ul>`, container);
593
+ const spans = [...container.querySelectorAll('span')].filter(isContentsSpan);
594
+ expect(spans.length).toBeGreaterThan(0);
595
+ spans.forEach((s) => expect(s.getAttribute('role')).toBe('presentation'));
596
+ });
597
+
598
+ it('a conditional `${cond ? html`` : null}` branch wraps in role="presentation"', () => {
599
+ stamp(html`<div>${true ? html`<span data-x>hi</span>` : null}</div>`, container);
600
+ const wrap = [...container.querySelectorAll('div > span')].find(isContentsSpan);
601
+ expect(wrap).toBeTruthy();
602
+ expect(wrap.getAttribute('role')).toBe('presentation');
603
+ });
604
+
605
+ it('`repeat()` keyed items wrap in role="presentation"', () => {
606
+ stamp(html`<ul>${repeat(['a', 'b'], (x) => x, (x) => html`<li>${x}</li>`)}</ul>`, container);
607
+ const spans = [...container.querySelectorAll('span')].filter(isContentsSpan);
608
+ expect(spans.length).toBeGreaterThan(0);
609
+ spans.forEach((s) => expect(s.getAttribute('role')).toBe('presentation'));
610
+ });
611
+ });