@adia-ai/web-components 0.6.24 → 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 CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## [0.6.24] — 2026-05-22
4
15
 
5
16
  ### Fixed — `slider-ui` label and suffix template snapshot trap (FB-45)
@@ -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 selected = opt.getAttribute('data-value') === this.value;
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
- let preSelected;
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 (preSelected === undefined && opt.hasAttribute('selected')) preSelected = opt.value;
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 (preSelected === undefined && child.hasAttribute('selected')) preSelected = child.value;
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
- if (!this.value && preSelected !== undefined) this.value = preSelected;
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
- if (opt.value === this.value) el.setAttribute('aria-selected', 'true');
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
+ });
@@ -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.24",
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",