@adia-ai/web-components 0.7.7 → 0.7.9
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 +17 -0
- package/components/combobox/combobox.class.js +9 -5
- package/components/combobox/combobox.test.js +17 -0
- package/components/date-range-picker/date-range-picker.class.js +4 -3
- package/components/input/input.class.js +8 -5
- package/components/input/input.test.js +16 -0
- package/components/nav-group/nav-group.class.js +3 -2
- package/components/nav-group/nav-group.test.js +32 -0
- package/components/option-card/option-card.class.js +4 -3
- package/components/option-card/option-card.test.js +32 -0
- package/components/step-progress/step-progress.class.js +4 -3
- package/components/step-progress/step-progress.test.js +11 -0
- package/components/timeline/timeline.class.js +2 -1
- package/components/timeline/timeline.test.js +31 -0
- package/components/tree/tree.class.js +7 -0
- package/core/logical-children.js +55 -0
- package/core/logical-children.test.js +54 -0
- package/dist/host.min.css +1 -1
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +91 -91
- package/package.json +1 -1
- package/styles/colors/parameters.css +13 -6
- package/styles/colors/primitives-warning.css +38 -38
- package/styles/colors/semantics/aliases.css +7 -18
- package/styles/colors/semantics/core.css +12 -12
- package/styles/verse.css +18 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog — @adia-ai/web-components
|
|
2
2
|
|
|
3
|
+
## [0.7.9] — 2026-06-03
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **`[verse]` control heights bumped one tier — `--a-size` 18/20/24 → 20/24/28px** (sm/md/lg @ density=1). Form-element / control heights in the compact `[verse]` register grow: `--a-size-sm` `1.125→1.250rem`, `--a-size-md` `1.25→1.500rem`, `--a-size-lg` `1.5→1.750rem` (still density-scaled rem). **Behavior change** — inputs / buttons / selects in a `[verse]` context render taller; the `[verse][size]` tier rules inherit the new values (they re-point `--a-size` to the sm/lg token). Verified by `scripts/qa/register-scale-probe.mjs` (verse 20/24/28; regular 24/30/36 + prose 28/36/44 unchanged). **Chrome follows:** `--a-toggle-size` 12/16/20 → 16/20/24, `--a-icon-size` 12/14/16 → 14/16/18, `--a-caret-size` 10/12/14 → 12/14/16 (each +1 tier) so check / radio / switch boxes + select carets + icons stay proportional to the larger controls. File: `styles/verse.css`.
|
|
8
|
+
- **Semantic color tier lift + status-compat removal (the `warning-yellow-cusp-lift`).** Per-family `-strong` tiers moved one step richer (`-50`/`-70` → `-60`) and the `-bg` aliases now read the base family token (`var(--a-accent)` etc.) instead of `-strong`, across accent / brand / info / success / warning (`colors/semantics/core.css` + `colors/parameters.css` + `colors/primitives-warning.css` — the warning yellow's cusp re-tuned for legibility). The legacy `colors/semantics/aliases.css` **STATUS COMPAT block** that overrode `--a-danger` / `--a-success` to `-strong` was **removed** — status families now resolve to their base tier deliberately (base tokens are defined upstream in `core.css`, so non-breaking); the `--a-scrim-*` role aliases in that file are **retained** (modal / drawer / tour / loading-overlay backdrops depend on them). **Behavior change** — danger / success / accent / brand / info surfaces shift tone. Gate-clean: no raw colors, `verify:contrast` + `check:foundation-layers` pass.
|
|
9
|
+
- **CDN bundles rebuilt** — `dist/host.min.css` + `dist/web-components.min.css` regenerated so the color cusp-lift + `[verse]` tier bump reach `@adia-ai/web-components@0.7` CDN consumers.
|
|
10
|
+
|
|
11
|
+
## [0.7.8] — 2026-06-02
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Wrapper-trap drain — 8 components no longer mis-handle `.map()`/interpolated slotted children.** A new shared `core/logical-children.js` (`logicalChildren` / `logicalSlotted`) pierces the template engine's `display:contents` / `role="presentation"` wrapper spans, so consumer slot content supplied via interpolation (`${items.map(…)}` / conditionals) is seen by a component's slot reads — not just static literals. Migrated 14 `:scope > [slot]` direct-child reads that previously missed wrapped content: **`combobox-ui`** (prefix / suffix / empty / loading / footer) + **`input-ui`** (leading / trailing) grabbed affordances then ran `innerHTML = ''`, wiping the wrapped ones (the `agent-artifact-ui` data-loss class); **`nav-group-ui`** (header), **`option-card-ui`** (heading / description / icon), **`timeline-item-ui`** (label), **`step-progress-ui`** (caption / track), and **`date-range-picker-ui`** (presets / footer) used `if (!query) stamp()` guards that false-negatived on a wrapped slot and stamped a DUPLICATE over the consumer's content. Companion field-reads were pierced in lockstep so they don't null-ref. `tree-item-ui`'s `[slot="row"]` is left as-is (a single static / stamped container, not an interpolated collection). New `core/logical-children.test.js` + per-component regression tests; consolidates the prior hand-rolled walks (`select-ui` `#logicalOptionChildren`, `tree-ui` `#logicalSlotChildren`). Files: `core/logical-children.js` + 8 component class files.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **CDN bundles rebuilt** — `dist/web-components.min.js` regenerated so the wrapper-trap drain (the new `core/logical-children.js` helper + the 14 pierced component reads) reaches `@adia-ai/web-components@0.7` CDN consumers.
|
|
19
|
+
|
|
3
20
|
## [0.7.7] — 2026-06-02
|
|
4
21
|
|
|
5
22
|
### Fixed
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { UIFormElement } from '../../core/form.js';
|
|
41
41
|
import { anchorPopover } from '../../core/anchor.js';
|
|
42
42
|
import { untracked } from '../../core/signals.js';
|
|
43
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
43
44
|
|
|
44
45
|
function escapeHTML(s) {
|
|
45
46
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -213,11 +214,14 @@ export class UICombobox extends UIFormElement {
|
|
|
213
214
|
const inputId = `${this.#instanceId}-input`;
|
|
214
215
|
|
|
215
216
|
// Capture consumer-supplied slotted children before innerHTML wipes them.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const
|
|
217
|
+
// logicalSlotted pierces the template engine's display:contents wrapper spans,
|
|
218
|
+
// so `.map()`/`repeat()`-interpolated affordances aren't missed then wiped
|
|
219
|
+
// (the wrapper trap — audit-wrapper-trap arm 2).
|
|
220
|
+
const prefixNodes = logicalSlotted(this, 'prefix');
|
|
221
|
+
const suffixNodes = logicalSlotted(this, 'suffix');
|
|
222
|
+
const emptyNodes = logicalSlotted(this, 'empty');
|
|
223
|
+
const loadingNodes = logicalSlotted(this, 'loading');
|
|
224
|
+
const footerNodes = logicalSlotted(this, 'footer');
|
|
221
225
|
|
|
222
226
|
this.innerHTML = `
|
|
223
227
|
<span data-label id="${labelId}"${this.label ? '' : ' style="display:none"'}>${escapeHTML(this.label || '')}</span>
|
|
@@ -28,6 +28,23 @@ describe('combobox-ui', () => {
|
|
|
28
28
|
expect(el.options[0].label).toBe('Alpha');
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
// Wrapper-trap regression (audit-wrapper-trap arm 2): a `.map()`-interpolated
|
|
32
|
+
// affordance (here [slot="footer"]) arrives nested in a display:contents
|
|
33
|
+
// wrapper span. The old `:scope > [slot]` grab missed it → innerHTML='' wiped
|
|
34
|
+
// it. logicalSlotted pierces the wrapper so the footer survives the rebuild.
|
|
35
|
+
it('preserves a WRAPPED (interpolated) [slot="footer"] across the rebuild (wrapper trap)', async () => {
|
|
36
|
+
const el = mount(`
|
|
37
|
+
<combobox-ui>
|
|
38
|
+
<option value="a">Alpha</option>
|
|
39
|
+
<span style="display:contents" role="presentation"><div slot="footer" data-test="ft">Footer</div></span>
|
|
40
|
+
</combobox-ui>
|
|
41
|
+
`);
|
|
42
|
+
await tick(); await tick();
|
|
43
|
+
const footer = el.querySelector('[slot="footer"]');
|
|
44
|
+
expect(footer).not.toBeNull();
|
|
45
|
+
expect(footer.dataset.test).toBe('ft');
|
|
46
|
+
});
|
|
47
|
+
|
|
31
48
|
it('honours <option selected> as initial value', async () => {
|
|
32
49
|
const el = mount(`
|
|
33
50
|
<combobox-ui>
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { UIFormElement } from '../../core/form.js';
|
|
41
41
|
import { anchorPopover } from '../../core/anchor.js';
|
|
42
42
|
import { untracked } from '../../core/signals.js';
|
|
43
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
43
44
|
|
|
44
45
|
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
45
46
|
const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
@@ -392,7 +393,7 @@ export class UIDateRangePicker extends UIFormElement {
|
|
|
392
393
|
this.#popoverRef.appendChild(calArea);
|
|
393
394
|
}
|
|
394
395
|
// Preset rail (unless author supplied [slot="presets"] OR no-presets is set).
|
|
395
|
-
const authorPresets = this
|
|
396
|
+
const authorPresets = logicalSlotted(this, 'presets')[0];
|
|
396
397
|
if (authorPresets && authorPresets.parentElement !== this.#popoverRef) {
|
|
397
398
|
this.#popoverRef.appendChild(authorPresets);
|
|
398
399
|
this.#presetRailRef = authorPresets;
|
|
@@ -425,7 +426,7 @@ export class UIDateRangePicker extends UIFormElement {
|
|
|
425
426
|
this.#calFromRef = calArea.querySelector(':scope > [data-cal-from]');
|
|
426
427
|
this.#calToRef = calArea.querySelector(':scope > [data-cal-to]');
|
|
427
428
|
// Footer slot — author-supplied; lift into popover after calendar area.
|
|
428
|
-
const authorFooter = this
|
|
429
|
+
const authorFooter = logicalSlotted(this, 'footer')[0];
|
|
429
430
|
if (authorFooter && authorFooter.parentElement !== this.#popoverRef) {
|
|
430
431
|
this.#popoverRef.appendChild(authorFooter);
|
|
431
432
|
}
|
|
@@ -477,7 +478,7 @@ export class UIDateRangePicker extends UIFormElement {
|
|
|
477
478
|
|
|
478
479
|
// Render the preset rail (unless overridden by [slot="presets"] or
|
|
479
480
|
// hidden via [no-presets]).
|
|
480
|
-
if (!this
|
|
481
|
+
if (!logicalSlotted(this, 'presets').length) {
|
|
481
482
|
if (this.noPresets) {
|
|
482
483
|
this.#presetRailRef?.remove();
|
|
483
484
|
this.#presetRailRef = null;
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
import { UIFormElement } from '../../core/form.js';
|
|
54
54
|
import { isIconName, whenIconRegistryReady } from '../../core/icons.js';
|
|
55
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
55
56
|
|
|
56
57
|
const renderAffix = (v) => isIconName(v)
|
|
57
58
|
? `<icon-ui name="${v}"></icon-ui>`
|
|
@@ -133,11 +134,13 @@ export class UIInput extends UIFormElement {
|
|
|
133
134
|
// §199 (v0.5.7): consumer-supplied leading/trailing buttons live as
|
|
134
135
|
// direct children of <input-ui> at author time. Capture references
|
|
135
136
|
// BEFORE innerHTML wipes them, so we can move them into [slot="field"]
|
|
136
|
-
// after the shell is built.
|
|
137
|
-
//
|
|
138
|
-
// re-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
// after the shell is built. logicalSlotted pierces the template engine's
|
|
138
|
+
// display:contents wrapper spans (so `.map()`-interpolated affordances
|
|
139
|
+
// aren't missed then wiped — the wrapper trap), and still won't re-match
|
|
140
|
+
// nodes already moved into the field (those nest in a real element, not a
|
|
141
|
+
// transparent wrapper).
|
|
142
|
+
const leadingNodes = logicalSlotted(this, 'leading');
|
|
143
|
+
const trailingNodes = logicalSlotted(this, 'trailing');
|
|
141
144
|
|
|
142
145
|
if (!this.querySelector('[slot="text"]')) {
|
|
143
146
|
const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
|
|
@@ -68,6 +68,22 @@ describe('input-ui — §199 leading/trailing affordance slots', () => {
|
|
|
68
68
|
expect(el.querySelector(':scope > [slot="leading"]')).toBeNull();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
// Wrapper-trap regression (audit-wrapper-trap arm 2): a `.map()`-interpolated
|
|
72
|
+
// affordance arrives nested in a display:contents/role=presentation wrapper
|
|
73
|
+
// span. The old `:scope > [slot]` direct-child grab missed it, so innerHTML=''
|
|
74
|
+
// wiped it. logicalSlotted pierces the wrapper → it reaches the field.
|
|
75
|
+
it('moves a WRAPPED (interpolated) [slot="trailing"] into the field (wrapper trap)', () => {
|
|
76
|
+
const el = mount(`
|
|
77
|
+
<input-ui value="Theme 1" suffix="Light">
|
|
78
|
+
<span style="display:contents" role="presentation"><button slot="trailing" data-test="open" aria-label="Open">↗</button></span>
|
|
79
|
+
</input-ui>
|
|
80
|
+
`);
|
|
81
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
82
|
+
const trailing = field.querySelector(':scope > [slot="trailing"]');
|
|
83
|
+
expect(trailing).not.toBeNull();
|
|
84
|
+
expect(trailing.dataset.test).toBe('open');
|
|
85
|
+
});
|
|
86
|
+
|
|
71
87
|
it('positions trailing after suffix and before controls (number mode)', () => {
|
|
72
88
|
const el = mount(`
|
|
73
89
|
<input-ui type="number" value="42" suffix="kg">
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { UIElement } from '../../core/element.js';
|
|
29
29
|
import { anchorPopover } from '../../core/anchor.js';
|
|
30
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
30
31
|
|
|
31
32
|
export class UINavGroup extends UIElement {
|
|
32
33
|
static properties = {
|
|
@@ -59,7 +60,7 @@ export class UINavGroup extends UIElement {
|
|
|
59
60
|
connected() {
|
|
60
61
|
this.setAttribute('role', 'group');
|
|
61
62
|
|
|
62
|
-
if (!this
|
|
63
|
+
if (!logicalSlotted(this, 'header').length) {
|
|
63
64
|
this.#headerEl = document.createElement('div');
|
|
64
65
|
this.#headerEl.setAttribute('slot', 'header');
|
|
65
66
|
this.#headerEl.setAttribute('tabindex', '0');
|
|
@@ -71,7 +72,7 @@ export class UINavGroup extends UIElement {
|
|
|
71
72
|
`;
|
|
72
73
|
this.prepend(this.#headerEl);
|
|
73
74
|
} else {
|
|
74
|
-
this.#headerEl = this
|
|
75
|
+
this.#headerEl = logicalSlotted(this, 'header')[0];
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
this.#headerEl?.addEventListener('keydown', this.#onHeaderKey);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './nav-group.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
function mount(html) {
|
|
7
|
+
const w = document.createElement('div');
|
|
8
|
+
w.innerHTML = html;
|
|
9
|
+
document.body.appendChild(w);
|
|
10
|
+
return w.firstElementChild;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('nav-group-ui — header stamp-if-absent (wrapper trap)', () => {
|
|
14
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
15
|
+
|
|
16
|
+
it('stamps a header when the consumer provides none', async () => {
|
|
17
|
+
const el = mount('<nav-group-ui text="Reports"></nav-group-ui>');
|
|
18
|
+
await tick();
|
|
19
|
+
expect(el.querySelectorAll('[slot="header"]').length).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Regression (audit-wrapper-trap arm 2): a `.map()`/conditional-interpolated
|
|
23
|
+
// [slot="header"] arrives nested in a display:contents wrapper. The old
|
|
24
|
+
// `:scope > [slot="header"]` absence check missed it and stamped a DUPLICATE.
|
|
25
|
+
it('does NOT stamp a duplicate when a WRAPPED consumer header is present', async () => {
|
|
26
|
+
const el = mount(`<nav-group-ui text="Reports"><span style="display:contents" role="presentation"><div slot="header" data-test="h">Custom</div></span></nav-group-ui>`);
|
|
27
|
+
await tick();
|
|
28
|
+
const headers = el.querySelectorAll('[slot="header"]');
|
|
29
|
+
expect(headers.length).toBe(1);
|
|
30
|
+
expect(headers[0].dataset.test).toBe('h');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import { UIFormElement } from '../../core/form.js';
|
|
48
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
48
49
|
|
|
49
50
|
export class UIOptionCard extends UIFormElement {
|
|
50
51
|
static properties = {
|
|
@@ -97,21 +98,21 @@ export class UIOptionCard extends UIFormElement {
|
|
|
97
98
|
/** Stamp heading / description / icon from attributes when the
|
|
98
99
|
* consumer hasn't already provided slotted content. */
|
|
99
100
|
#ensureLayout() {
|
|
100
|
-
if (this.heading && !this
|
|
101
|
+
if (this.heading && !logicalSlotted(this, 'heading').length) {
|
|
101
102
|
const el = document.createElement('span');
|
|
102
103
|
el.setAttribute('slot', 'heading');
|
|
103
104
|
el.dataset.fromAttr = 'true';
|
|
104
105
|
el.textContent = this.heading;
|
|
105
106
|
this.appendChild(el);
|
|
106
107
|
}
|
|
107
|
-
if (this.description && !this
|
|
108
|
+
if (this.description && !logicalSlotted(this, 'description').length) {
|
|
108
109
|
const el = document.createElement('span');
|
|
109
110
|
el.setAttribute('slot', 'description');
|
|
110
111
|
el.dataset.fromAttr = 'true';
|
|
111
112
|
el.textContent = this.description;
|
|
112
113
|
this.appendChild(el);
|
|
113
114
|
}
|
|
114
|
-
if (this.icon && !this
|
|
115
|
+
if (this.icon && !logicalSlotted(this, 'icon').length) {
|
|
115
116
|
const el = document.createElement('icon-ui');
|
|
116
117
|
el.setAttribute('slot', 'icon');
|
|
117
118
|
el.dataset.fromAttr = 'true';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './option-card.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
function mount(html) {
|
|
7
|
+
const w = document.createElement('div');
|
|
8
|
+
w.innerHTML = html;
|
|
9
|
+
document.body.appendChild(w);
|
|
10
|
+
return w.firstElementChild;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('option-card-ui — heading/description stamp-if-absent (wrapper trap)', () => {
|
|
14
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
15
|
+
|
|
16
|
+
it('stamps heading + description from attrs when no slots are provided', async () => {
|
|
17
|
+
const el = mount('<option-card-ui heading="Pro" description="Best value"></option-card-ui>');
|
|
18
|
+
await tick();
|
|
19
|
+
expect(el.querySelectorAll('[slot="heading"]').length).toBe(1);
|
|
20
|
+
expect(el.querySelectorAll('[slot="description"]').length).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Regression (audit-wrapper-trap arm 2): a WRAPPED consumer [slot="heading"]
|
|
24
|
+
// override must suppress the attr-stamp, not collide with it.
|
|
25
|
+
it('does NOT stamp a duplicate heading when a WRAPPED consumer heading is present', async () => {
|
|
26
|
+
const el = mount(`<option-card-ui heading="Pro"><span style="display:contents" role="presentation"><h3 slot="heading" data-test="h">Custom</h3></span></option-card-ui>`);
|
|
27
|
+
await tick();
|
|
28
|
+
const headings = el.querySelectorAll('[slot="heading"]');
|
|
29
|
+
expect(headings.length).toBe(1);
|
|
30
|
+
expect(headings[0].dataset.test).toBe('h');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { UIElement } from '../../core/element.js';
|
|
33
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
33
34
|
|
|
34
35
|
export class UIStepProgress extends UIElement {
|
|
35
36
|
static properties = {
|
|
@@ -48,7 +49,7 @@ export class UIStepProgress extends UIElement {
|
|
|
48
49
|
this.setAttribute('aria-valuemin', '0');
|
|
49
50
|
|
|
50
51
|
// Auto-mint internal structure if the consumer didn't provide custom slots.
|
|
51
|
-
if (!this
|
|
52
|
+
if (!logicalSlotted(this, 'caption').length && !logicalSlotted(this, 'track').length) {
|
|
52
53
|
this.#captionEl = document.createElement('span');
|
|
53
54
|
this.#captionEl.setAttribute('slot', 'caption');
|
|
54
55
|
this.appendChild(this.#captionEl);
|
|
@@ -57,8 +58,8 @@ export class UIStepProgress extends UIElement {
|
|
|
57
58
|
this.#trackEl.setAttribute('slot', 'track');
|
|
58
59
|
this.appendChild(this.#trackEl);
|
|
59
60
|
} else {
|
|
60
|
-
this.#captionEl = this
|
|
61
|
-
this.#trackEl = this
|
|
61
|
+
this.#captionEl = logicalSlotted(this, 'caption')[0];
|
|
62
|
+
this.#trackEl = logicalSlotted(this, 'track')[0];
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -115,4 +115,15 @@ describe('step-progress-ui', () => {
|
|
|
115
115
|
const el = mount('<step-progress-ui value="3" total="10"></step-progress-ui>');
|
|
116
116
|
expect(() => el.remove()).not.toThrow();
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
// Regression (audit-wrapper-trap arm 2): WRAPPED consumer [slot="caption"] +
|
|
120
|
+
// [slot="track"] (e.g. via `.map()`/conditional interpolation) must be seen by
|
|
121
|
+
// the absence check so it doesn't MINT duplicates over the consumer's slots.
|
|
122
|
+
it('does NOT mint duplicates when WRAPPED consumer caption + track are present', async () => {
|
|
123
|
+
const el = mount(`<step-progress-ui value="2" total="5"><span style="display:contents" role="presentation"><span slot="caption" data-test="c">Custom</span><div slot="track" data-test="t"></div></span></step-progress-ui>`);
|
|
124
|
+
await tick();
|
|
125
|
+
expect(el.querySelectorAll('[slot="caption"]').length).toBe(1);
|
|
126
|
+
expect(el.querySelectorAll('[slot="track"]').length).toBe(1);
|
|
127
|
+
expect(el.querySelector('[slot="caption"]').dataset.test).toBe('c');
|
|
128
|
+
});
|
|
118
129
|
});
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
import { UIElement } from '../../core/element.js';
|
|
59
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
59
60
|
|
|
60
61
|
// ── Container ──────────────────────────────────────────────────
|
|
61
62
|
|
|
@@ -104,7 +105,7 @@ export class UITimelineItem extends UIElement {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
connected() {
|
|
107
|
-
if (!this
|
|
108
|
+
if (!logicalSlotted(this, 'label').length) {
|
|
108
109
|
// Author provided just text/time — stamp the default slot structure
|
|
109
110
|
this.innerHTML = `
|
|
110
111
|
<span slot="label"></span>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './timeline.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
function mount(html) {
|
|
7
|
+
const w = document.createElement('div');
|
|
8
|
+
w.innerHTML = html;
|
|
9
|
+
document.body.appendChild(w);
|
|
10
|
+
return w.firstElementChild;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('timeline-item-ui — label stamp-if-absent (wrapper trap)', () => {
|
|
14
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
15
|
+
|
|
16
|
+
it('stamps the label structure when the consumer provides none', async () => {
|
|
17
|
+
const el = mount('<timeline-item-ui text="Shipped"></timeline-item-ui>');
|
|
18
|
+
await tick();
|
|
19
|
+
expect(el.querySelectorAll('[slot="label"]').length).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Regression (audit-wrapper-trap arm 2): a WRAPPED consumer [slot="label"]
|
|
23
|
+
// must suppress the default-structure stamp, not produce a duplicate label.
|
|
24
|
+
it('does NOT stamp a duplicate when a WRAPPED consumer label is present', async () => {
|
|
25
|
+
const el = mount(`<timeline-item-ui text="Shipped"><span style="display:contents" role="presentation"><span slot="label" data-test="l">Custom</span></span></timeline-item-ui>`);
|
|
26
|
+
await tick();
|
|
27
|
+
const labels = el.querySelectorAll('[slot="label"]');
|
|
28
|
+
expect(labels.length).toBe(1);
|
|
29
|
+
expect(labels[0].dataset.test).toBe('l');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -297,6 +297,13 @@ export class UITreeItem extends UIElement {
|
|
|
297
297
|
// required parent, and the row's required `group` parent is the host.
|
|
298
298
|
this.setAttribute('role', 'group');
|
|
299
299
|
|
|
300
|
+
// [slot="row"] is a SINGLE per-item container — supplied statically or
|
|
301
|
+
// component-stamped, never a `.map()` collection. A full wrapper-pierce would
|
|
302
|
+
// have to migrate all 9 focus / keyboard-nav row reads in lockstep
|
|
303
|
+
// (`item.querySelector(':scope > [slot="row"]')`), or nav breaks for a wrapped
|
|
304
|
+
// row — high regression risk for a rare interpolated-single-row case (the
|
|
305
|
+
// common path, actions / caret, is already FB-96-handled below).
|
|
306
|
+
// wrapper-trap-ok: row is static / stamped, not an interpolated collection.
|
|
300
307
|
if (!this.querySelector(':scope > [slot="row"]')) {
|
|
301
308
|
this.#stamp();
|
|
302
309
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/logical-children.js — wrapper-piercing light-DOM child access.
|
|
3
|
+
*
|
|
4
|
+
* The template engine wraps EVERY interpolated value — a single result OR a
|
|
5
|
+
* `.map()` / `repeat()` array — in a `display:contents` / `role="presentation"`
|
|
6
|
+
* span (core/template.js `wrap()` + the Array branch). So a `:scope > [slot="X"]`
|
|
7
|
+
* direct-child query (or a `for (… of this.children)` loop) MISSES interpolated
|
|
8
|
+
* slotted children: they arrive as grand-children of a wrapper span, not direct
|
|
9
|
+
* children of the host. This is the recurring "wrapper trap" (FB-92 menu #show,
|
|
10
|
+
* FB-96 tree #stamp, FB-98 select #parseOptions, agent-artifact actions).
|
|
11
|
+
* `scripts/dev/audit-wrapper-trap.mjs` flags the un-pierced reads.
|
|
12
|
+
*
|
|
13
|
+
* `logicalChildren(host)` returns the host's LOGICAL direct children — its
|
|
14
|
+
* direct element children, but recursing THROUGH (and dropping) the transparent
|
|
15
|
+
* wrapper spans — so interpolated and static children read identically.
|
|
16
|
+
* `logicalSlotted(host, name)` filters those to `slot="<name>"`.
|
|
17
|
+
*
|
|
18
|
+
* Consolidates the per-component hand-rolled walks (select `#logicalOptionChildren`,
|
|
19
|
+
* tree `#logicalSlotChildren`) into one tested helper.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A transparent template-engine wrapper span (never authored by a consumer). */
|
|
23
|
+
const isWrapper = (el) =>
|
|
24
|
+
el.getAttribute?.('role') === 'presentation' || el.style?.display === 'contents';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The host's logical direct element children, piercing display:contents /
|
|
28
|
+
* role="presentation" wrapper spans (depth-first, wrappers flattened away).
|
|
29
|
+
* @param {Element} host
|
|
30
|
+
* @returns {Element[]}
|
|
31
|
+
*/
|
|
32
|
+
export function logicalChildren(host) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const walk = (nodes) => {
|
|
35
|
+
for (const n of nodes) {
|
|
36
|
+
if (n.nodeType !== 1) continue; // elements only (slotted children are elements)
|
|
37
|
+
if (isWrapper(n)) { walk(n.children); continue; } // pierce + drop the wrapper
|
|
38
|
+
out.push(n);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
if (host) walk(host.children);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Logical direct children carrying `slot="<slotName>"`, wrapper-pierced.
|
|
47
|
+
* Use `.length` for a stamp-if-absent guard, `[0]` for the single case, or the
|
|
48
|
+
* array for a collection grab — all safe against interpolated content.
|
|
49
|
+
* @param {Element} host
|
|
50
|
+
* @param {string} slotName
|
|
51
|
+
* @returns {Element[]}
|
|
52
|
+
*/
|
|
53
|
+
export function logicalSlotted(host, slotName) {
|
|
54
|
+
return logicalChildren(host).filter((el) => el.getAttribute('slot') === slotName);
|
|
55
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { logicalChildren, logicalSlotted } from './logical-children.js';
|
|
3
|
+
|
|
4
|
+
function host(html) {
|
|
5
|
+
const el = document.createElement('div');
|
|
6
|
+
el.innerHTML = html;
|
|
7
|
+
return el;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('logical-children', () => {
|
|
11
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
12
|
+
|
|
13
|
+
it('returns static direct children as-is', () => {
|
|
14
|
+
const h = host(`<button slot="primary">A</button><p>body</p><button slot="secondary">B</button>`);
|
|
15
|
+
expect(logicalChildren(h).map((e) => e.tagName)).toEqual(['BUTTON', 'P', 'BUTTON']);
|
|
16
|
+
expect(logicalSlotted(h, 'primary').length).toBe(1);
|
|
17
|
+
expect(logicalSlotted(h, 'secondary')[0].textContent).toBe('B');
|
|
18
|
+
expect(logicalSlotted(h, 'missing').length).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('pierces a single display:contents/role=presentation wrapper', () => {
|
|
22
|
+
const h = host(`
|
|
23
|
+
<span style="display:contents" role="presentation"><button slot="primary">A</button></span>
|
|
24
|
+
<p>body</p>
|
|
25
|
+
`);
|
|
26
|
+
// Without piercing, h.querySelector(':scope > [slot="primary"]') would be null:
|
|
27
|
+
expect(h.querySelector(':scope > [slot="primary"]')).toBeNull();
|
|
28
|
+
// With the helper, it's found:
|
|
29
|
+
expect(logicalSlotted(h, 'primary').length).toBe(1);
|
|
30
|
+
expect(logicalChildren(h).map((e) => e.tagName)).toEqual(['BUTTON', 'P']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('pierces NESTED wrappers (the .map() shape)', () => {
|
|
34
|
+
const h = host(`
|
|
35
|
+
<span style="display:contents" role="presentation">
|
|
36
|
+
<span style="display:contents" role="presentation"><button slot="act">A</button></span>
|
|
37
|
+
<span style="display:contents" role="presentation"><button slot="act">B</button></span>
|
|
38
|
+
</span>
|
|
39
|
+
`);
|
|
40
|
+
expect(logicalSlotted(h, 'act').map((e) => e.textContent)).toEqual(['A', 'B']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does NOT pierce non-wrapper elements (logical = direct, wrappers aside)', () => {
|
|
44
|
+
const h = host(`<div><button slot="primary">nested-in-real-div</button></div>`);
|
|
45
|
+
// The button is inside a real <div>, not a transparent wrapper → not a logical child.
|
|
46
|
+
expect(logicalSlotted(h, 'primary').length).toBe(0);
|
|
47
|
+
expect(logicalChildren(h).map((e) => e.tagName)).toEqual(['DIV']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('tolerates a null host', () => {
|
|
51
|
+
expect(logicalChildren(null)).toEqual([]);
|
|
52
|
+
expect(logicalSlotted(null, 'x')).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|