@adia-ai/web-components 0.7.18 → 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,15 @@
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
+
8
+ ## [0.7.19] — 2026-06-10
9
+
10
+ ### Fixed
11
+ - **`icon-ui` — glyphs always paint on the standalone import path.** Importing `components/icon` directly (or a demo page loading only `./icon.js`) never loaded `core/icons-phosphor.js`, so the weight-loader map stayed empty and every `getIcon()` returned `''` — **silently** (the not-ready guard suppressed the warning): blank icons, zero console errors. Two fixes: `icon.js` now imports the Phosphor loader as a side-effect (the barrel paths already did), and `icon.class.js` gains a signal-reactive retry (`#rev` bumped by `whenIconRegistryReady`, aborted symmetrically in `disconnected()`) so elements that connect *after* the loaders install — e.g. fetched-and-injected demo markup — re-render once the registry is ready. Production (barrel-served) was never affected; the docs/demo single-component path was. Regression probe: `scripts/dev/icon-paint-probe.mjs`. `dist/web-components.min.js` (and the web-modules kitchen-sink bundle) regenerated to carry the fix.
12
+
3
13
  ## [0.7.18] — 2026-06-09
4
14
 
5
15
  ### Changed
@@ -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
  });
@@ -11,8 +11,8 @@
11
11
  * @see ../../USAGE.md#registration--auto-vs-explicit
12
12
  */
13
13
 
14
- import { UIElement, signal, html } from '../../core/element.js';
15
- import { getIcon } from '../../core/icons.js';
14
+ import { UIElement } from '../../core/element.js';
15
+ import { getIcon, whenIconRegistryReady } from '../../core/icons.js';
16
16
 
17
17
  export class UIIcon extends UIElement {
18
18
  static properties = {
@@ -23,12 +23,42 @@ export class UIIcon extends UIElement {
23
23
  weight: { type: String, default: 'regular', reflect: true },
24
24
  };
25
25
 
26
+ // Incremented when the icon registry becomes ready so the reactive
27
+ // render() re-runs automatically after the loader map arrives.
28
+ // Declared as a private field (not a static property) to keep it out
29
+ // of the attribute reflection loop — it is a purely internal trigger.
30
+ #rev = this.signal(0);
31
+
32
+ // Abort controller for the registry-ready subscription. Cancelled on
33
+ // disconnectedCallback so elements removed before the registry resolves
34
+ // do not attempt a render on a detached host.
35
+ #readyCtrl = null;
36
+
26
37
  connected() {
27
38
  this.setAttribute('role', this.label ? 'img' : 'presentation');
28
39
  if (!this.label) this.setAttribute('aria-hidden', 'true');
40
+
41
+ // Subscribe to the registry-ready promise ONCE per connect cycle.
42
+ // When installIconLoaders() fires (could be before or after connect),
43
+ // bump #rev so the reactive render() reruns and picks up the SVG.
44
+ // Guard: if the registry was already ready (e.g. warm page reload),
45
+ // the .then() fires in a microtask — still safe, just a no-op repaint.
46
+ this.#readyCtrl = new AbortController();
47
+ const ctrl = this.#readyCtrl;
48
+ whenIconRegistryReady.then(() => {
49
+ if (!ctrl.signal.aborted) this.#rev.value++;
50
+ });
51
+ }
52
+
53
+ disconnected() {
54
+ this.#readyCtrl?.abort();
55
+ this.#readyCtrl = null;
29
56
  }
30
57
 
31
58
  render() {
59
+ // Read #rev so this render() is tracked as a dependent of the registry
60
+ // arrival. The value itself is not used — the read is the tracking hook.
61
+ void this.#rev.value;
32
62
  if (this.label) this.setAttribute('aria-label', this.label);
33
63
  const svg = getIcon(this.name, this.weight || 'regular');
34
64
  if (svg && this.innerHTML !== svg) this.innerHTML = svg;
@@ -12,6 +12,17 @@
12
12
  import { defineIfFree } from '../../core/register.js';
13
13
  import { UIIcon } from './icon.class.js';
14
14
 
15
+ // Side-effect: wires the full @phosphor-icons/core asset tree into the icon
16
+ // registry so that any Phosphor name resolves without pre-registration.
17
+ // icon-ui is a glyph renderer — the icon loader is required for it to paint.
18
+ // The barrel (components/index.js) and core barrel both carry this import
19
+ // already; this import ensures standalone usage of the icon component
20
+ // (e.g. `import '@adia-ai/web-components/components/icon'`) also works.
21
+ // Apps with bundle-size SLOs that manage their own icon registration should
22
+ // import the class subpath directly and call installIconLoaders() themselves:
23
+ // import { UIIcon } from '@adia-ai/web-components/components/icon/class';
24
+ import '../../core/icons-phosphor.js';
25
+
15
26
  defineIfFree('icon-ui', UIIcon);
16
27
 
17
28
  export { UIIcon };