@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 +14 -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 +12 -3
- 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/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +51 -51
- package/package.json +1 -1
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
|
-
/*
|
|
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
|
|
|
@@ -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
|
+
});
|