@adia-ai/web-components 0.0.11 → 0.0.12

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.
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { AdiaElement, signal, effect } from './element.js';
3
+
4
+ // ── Shared helpers ──
5
+
6
+ let registrationCounter = 0;
7
+ function registerTestElement(BaseImpl) {
8
+ const tag = `test-el-${++registrationCounter}`;
9
+ customElements.define(tag, BaseImpl);
10
+ return tag;
11
+ }
12
+
13
+ function mount(tag, attrs = {}) {
14
+ const el = document.createElement(tag);
15
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
16
+ document.body.appendChild(el);
17
+ return el;
18
+ }
19
+
20
+ // Signals flush via queueMicrotask — await one tick to let reactive
21
+ // effects settle after a mutation.
22
+ const tick = () => new Promise((r) => queueMicrotask(r));
23
+
24
+ describe('AdiaElement — construction + lifecycle', () => {
25
+ beforeEach(() => { document.body.innerHTML = ''; });
26
+
27
+ it('instantiates and calls connected() on mount, disconnected() on remove', () => {
28
+ const events = [];
29
+ class El extends AdiaElement {
30
+ connected() { events.push('connected'); }
31
+ disconnected() { events.push('disconnected'); }
32
+ }
33
+ const tag = registerTestElement(El);
34
+ const el = mount(tag);
35
+ expect(events).toEqual(['connected']);
36
+ el.remove();
37
+ expect(events).toEqual(['connected', 'disconnected']);
38
+ });
39
+
40
+ it('remount after disconnect re-runs connected()', () => {
41
+ const events = [];
42
+ class El extends AdiaElement {
43
+ connected() { events.push('connected'); }
44
+ disconnected() { events.push('disconnected'); }
45
+ }
46
+ const tag = registerTestElement(El);
47
+ const el = mount(tag);
48
+ el.remove();
49
+ document.body.appendChild(el);
50
+ expect(events).toEqual(['connected', 'disconnected', 'connected']);
51
+ });
52
+ });
53
+
54
+ describe('AdiaElement — properties', () => {
55
+ beforeEach(() => { document.body.innerHTML = ''; });
56
+
57
+ it('reads default value from static properties', () => {
58
+ class El extends AdiaElement {
59
+ static properties = { label: { type: String, default: 'hi' } };
60
+ }
61
+ const tag = registerTestElement(El);
62
+ const el = mount(tag);
63
+ expect(el.label).toBe('hi');
64
+ });
65
+
66
+ it('reflects Boolean properties to HTML attributes when reflect: true', () => {
67
+ class El extends AdiaElement {
68
+ static properties = { disabled: { type: Boolean, default: false, reflect: true } };
69
+ }
70
+ const tag = registerTestElement(El);
71
+ const el = mount(tag);
72
+ expect(el.hasAttribute('disabled')).toBe(false);
73
+ el.disabled = true;
74
+ expect(el.hasAttribute('disabled')).toBe(true);
75
+ el.disabled = false;
76
+ expect(el.hasAttribute('disabled')).toBe(false);
77
+ });
78
+
79
+ it('reflects String properties as attribute values', () => {
80
+ class El extends AdiaElement {
81
+ static properties = { variant: { type: String, default: 'neutral', reflect: true } };
82
+ }
83
+ const tag = registerTestElement(El);
84
+ const el = mount(tag);
85
+ el.variant = 'accent';
86
+ expect(el.getAttribute('variant')).toBe('accent');
87
+ });
88
+
89
+ it('parses incoming attributes back into typed properties', () => {
90
+ class El extends AdiaElement {
91
+ static properties = {
92
+ count: { type: Number, default: 0 },
93
+ active: { type: Boolean, default: false },
94
+ };
95
+ }
96
+ const tag = registerTestElement(El);
97
+ const el = mount(tag, { count: '42', active: '' });
98
+ expect(el.count).toBe(42);
99
+ expect(el.active).toBe(true);
100
+ });
101
+
102
+ it('does NOT reflect when reflect: false / omitted', () => {
103
+ class El extends AdiaElement {
104
+ static properties = { internal: { type: String, default: 'x' } };
105
+ }
106
+ const tag = registerTestElement(El);
107
+ const el = mount(tag);
108
+ el.internal = 'y';
109
+ expect(el.hasAttribute('internal')).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('AdiaElement — reactive effect', () => {
114
+ beforeEach(() => { document.body.innerHTML = ''; });
115
+
116
+ it('calls render() on mount and when a reactive property changes', async () => {
117
+ let renders = 0;
118
+ class El extends AdiaElement {
119
+ static properties = { label: { type: String, default: 'a' } };
120
+ render() { renders++; void this.label; }
121
+ }
122
+ const tag = registerTestElement(El);
123
+ const el = mount(tag);
124
+ expect(renders).toBeGreaterThan(0);
125
+ const before = renders;
126
+ el.label = 'b';
127
+ await tick();
128
+ expect(renders).toBeGreaterThan(before);
129
+ });
130
+
131
+ it('stops re-rendering after disconnect', async () => {
132
+ let renders = 0;
133
+ class El extends AdiaElement {
134
+ static properties = { label: { type: String, default: 'a' } };
135
+ render() { renders++; void this.label; }
136
+ }
137
+ const tag = registerTestElement(El);
138
+ const el = mount(tag);
139
+ el.remove();
140
+ const frozen = renders;
141
+ el.label = 'c';
142
+ await tick();
143
+ expect(renders).toBe(frozen);
144
+ });
145
+ });
146
+
147
+ describe('AdiaElement — subscription-leak guard (0.0.10 regression)', () => {
148
+ beforeEach(() => { document.body.innerHTML = ''; });
149
+
150
+ it('child connected() reads do NOT subscribe an outer effect', async () => {
151
+ // The bug: when a parent effect called appendChild(child) while running,
152
+ // child.connectedCallback would synchronously call connected(), and any
153
+ // template-literal reads of child's reactive properties would subscribe
154
+ // the OUTER effect to the CHILD's signals. A later change to a child
155
+ // prop would spuriously re-run the parent effect.
156
+ //
157
+ // Fix: connected() is now wrapped in untracked(…).
158
+ class Child extends AdiaElement {
159
+ static properties = { value: { type: String, default: 'initial', reflect: true } };
160
+ connected() {
161
+ // Read our own reactive prop exactly the way template-literal
162
+ // interpolation would — this read must not subscribe an ancestor
163
+ // effect.
164
+ void this.value;
165
+ }
166
+ }
167
+ const tag = registerTestElement(Child);
168
+
169
+ const trigger = signal(0);
170
+ let outerRuns = 0;
171
+ let childRef = null;
172
+
173
+ const dispose = effect(() => {
174
+ outerRuns++;
175
+ void trigger.value;
176
+ if (!childRef) {
177
+ childRef = document.createElement(tag);
178
+ document.body.appendChild(childRef);
179
+ }
180
+ });
181
+
182
+ const outerRunsAfterMount = outerRuns;
183
+ expect(childRef).not.toBeNull();
184
+
185
+ // Now mutate the CHILD's signal — the outer effect MUST NOT re-run.
186
+ childRef.value = 'changed';
187
+ await tick();
188
+ expect(outerRuns).toBe(outerRunsAfterMount);
189
+
190
+ // Sanity check: mutating the trigger signal the outer effect DID
191
+ // subscribe to re-runs the effect, proving the effect is alive.
192
+ trigger.value = 1;
193
+ await tick();
194
+ expect(outerRuns).toBe(outerRunsAfterMount + 1);
195
+
196
+ dispose();
197
+ });
198
+ });
199
+
200
+ describe('AdiaElement — addTrait', () => {
201
+ beforeEach(() => { document.body.innerHTML = ''; });
202
+
203
+ it('applies a trait once, idempotent on repeat', () => {
204
+ let connects = 0;
205
+ const myTrait = () => ({
206
+ connect() { connects++; },
207
+ disconnect() {},
208
+ });
209
+
210
+ class El extends AdiaElement {}
211
+ const tag = registerTestElement(El);
212
+ const el = mount(tag);
213
+ el.addTrait(myTrait);
214
+ el.addTrait(myTrait);
215
+ expect(connects).toBe(1);
216
+ });
217
+
218
+ it('disconnects all applied traits on remove', () => {
219
+ let connects = 0, disconnects = 0;
220
+ const myTrait = () => ({
221
+ connect() { connects++; },
222
+ disconnect() { disconnects++; },
223
+ });
224
+ class El extends AdiaElement {
225
+ static traits = [myTrait];
226
+ }
227
+ const tag = registerTestElement(El);
228
+ const el = mount(tag);
229
+ expect(connects).toBe(1);
230
+ expect(disconnects).toBe(0);
231
+ el.remove();
232
+ expect(disconnects).toBe(1);
233
+ });
234
+ });
package/core/form.js CHANGED
@@ -178,8 +178,34 @@ export class AdiaFormElement extends AdiaElement {
178
178
  this.addEventListener('invalid', this.#onInvalid);
179
179
  this.addEventListener('input', this.#onInput);
180
180
  this.addEventListener('blur', this.#onBlur, true);
181
+ this.#warnDeprecatedLabel();
181
182
  }
182
183
 
184
+ /**
185
+ * Soft-deprecation warning for the per-control `label` attribute.
186
+ * Wrapping the control in `<field-ui label="…">` (Phase 1 shipped
187
+ * 2026-04-23) gives proper `<label for>` association and composition
188
+ * slots; the per-control `label` attr renders via an inert shadow
189
+ * slot without `[for]`, so click-to-focus doesn't work.
190
+ *
191
+ * One-shot per class so the console isn't spammed on docs pages.
192
+ */
193
+ #warnDeprecatedLabel() {
194
+ const ctor = this.constructor;
195
+ if (!ctor.properties?.label) return;
196
+ if (!this.hasAttribute('label')) return;
197
+ if (AdiaFormElement.#labelWarned.has(ctor)) return;
198
+ AdiaFormElement.#labelWarned.add(ctor);
199
+ // eslint-disable-next-line no-console
200
+ console.warn(
201
+ `[AdiaUI] <${this.localName} label="…"> is deprecated — wrap in ` +
202
+ `<field-ui label="…"> for proper label association. ` +
203
+ `Docs: https://ui-kit.exe.xyz/site/components/field`
204
+ );
205
+ }
206
+
207
+ static #labelWarned = new Set();
208
+
183
209
  disconnected() {
184
210
  super.disconnected();
185
211
  this.removeEventListener('invalid', this.#onInvalid);
package/core/markdown.js CHANGED
@@ -19,7 +19,10 @@ export function renderMarkdown(src) {
19
19
  while (i < lines.length) {
20
20
  const line = lines[i];
21
21
 
22
- // Fenced code block
22
+ // Fenced code block — when a language is named, emit <code-ui> so
23
+ // the site picks up the syntax-highlight upgrade shipped in
24
+ // SPEC-CODE-EDITOR-001. Unlabeled fences keep the bare <pre><code>
25
+ // form (no highlight needed; zero CM6 cost).
23
26
  const fence = line.match(/^```(\w*)/);
24
27
  if (fence) {
25
28
  const lang = fence[1];
@@ -30,7 +33,10 @@ export function renderMarkdown(src) {
30
33
  i++;
31
34
  }
32
35
  i++; // skip closing ```
33
- out.push(`<pre><code${lang ? ` data-lang="${lang}"` : ''}>${code.join('\n')}</code></pre>`);
36
+ const body = code.join('\n');
37
+ out.push(lang
38
+ ? `<code-ui language="${lang}">${body}</code-ui>`
39
+ : `<pre><code>${body}</code></pre>`);
34
40
  continue;
35
41
  }
36
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -419,19 +419,46 @@
419
419
  --a-danger-border-invalid: var(--a-danger-border);
420
420
 
421
421
  /* ══════════════════════════════════════════════════════════════
422
- FOCUS
423
- ══════════════════════════════════════════════════════════════ */
424
-
425
- --a-focus-color: var(--a-accent-strong);
426
- --a-focus-width: 2px;
427
- --a-focus-offset: 2px;
428
- --a-focus-ring-shadow: 0 0 0 var(--a-focus-offset) var(--a-canvas-0),
422
+ FOCUS — canonical ring recipe shared by all form controls
423
+ ══════════════════════════════════════════════════════════════
424
+
425
+ The ring is a dual-layer box-shadow: an inner "gap" the canvas
426
+ color wide, then an outer ring the accent color wide. Tuning any
427
+ of the scalar tokens below cascades to every component that
428
+ consumes `--a-focus-ring`:
429
+
430
+ --a-focus-width — outer ring thickness
431
+ --a-focus-offset — inner gap thickness (distance from element
432
+ edge to ring)
433
+ --a-focus-color — ring color (default: accent-strong)
434
+ --a-focus-bg — gap color (default: the surface under the
435
+ control; normally matches --a-canvas-0)
436
+
437
+ Invalid / error state uses the same geometry but a danger color
438
+ via `--a-focus-ring-invalid`.
439
+
440
+ Components must NOT hardcode 2px / 4px / accent hex in their CSS;
441
+ consume either `--a-focus-ring` directly or an L3 alias like
442
+ `--{component}-focus-ring: var(--a-focus-ring)` (the more common
443
+ pattern — gives per-component override headroom without breaking
444
+ the canonical default). */
445
+
446
+ --a-focus-color: var(--a-accent-strong);
447
+ --a-focus-color-invalid: var(--a-danger);
448
+ --a-focus-width: 2px;
449
+ --a-focus-offset: 2px;
450
+ --a-focus-bg: var(--a-canvas-0);
451
+
452
+ --a-focus-ring-shadow: 0 0 0 var(--a-focus-offset) var(--a-focus-bg),
429
453
  0 0 0 calc(var(--a-focus-offset) + var(--a-focus-width)) var(--a-focus-color);
430
-
431
- /* L3 */
432
- --a-ring: var(--a-focus-color);
433
- --a-ring-invalid: var(--a-danger);
434
- --a-focus-ring: var(--a-focus-ring-shadow);
454
+ --a-focus-ring-shadow-invalid: 0 0 0 var(--a-focus-offset) var(--a-focus-bg),
455
+ 0 0 0 calc(var(--a-focus-offset) + var(--a-focus-width)) var(--a-focus-color-invalid);
456
+
457
+ /* L3 — what components consume. */
458
+ --a-ring: var(--a-focus-color);
459
+ --a-ring-invalid: var(--a-focus-color-invalid);
460
+ --a-focus-ring: var(--a-focus-ring-shadow);
461
+ --a-focus-ring-invalid: var(--a-focus-ring-shadow-invalid);
435
462
 
436
463
  /* ══════════════════════════════════════════════════════════════
437
464
  UI (FIELD) — Form input surfaces
package/styles/styles.css CHANGED
@@ -41,6 +41,7 @@
41
41
  @import "../components/kbd/kbd.css";
42
42
  @import "../components/tag/tag.css";
43
43
  @import "../components/col/col.css";
44
+ @import "../components/field/field.css";
44
45
  @import "../components/row/row.css";
45
46
  @import "../components/grid/grid.css";
46
47
  @import "../components/stack/stack.css";