@adia-ai/web-components 0.7.6 → 0.7.8
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 +21 -0
- package/components/agent-artifact/agent-artifact.class.js +28 -12
- package/components/agent-artifact/agent-artifact.test.js +61 -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/drawer/drawer.css +1 -1
- 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/web-components.min.css +1 -1
- package/dist/web-components.min.js +91 -91
- package/package.json +1 -1
- package/styles/verse.css +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog — @adia-ai/web-components
|
|
2
2
|
|
|
3
|
+
## [0.7.8] — 2026-06-02
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
12
|
+
## [0.7.7] — 2026-06-02
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`[verse]` register now scales `--a-toggle-size` (check / radio / switch) down with its type + controls.** Companion to the 0.7.6 `[verse]` icon/caret fix (`91460aae2`). `--a-toggle-size` (the check / radio / switch control size) is declared at `:root` (`styles/foundation/size.css` = `--a-size-sm − --a-space-1` = 20px @md) and shifted by the global `[size]` rules (`styles/api/sizing.css` = 16/24 at sm/lg), but was **absent from `styles/verse.css`** — so in a verse context check / radio / switch chrome stayed the `:root`-computed 20px against verse's smaller (18/20/24) controls and 11/12/13px type. Added `--a-toggle-size` to all three verse sites (base `[verse]` + `[verse][size="lg"]` + `[verse][size="sm"]`) on clean `--a-space` rungs, one tier down: verse toggle is now **12 / 16 / 20px** at sm/md/lg (was a flat 20px); the regular register is unchanged (16/20/24). The explicit `[size]` tier rules are **required** — verse's `context` layer shadows the lower-layer global `[size]` `--a-toggle-size` rules, the same reason `--a-size` and the icon/caret tokens need them. `--a-space` rungs (not literal px like icon/caret) because the toggle targets land exactly on rungs, keeping the values density-aware and consistent with verse's inset/gap. Verified by `scripts/qa/register-scale-probe.mjs` (extended with a toggle axis): verse 12/16/20, regular 16/20/24, all other registers/axes unchanged. Showcased on the `check` / `radio` / `switch` demos (a "Typography registers" section, mirroring the card / button / text / field / badge / stat pages). File: `styles/verse.css`.
|
|
17
|
+
- **`<agent-artifact-ui>` no longer strands `.map()`-rendered action buttons in the body.** `#build()` grabbed the author's `slot="primary"` / `slot="secondary"` buttons with a `:scope > [slot]` direct-child query, then `this.innerHTML = ''`. Buttons supplied via interpolation (`${actions.map(…)}`) arrive nested in the template engine's `display:contents` / `role="presentation"` wrapper spans, so the direct-child grab missed them and the wipe left them rendered in the artifact *body* instead of the header actions cluster. `#build()` now partitions children with a wrapper-piercing logical walk (the FB-92/96/98 pattern, mirroring `select-ui` `#logicalOptionChildren`), so interpolated AND static action buttons both reach the actions cluster. +2 regression tests. Surfaced by `audit-wrapper-trap` arm 2. File: `components/agent-artifact/agent-artifact.class.js`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`<drawer-ui>` body inset is now register / size-aware (`--drawer-inset` → `var(--a-inset)`).** `--drawer-inset` hardcoded `var(--a-space-4)` (a literal 16px rung); it now reads `var(--a-inset)`, matching its sibling `card-ui` (`--card-inset: var(--a-inset)`). **Behavior-neutral at the default tier** (`--a-inset-md` == `--a-space-4` == 16px); the change is that the drawer body / header / footer inset (header & footer padding derive from `--drawer-inset`) now scales with `[size]` (sm 14 / lg 18), `[verse]` (12), and `[prose]` (40). drawer's `[padding]` / `[bleed]` are layout *modes* (canvas-bg / no-pad), not a value scale — unchanged. Part of the 0.7.7 inset-alignment audit, which left `page` / `canvas` / `table` filter / `richtext` `pre` as intentional fixed rungs (their literals aren't 16px, so a swap would change behavior). Files: `components/drawer/drawer.css`, `components/drawer/drawer.examples.html`.
|
|
22
|
+
- **CDN bundle rebuilt** — `dist/web-components.min.css` regenerated so the `[verse]` `--a-toggle-size` fix + the drawer inset reach `@adia-ai/web-components@0.7` CDN consumers.
|
|
23
|
+
|
|
3
24
|
## [0.7.6] — 2026-06-02
|
|
4
25
|
|
|
5
26
|
### Added
|
|
@@ -139,19 +139,35 @@ export class UIAgentArtifact extends UIElement {
|
|
|
139
139
|
// To keep the Light-DOM approach consistent, we wrap existing children
|
|
140
140
|
// into a body container if one isn't already present.
|
|
141
141
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
// Partition the author's children by LOGICAL slot, piercing the template
|
|
143
|
+
// engine's display:contents / role="presentation" wrapper spans. A consumer
|
|
144
|
+
// that interpolates the action buttons — `${actions.map(a => html`<button-ui
|
|
145
|
+
// slot="primary">…`)}` — gets them nested in wrapper spans (core/template.js
|
|
146
|
+
// wrap()), so the old `:scope > [slot="primary"]` direct-child grab returned
|
|
147
|
+
// nothing and `innerHTML = ''` stranded them in the body. This is the
|
|
148
|
+
// wrapper-trap class (FB-92/96/98); walk + pierce so wrapped action buttons
|
|
149
|
+
// reach the header actions cluster. Wrappers are flattened (display:contents
|
|
150
|
+
// is layout-transparent); everything non-action becomes body.
|
|
151
|
+
const primaryBtns = [];
|
|
152
|
+
const secondaryBtns = [];
|
|
153
|
+
const bodyNodes = [];
|
|
154
|
+
const isWrapper = (el) =>
|
|
155
|
+
el.getAttribute('role') === 'presentation' || el.style?.display === 'contents';
|
|
156
|
+
const partition = (nodes) => {
|
|
157
|
+
for (const n of nodes) {
|
|
158
|
+
if (n.nodeType === 1) {
|
|
159
|
+
const el = /** @type {Element} */ (n);
|
|
160
|
+
if (isWrapper(el)) { partition(el.childNodes); continue; } // pierce; drop the wrapper
|
|
161
|
+
const slot = el.getAttribute('slot') || '';
|
|
162
|
+
if (slot === 'primary') { primaryBtns.push(el); continue; }
|
|
163
|
+
if (slot === 'secondary') { secondaryBtns.push(el); continue; }
|
|
164
|
+
}
|
|
165
|
+
bodyNodes.push(n);
|
|
147
166
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// Clear — we'll rebuild, preserving slotted action buttons
|
|
152
|
-
const primaryBtns = this.querySelectorAll(':scope > [slot="primary"]');
|
|
153
|
-
const secondaryBtns = this.querySelectorAll(':scope > [slot="secondary"]');
|
|
167
|
+
};
|
|
168
|
+
partition(Array.from(this.childNodes));
|
|
154
169
|
|
|
170
|
+
// Clear — we'll rebuild around the captured (now-detached) children.
|
|
155
171
|
this.innerHTML = '';
|
|
156
172
|
|
|
157
173
|
// Header — keyboard-focusable button-style row that toggles collapsed.
|
|
@@ -197,7 +213,7 @@ export class UIAgentArtifact extends UIElement {
|
|
|
197
213
|
// Body
|
|
198
214
|
this.#bodyEl = document.createElement('div');
|
|
199
215
|
this.#bodyEl.setAttribute('data-artifact-body', '');
|
|
200
|
-
for (const n of
|
|
216
|
+
for (const n of bodyNodes) this.#bodyEl.appendChild(n);
|
|
201
217
|
if (this.collapsed) this.#bodyEl.hidden = true;
|
|
202
218
|
|
|
203
219
|
this.append(this.#headerEl, this.#bodyEl);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './agent-artifact.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
|
|
7
|
+
function mount(html) {
|
|
8
|
+
const wrap = document.createElement('div');
|
|
9
|
+
wrap.innerHTML = html;
|
|
10
|
+
document.body.appendChild(wrap);
|
|
11
|
+
return wrap.firstElementChild;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('agent-artifact-ui', () => {
|
|
15
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
16
|
+
|
|
17
|
+
// Baseline — static slot="primary"/"secondary" buttons route into the header
|
|
18
|
+
// actions cluster, and the default-slot content lands in the body.
|
|
19
|
+
it('routes static action buttons into the header actions cluster (not the body)', async () => {
|
|
20
|
+
const a = mount(`
|
|
21
|
+
<agent-artifact-ui title="Build">
|
|
22
|
+
<button-ui slot="primary">Run</button-ui>
|
|
23
|
+
<button-ui slot="secondary">Cancel</button-ui>
|
|
24
|
+
<p>Body content</p>
|
|
25
|
+
</agent-artifact-ui>
|
|
26
|
+
`);
|
|
27
|
+
await tick();
|
|
28
|
+
const actions = a.querySelector('[data-artifact-actions]');
|
|
29
|
+
const body = a.querySelector('[data-artifact-body]');
|
|
30
|
+
expect(actions).toBeTruthy();
|
|
31
|
+
expect(actions.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(2);
|
|
32
|
+
expect(body.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(0);
|
|
33
|
+
expect(body.textContent).toContain('Body content');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Wrapper-trap regression (audit-wrapper-trap arm 2): action buttons rendered
|
|
37
|
+
// via `.map()`/`repeat()` arrive nested in the template engine's
|
|
38
|
+
// display:contents / role="presentation" wrapper spans. The old `:scope >
|
|
39
|
+
// [slot="primary"]` direct-child grab missed them, so `innerHTML = ''`
|
|
40
|
+
// stranded them in the body. They must reach the actions cluster.
|
|
41
|
+
it('routes WRAPPED (interpolated) action buttons into the actions cluster, not the body', async () => {
|
|
42
|
+
const a = mount(`
|
|
43
|
+
<agent-artifact-ui title="Build">
|
|
44
|
+
<span style="display:contents" role="presentation">
|
|
45
|
+
<span style="display:contents" role="presentation"><button-ui slot="primary">Run</button-ui></span>
|
|
46
|
+
<span style="display:contents" role="presentation"><button-ui slot="secondary">Cancel</button-ui></span>
|
|
47
|
+
</span>
|
|
48
|
+
<p>Body content</p>
|
|
49
|
+
</agent-artifact-ui>
|
|
50
|
+
`);
|
|
51
|
+
await tick();
|
|
52
|
+
const actions = a.querySelector('[data-artifact-actions]');
|
|
53
|
+
const body = a.querySelector('[data-artifact-body]');
|
|
54
|
+
expect(actions.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(2);
|
|
55
|
+
// The regression: before the wrapper-pierce fix these landed in the body.
|
|
56
|
+
expect(body.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(0);
|
|
57
|
+
expect(body.textContent).toContain('Body content');
|
|
58
|
+
// No empty wrapper spans left dangling — they're flattened, not re-appended.
|
|
59
|
+
expect(a.querySelectorAll('[role="presentation"]').length).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -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;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
/* ── Geometry ── */
|
|
20
20
|
--drawer-width: 24rem;
|
|
21
21
|
--drawer-height: 24rem;
|
|
22
|
-
--drawer-inset: var(--a-
|
|
22
|
+
--drawer-inset: var(--a-inset);
|
|
23
23
|
--drawer-header-pad: var(--drawer-inset);
|
|
24
24
|
--drawer-footer-pad: var(--drawer-inset);
|
|
25
25
|
--drawer-header-gap: var(--a-space-2);
|
|
@@ -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
|
+
}
|