@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 +26 -0
- package/components/input/input.class.js +38 -0
- package/components/input/input.css +8 -4
- package/components/input/input.test.js +57 -0
- package/components/menu/menu.class.js +26 -5
- package/components/menu/menu.test.js +130 -0
- package/components/tree/tree.class.js +24 -4
- package/components/tree/tree.test.js +108 -0
- package/core/template.js +9 -0
- package/core/template.test.js +36 -0
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +51 -51
- package/package.json +1 -1
- package/styles/type/roles.css +5 -0
- package/styles/typography.css +1 -1
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
|
-
/*
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
+
row.setAttribute('aria-expanded', String(this.open));
|
|
356
374
|
} else {
|
|
357
|
-
|
|
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
|
}
|
package/core/template.test.js
CHANGED
|
@@ -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
|
+
});
|