@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 +5 -0
- package/components/field/field.class.js +31 -2
- package/components/field/field.test.js +20 -0
- package/dist/web-components.min.js +26 -26
- package/package.json +1 -1
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 (
|
|
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
|
|
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
|
});
|