@adia-ai/web-components 0.7.19 → 0.7.20

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,10 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.20] — 2026-06-10
4
+
5
+ ### Fixed
6
+ - **`field-ui` (`components/field/`) — control binding survives junk siblings.** `#findControl()` bound the *first slot-less child* as the control; generated markup that lands a `text-ui` "label" or a `<small>` error inside `field-ui` (the 2026-06-10 gen-UI contact-form incident) stole the `id`/`for` wiring from the real input. Binding now prefers a known form-control tag (timing-independent), then a focusable-containing child, then the old first-slot-less fallback. Regression test with the exact incident markup. `dist/web-components.min.js` regenerated.
7
+
3
8
  ## [0.7.19] — 2026-06-10
4
9
 
5
10
  ### Fixed
@@ -246,12 +246,41 @@ export class UIField extends UIElement {
246
246
  /** The default-slot control — first child without a `slot` attribute. */
247
247
  #findControl() {
248
248
  // wrapper-trap-ok: field control is always a single literal child (one <input>/<textarea>/<select>); interpolating the control via .map() is not a documented field pattern
249
+ //
250
+ // Prefer the first slot-less child that is actually a CONTROL (focusable
251
+ // itself or containing a focusable). Generated markup sometimes lands
252
+ // junk siblings inside field-ui (a text-ui "label", a <small> error —
253
+ // the gen-UI contact-form incident, 2026-06-10): first-slot-less-child
254
+ // bound the label as the control, stealing the id/for wiring from the
255
+ // real input. Fall back to the old first-slot-less rule so a not-yet-
256
+ // upgraded custom control (no focusable stamped yet) still binds.
257
+ let fallback = null;
258
+ let focusableHit = null;
249
259
  for (const ch of this.children) {
250
- if (!ch.hasAttribute('slot')) return ch;
260
+ if (ch.hasAttribute('slot')) continue;
261
+ if (fallback === null) fallback = ch;
262
+ // Deterministic first: a known form-control tag wins regardless of
263
+ // upgrade timing (its internal focusable may not be stamped yet at
264
+ // field-ui's connect, and the childList observer can't see grandchild
265
+ // stamps).
266
+ if (UIField.#CONTROL_TAGS.has(ch.localName)) return ch;
267
+ if (focusableHit === null && this.#firstFocusable(ch)) focusableHit = ch;
251
268
  }
252
- return null;
269
+ return focusableHit ?? fallback;
253
270
  }
254
271
 
272
+ /** Form-control tags field-ui binds to by name (timing-independent).
273
+ * Source: the form-bearing primitives (UIFormElement subclasses + native
274
+ * wrappers); extend when a new control primitive lands. */
275
+ static #CONTROL_TAGS = new Set([
276
+ 'input-ui', 'textarea-ui', 'select-ui', 'combobox-ui', 'search-ui',
277
+ 'check-ui', 'radio-ui', 'switch-ui', 'slider-ui', 'range-ui',
278
+ 'otp-input-ui', 'tags-input-ui', 'chat-input-ui', 'autocomplete-input-ui',
279
+ 'datetime-picker-ui', 'date-range-picker-ui', 'calendar-picker-ui',
280
+ 'color-picker-ui', 'upload-ui', 'rating-ui', 'segmented-ui',
281
+ 'input', 'textarea', 'select',
282
+ ]);
283
+
255
284
  /** First focusable descendant (or self) of `root`. Looks for native
256
285
  * `<input>`, `<textarea>`, `<select>`, `<button>`, `[contenteditable]`,
257
286
  * and any `[tabindex]` ≥ 0. Light DOM only. */
@@ -237,4 +237,24 @@ describe('field-ui', () => {
237
237
  /:scope:has\((?:>\s*)?\[slot="hint"\]\s*,\s*(?:>\s*)?\[slot="error"\]\)\s*\{[^}]*row-gap:\s*var\(--field-gap[,)]/
238
238
  );
239
239
  });
240
+
241
+ it('binds the CONTROL (not junk label/error siblings) — the gen-UI contact-form incident', async () => {
242
+ // Generated markup sometimes lands a text-ui "label" + a <small> error
243
+ // INSIDE field-ui (the 2026-06-10 canvas incident). The old first-slot-less
244
+ // rule bound the text-ui as the control, stealing the id/for wiring.
245
+ const el = mount(`
246
+ <field-ui label="Full Name">
247
+ <text-ui variant="label" weight="semibold">Full Name</text-ui>
248
+ <input-ui type="text" name="name" placeholder="Enter your full name"></input-ui>
249
+ <small color="danger">Name is required</small>
250
+ </field-ui>
251
+ `);
252
+ await tick(); await tick();
253
+ const input = el.querySelector('input-ui');
254
+ const textUi = el.querySelector('text-ui');
255
+ const label = el.querySelector('[data-field-label]');
256
+ expect(input.id).toMatch(/^field-ctl-/); // the real control got the binding
257
+ expect(textUi.id || '').not.toMatch(/^field-ctl-/); // the junk label did NOT
258
+ expect(label.getAttribute('for')).toBe(input.id);
259
+ });
240
260
  });