@adia-ai/web-components 0.6.23 → 0.6.25
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/README.md +1 -1
- package/components/select/class.js +19 -6
- package/components/select/select.test.js +89 -0
- package/components/slider/class.js +15 -0
- package/components/slider/slider.test.js +39 -0
- package/components/table/class.js +7 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog — @adia-ai/web-components
|
|
2
2
|
|
|
3
|
+
## [0.6.25] — 2026-05-22
|
|
4
|
+
|
|
5
|
+
### Fixed — `table-ui` Enter key activates focused body cell (FB-47)
|
|
6
|
+
|
|
7
|
+
- **Enter on a focused body cell now dispatches a click**, triggering the existing `cell-click` → `row-click` pipeline — the same event chain that fires on mouse click. Previously Enter was a no-op on body cells; only header cells triggered sort. Aligns with WAI-ARIA Grid Pattern §6.2.2. File: `components/table/class.js`.
|
|
8
|
+
|
|
9
|
+
### Fixed — `select-ui[multiple]` pre-initialization via `<option selected>` and `.value=` setter (FB-46)
|
|
10
|
+
|
|
11
|
+
- **`[multiple]` now correctly captures ALL `<option selected>` children** as the initial value (joined with commas), not just the first. The single-value `preSelected` accumulator was replaced with `preSelectedArr` that collects every selected option before setting `this.value = arr.join(',')`.
|
|
12
|
+
- **`#renderOptions()` and the aria-selected sync loop** now use a `Set` derived from `this.value.split(',')` for `[multiple]` mode, so every pre-selected option gets `aria-selected="true"` rendered. File: `components/select/class.js`.
|
|
13
|
+
|
|
14
|
+
## [0.6.24] — 2026-05-22
|
|
15
|
+
|
|
16
|
+
### Fixed — `slider-ui` label and suffix template snapshot trap (FB-45)
|
|
17
|
+
|
|
18
|
+
- **`[slot="label"]` and `[slot="suffix"]` now re-sync in `render()`**, resolving the snapshot trap where `label=${expr}` via the template engine rendered the literal `{{p:N}}` placeholder instead of the resolved value. Both spans are updated on every reactive cycle; a missing label span is also created if absent at connection time. File: `components/slider/class.js`.
|
|
19
|
+
|
|
20
|
+
### Fixed — `toast-ui` demo shell missing `feed.css` import
|
|
21
|
+
|
|
22
|
+
- **`toast.html` now imports `feed/feed.css` + `feed/feed.js`.** `<toast-ui>` renders via `<feed-ui>` — the demo shell was not loading feed's stylesheet, so feed items rendered without background, typography, or spacing. Files: `components/toast/toast.html`.
|
|
23
|
+
|
|
3
24
|
## [0.6.23] — 2026-05-22
|
|
4
25
|
|
|
5
26
|
### Added — `row-ui` `wrap-at="bp"` responsive wrap (`components/row/`)
|
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ class MyPanel extends UIElement {
|
|
|
215
215
|
The `el` parameter is the element instance — every signal-backed property is reactively read.
|
|
216
216
|
|
|
217
217
|
Full authoring contract: [`docs/specs/component-token-contract.md`](../../docs/specs/component-token-contract.md).
|
|
218
|
-
The `adia-ui-
|
|
218
|
+
The `adia-ui-authoring` skill encodes the 20 non-negotiable rules.
|
|
219
219
|
|
|
220
220
|
## Card-n / drawer-ui composition parity
|
|
221
221
|
|
|
@@ -171,9 +171,14 @@ export class UISelect extends UIFormElement {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
// Sync aria-selected when value changes
|
|
174
|
+
// §FB-46: [multiple] value is comma-separated — use Set membership.
|
|
174
175
|
if (this.#listbox) {
|
|
176
|
+
const selSet = this.multiple
|
|
177
|
+
? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
|
|
178
|
+
: null;
|
|
175
179
|
for (const opt of this.#listbox.querySelectorAll('[role="option"]')) {
|
|
176
|
-
const
|
|
180
|
+
const v = opt.getAttribute('data-value');
|
|
181
|
+
const selected = selSet ? selSet.has(v) : v === this.value;
|
|
177
182
|
if (selected) opt.setAttribute('aria-selected', 'true');
|
|
178
183
|
else opt.removeAttribute('aria-selected');
|
|
179
184
|
}
|
|
@@ -214,18 +219,19 @@ export class UISelect extends UIFormElement {
|
|
|
214
219
|
const unknownTags = new Set();
|
|
215
220
|
// FEEDBACK-36: capture the value of an <option selected> child so a
|
|
216
221
|
// declarative initial selection is honoured (native <select> parity).
|
|
217
|
-
|
|
222
|
+
// §FB-46: collect ALL selected options for [multiple] mode (not just first).
|
|
223
|
+
const preSelectedArr = [];
|
|
218
224
|
for (const child of this.children) {
|
|
219
225
|
if (child.tagName === 'OPTGROUP') {
|
|
220
226
|
const group = { label: child.label || child.getAttribute('label') || '', options: [] };
|
|
221
227
|
for (const opt of child.querySelectorAll('option')) {
|
|
222
228
|
group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled });
|
|
223
|
-
if (
|
|
229
|
+
if (opt.hasAttribute('selected')) preSelectedArr.push(opt.value);
|
|
224
230
|
}
|
|
225
231
|
this.#options.push(group);
|
|
226
232
|
} else if (child.tagName === 'OPTION') {
|
|
227
233
|
this.#options.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled });
|
|
228
|
-
if (
|
|
234
|
+
if (child.hasAttribute('selected')) preSelectedArr.push(child.value);
|
|
229
235
|
} else if (
|
|
230
236
|
// §225: skip [slot="display"] / [slot="listbox"] / [slot="action"] etc. — these are
|
|
231
237
|
// stamped by the component itself, not consumer-authored options. Anything other
|
|
@@ -239,7 +245,10 @@ export class UISelect extends UIFormElement {
|
|
|
239
245
|
// FEEDBACK-36: honour <option selected> as the initial value when the host
|
|
240
246
|
// carries no [value] — matches native <select> semantics. Guarded on an
|
|
241
247
|
// empty this.value so consumers who set [value] explicitly are unaffected.
|
|
242
|
-
|
|
248
|
+
// §FB-46: for [multiple], join all selected values with comma.
|
|
249
|
+
if (!this.value && preSelectedArr.length) {
|
|
250
|
+
this.value = this.multiple ? preSelectedArr.join(',') : preSelectedArr[0];
|
|
251
|
+
}
|
|
243
252
|
// §225 (v0.5.9): warn once per element when consumer authored non-<option> children.
|
|
244
253
|
if (unknownTags.size > 0 && !UISelect.#warnedNonOption.has(this)) {
|
|
245
254
|
UISelect.#warnedNonOption.add(this);
|
|
@@ -297,7 +306,11 @@ export class UISelect extends UIFormElement {
|
|
|
297
306
|
el.setAttribute('data-value', opt.value || '');
|
|
298
307
|
if (opt.icon) el.innerHTML = `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>${escapeHTML(opt.label)}`;
|
|
299
308
|
else el.textContent = opt.label;
|
|
300
|
-
|
|
309
|
+
// §FB-46: [multiple] value is comma-separated — use Set membership.
|
|
310
|
+
const selSet = this.multiple
|
|
311
|
+
? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
|
|
312
|
+
: null;
|
|
313
|
+
if (selSet ? selSet.has(opt.value) : opt.value === this.value) el.setAttribute('aria-selected', 'true');
|
|
301
314
|
if (opt.disabled) el.setAttribute('aria-disabled', 'true');
|
|
302
315
|
if (opt.action) el.dataset.action = opt.action;
|
|
303
316
|
el.__adiaOption = opt;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './select.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('select-ui', () => {
|
|
15
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
16
|
+
|
|
17
|
+
// §FEEDBACK-36 — declarative <option selected> initial value (single-select)
|
|
18
|
+
it('honours the first <option selected> as initial value for single-select', async () => {
|
|
19
|
+
const s = mount(`
|
|
20
|
+
<select-ui>
|
|
21
|
+
<option value="a">Alpha</option>
|
|
22
|
+
<option value="b" selected>Beta</option>
|
|
23
|
+
<option value="c">Gamma</option>
|
|
24
|
+
</select-ui>
|
|
25
|
+
`);
|
|
26
|
+
await tick();
|
|
27
|
+
expect(s.value).toBe('b');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// §FB-46 — <option selected> must capture ALL values in [multiple] mode
|
|
31
|
+
it('captures all <option selected> values as comma-separated for [multiple]', async () => {
|
|
32
|
+
const s = mount(`
|
|
33
|
+
<select-ui multiple>
|
|
34
|
+
<option value="a" selected>Alpha</option>
|
|
35
|
+
<option value="b">Beta</option>
|
|
36
|
+
<option value="c" selected>Gamma</option>
|
|
37
|
+
</select-ui>
|
|
38
|
+
`);
|
|
39
|
+
await tick();
|
|
40
|
+
// All selected values should be present (order-agnostic)
|
|
41
|
+
const parts = new Set(s.value.split(',').map(v => v.trim()));
|
|
42
|
+
expect(parts.has('a')).toBe(true);
|
|
43
|
+
expect(parts.has('c')).toBe(true);
|
|
44
|
+
expect(parts.has('b')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// §FB-46 — .value setter with comma-separated string must sync aria-selected
|
|
48
|
+
it('syncs aria-selected on all comma-separated values when .value is set', async () => {
|
|
49
|
+
const s = mount(`
|
|
50
|
+
<select-ui multiple open>
|
|
51
|
+
<option value="a">Alpha</option>
|
|
52
|
+
<option value="b">Beta</option>
|
|
53
|
+
<option value="c">Gamma</option>
|
|
54
|
+
</select-ui>
|
|
55
|
+
`);
|
|
56
|
+
await tick();
|
|
57
|
+
s.value = 'a,c';
|
|
58
|
+
await tick();
|
|
59
|
+
|
|
60
|
+
const opts = s.querySelectorAll('[role="option"]');
|
|
61
|
+
const selected = Array.from(opts)
|
|
62
|
+
.filter(o => o.getAttribute('aria-selected') === 'true')
|
|
63
|
+
.map(o => o.getAttribute('data-value'));
|
|
64
|
+
|
|
65
|
+
expect(selected).toContain('a');
|
|
66
|
+
expect(selected).toContain('c');
|
|
67
|
+
expect(selected).not.toContain('b');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Regression: single-select aria-selected must still work with the Set path
|
|
71
|
+
it('still sets aria-selected correctly for single-select after §FB-46 changes', async () => {
|
|
72
|
+
const s = mount(`
|
|
73
|
+
<select-ui open>
|
|
74
|
+
<option value="a">Alpha</option>
|
|
75
|
+
<option value="b">Beta</option>
|
|
76
|
+
</select-ui>
|
|
77
|
+
`);
|
|
78
|
+
await tick();
|
|
79
|
+
s.value = 'b';
|
|
80
|
+
await tick();
|
|
81
|
+
|
|
82
|
+
const opts = s.querySelectorAll('[role="option"]');
|
|
83
|
+
const selected = Array.from(opts)
|
|
84
|
+
.filter(o => o.getAttribute('aria-selected') === 'true')
|
|
85
|
+
.map(o => o.getAttribute('data-value'));
|
|
86
|
+
|
|
87
|
+
expect(selected).toEqual(['b']);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -149,6 +149,21 @@ export class UISlider extends UIFormElement {
|
|
|
149
149
|
const valueEl = this.querySelector('[slot="value"]');
|
|
150
150
|
if (valueEl) valueEl.textContent = this.#format(this.value);
|
|
151
151
|
|
|
152
|
+
// §FB-45: label and suffix are read in connected() but may be set later
|
|
153
|
+
// by the template engine (which first writes {{p:N}} then resolves).
|
|
154
|
+
// Re-sync both slots on every render so reactive bindings work.
|
|
155
|
+
const labelEl = this.querySelector('[slot="label"]');
|
|
156
|
+
if (labelEl) {
|
|
157
|
+
labelEl.textContent = this.label;
|
|
158
|
+
} else if (this.label) {
|
|
159
|
+
const header = this.querySelector('[slot="header"]');
|
|
160
|
+
if (header) { const s = document.createElement('span'); s.slot = 'label'; s.textContent = this.label; header.prepend(s); }
|
|
161
|
+
}
|
|
162
|
+
if (this.label) this.setAttribute('aria-label', this.label);
|
|
163
|
+
|
|
164
|
+
const suffixEl = this.querySelector('[slot="suffix"]');
|
|
165
|
+
if (suffixEl) suffixEl.textContent = this.suffix;
|
|
166
|
+
|
|
152
167
|
this.setAttribute('aria-valuenow', this.value);
|
|
153
168
|
this.setAttribute('aria-valuetext', `${this.#format(this.value)}${this.suffix ? ' ' + this.suffix : ''}`);
|
|
154
169
|
this.syncValue(String(this.value));
|
|
@@ -105,4 +105,43 @@ describe('slider-ui', () => {
|
|
|
105
105
|
await tick();
|
|
106
106
|
expect(s.getAttribute('aria-valuenow')).toBe('80');
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
// §FB-45 (color-app, 2026-05-22) — template engine snapshot trap.
|
|
110
|
+
// connected() stamped label/suffix once; render() didn't re-sync them.
|
|
111
|
+
// Template engine writes {{p:N}} first, then resolves to the real value —
|
|
112
|
+
// simulated here by setAttribute('label', placeholder) then setting the
|
|
113
|
+
// real value. Both spans must reflect the final value, not the placeholder.
|
|
114
|
+
it('re-syncs [slot="label"] when label attribute changes after connection', async () => {
|
|
115
|
+
const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
|
|
116
|
+
await tick();
|
|
117
|
+
// No label initially — span absent
|
|
118
|
+
expect(s.querySelector('[slot="label"]')).toBeNull();
|
|
119
|
+
|
|
120
|
+
// Simulate template engine writing placeholder then real value
|
|
121
|
+
s.setAttribute('label', '{{p:7}}');
|
|
122
|
+
await tick();
|
|
123
|
+
s.setAttribute('label', 'Volume');
|
|
124
|
+
await tick();
|
|
125
|
+
|
|
126
|
+
const labelEl = s.querySelector('[slot="label"]');
|
|
127
|
+
expect(labelEl).not.toBeNull();
|
|
128
|
+
expect(labelEl.textContent).toBe('Volume');
|
|
129
|
+
expect(s.getAttribute('aria-label')).toBe('Volume');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('re-syncs [slot="suffix"] when suffix attribute changes after connection', async () => {
|
|
133
|
+
const s = mount('<slider-ui value="50" min="0" max="100" label="Volume" suffix="%"></slider-ui>');
|
|
134
|
+
await tick();
|
|
135
|
+
const suffixEl = s.querySelector('[slot="suffix"]');
|
|
136
|
+
expect(suffixEl).not.toBeNull();
|
|
137
|
+
expect(suffixEl.textContent).toBe('%');
|
|
138
|
+
|
|
139
|
+
// Simulate placeholder → resolved suffix
|
|
140
|
+
s.setAttribute('suffix', '{{p:8}}');
|
|
141
|
+
await tick();
|
|
142
|
+
s.setAttribute('suffix', 'rem');
|
|
143
|
+
await tick();
|
|
144
|
+
|
|
145
|
+
expect(s.querySelector('[slot="suffix"]').textContent).toBe('rem');
|
|
146
|
+
});
|
|
108
147
|
});
|
|
@@ -1431,12 +1431,18 @@ export class UITable extends UIElement {
|
|
|
1431
1431
|
if (row < -1 || row >= totalRows) { handled = false; break; }
|
|
1432
1432
|
break;
|
|
1433
1433
|
case 'Enter':
|
|
1434
|
-
// If on header row, trigger sort
|
|
1435
1434
|
if (row === -1) {
|
|
1435
|
+
// Header row: trigger sort via the existing click path
|
|
1436
1436
|
const header = this.querySelector(':scope > [data-header]');
|
|
1437
1437
|
const cell = header?.children[col];
|
|
1438
1438
|
const sortKey = cell?.dataset.sortKey;
|
|
1439
1439
|
if (sortKey) cell.click();
|
|
1440
|
+
} else {
|
|
1441
|
+
// Body row: activate focused cell (WAI-ARIA Grid Pattern §6.2.2).
|
|
1442
|
+
// Delegates to the existing click → cell-click → row-click pipeline
|
|
1443
|
+
// so consumers get the same event regardless of mouse vs keyboard.
|
|
1444
|
+
const focused = this.querySelector('[data-focused]');
|
|
1445
|
+
if (focused) focused.click();
|
|
1440
1446
|
}
|
|
1441
1447
|
break;
|
|
1442
1448
|
default:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.25",
|
|
4
4
|
"description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|