@adia-ai/web-components 0.0.14 → 0.0.16
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/README.md +43 -1
- package/components/alert/alert.css +5 -0
- package/components/alert/alert.js +4 -2
- package/components/button/button.js +4 -1
- package/components/chart/chart.js +7 -4
- package/components/chat/chat-input.js +13 -2
- package/components/description-list/description-list.js +4 -3
- package/components/field/field.css +113 -63
- package/components/field/field.js +44 -142
- package/components/icon/icon.a2ui.json +1 -1
- package/components/icon/icon.css +16 -0
- package/components/icon/icon.js +18 -0
- package/components/icon/icon.yaml +6 -2
- package/components/index.js +7 -0
- package/components/input/input.a2ui.json +1 -1
- package/components/input/input.css +21 -23
- package/components/input/input.js +36 -9
- package/components/input/input.yaml +3 -1
- package/components/option-card/option-card.a2ui.json +262 -0
- package/components/option-card/option-card.css +215 -0
- package/components/option-card/option-card.js +158 -0
- package/components/option-card/option-card.yaml +234 -0
- package/components/rating/rating.a2ui.json +10 -0
- package/components/rating/rating.yaml +8 -0
- package/components/segment/segment.a2ui.json +5 -0
- package/components/segment/segment.css +2 -0
- package/components/segment/segment.js +21 -1
- package/components/segment/segment.yaml +5 -0
- package/components/textarea/textarea.css +3 -1
- package/components/textarea/textarea.js +2 -2
- package/components/tooltip/tooltip.js +10 -3
- package/core/data-stream.js +486 -0
- package/core/form.js +5 -0
- package/core/index.js +2 -0
- package/core/streams-bridge.js +96 -0
- package/package.json +1 -1
- package/styles/colors/semantics.css +21 -3
- package/styles/components.css +1 -0
- package/styles/prose.css +3 -7
- package/styles/tokens.css +7 -4
- package/styles/typography.css +6 -1
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data-stream — attribute-driven data ingestion, signal-backed.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (signal-based, refcounted):
|
|
5
|
+
*
|
|
6
|
+
* ┌──────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ STREAMS: Map<streamId, { signal, refs, transport, opts }>│
|
|
8
|
+
* └──────────────────────────────────────────────────────────┘
|
|
9
|
+
* │ first ref creates transport
|
|
10
|
+
* │ each tick / message: signal.value = parsed
|
|
11
|
+
* │ last ref ends transport
|
|
12
|
+
* ▼
|
|
13
|
+
* ┌──────────────────────────────────────────────────────────┐
|
|
14
|
+
* │ Per-element effect: │
|
|
15
|
+
* │ effect(() => applyData(el, stream.signal.value, opts)) │
|
|
16
|
+
* │ Subscribes to the shared signal — fires on every update. │
|
|
17
|
+
* └──────────────────────────────────────────────────────────┘
|
|
18
|
+
*
|
|
19
|
+
* Stream identity:
|
|
20
|
+
* - Explicit `data-stream-id="..."` shares across elements.
|
|
21
|
+
* - When unset, the id is a stable hash of (src, mode, interval,
|
|
22
|
+
* method, body, headers, format, event). Two elements with
|
|
23
|
+
* attribute-identical configs share one transport automatically.
|
|
24
|
+
*
|
|
25
|
+
* Universal applicability — any element with a settable property:
|
|
26
|
+
* <chart-ui data-stream-src="/api/series" data-stream-path="data">
|
|
27
|
+
* <table-ui data-stream-src="/api/orders" data-stream-interval="5000">
|
|
28
|
+
* <stat-ui data-stream-src="/api/kpi" data-stream-target="*">
|
|
29
|
+
* <text-ui data-stream-src="/api/range" data-stream-target="textContent">
|
|
30
|
+
* <heatmap-ui data-stream-src="/sse/cells" data-stream-mode="sse">
|
|
31
|
+
*
|
|
32
|
+
* Programmatic access: `streams` is exported as a read-only Map of
|
|
33
|
+
* stream-id → signal so app code can subscribe with `effect()` or read
|
|
34
|
+
* `.value` directly.
|
|
35
|
+
*
|
|
36
|
+
* Events (all bubble from the element):
|
|
37
|
+
* stream-load — first signal value received for this element
|
|
38
|
+
* stream-update — each subsequent value, detail.data = the new value
|
|
39
|
+
* stream-error — transport-level error, detail.error = message
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { signal, effect, untracked } from './signals.js';
|
|
43
|
+
|
|
44
|
+
const ATTRS = {
|
|
45
|
+
src: 'data-stream-src',
|
|
46
|
+
mode: 'data-stream-mode',
|
|
47
|
+
interval: 'data-stream-interval',
|
|
48
|
+
path: 'data-stream-path',
|
|
49
|
+
event: 'data-stream-event',
|
|
50
|
+
method: 'data-stream-method',
|
|
51
|
+
headers: 'data-stream-headers',
|
|
52
|
+
body: 'data-stream-body',
|
|
53
|
+
target: 'data-stream-target',
|
|
54
|
+
merge: 'data-stream-merge',
|
|
55
|
+
format: 'data-stream-format',
|
|
56
|
+
id: 'data-stream-id',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const STREAMS = new Map(); /* streamId → { signal, refs, transport, opts } */
|
|
60
|
+
const ELEMENT_STREAMS = new WeakMap(); /* el → { streamId, dispose, loaded } */
|
|
61
|
+
const PENDING = new Map(); /* streamId → array of whenStream resolvers */
|
|
62
|
+
const WARNED = new WeakSet();
|
|
63
|
+
|
|
64
|
+
/* Read-only public view of the streams registry. App code can do:
|
|
65
|
+
`import { streams } from '@adia-ai/web-components/core/data-stream';
|
|
66
|
+
streams.get('rev').signal.value` to read; subscribe via effect(). */
|
|
67
|
+
export const streams = {
|
|
68
|
+
get(id) { return STREAMS.get(id); },
|
|
69
|
+
has(id) { return STREAMS.has(id); },
|
|
70
|
+
keys() { return STREAMS.keys(); },
|
|
71
|
+
size() { return STREAMS.size; },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/* Resolves with the stream entry once it's registered. If the stream
|
|
75
|
+
already exists, resolves synchronously on the next microtask; if not,
|
|
76
|
+
resolves the moment the first DOM consumer (or any caller of
|
|
77
|
+
acquireStream) creates it. Useful for cross-package adapters
|
|
78
|
+
(streams-bridge, A2UI surfaces) that may run before the DOM source
|
|
79
|
+
has connected. The promise never rejects — pair with AbortSignal at
|
|
80
|
+
the caller if you need cancellation. */
|
|
81
|
+
export function whenStream(id) {
|
|
82
|
+
if (STREAMS.has(id)) return Promise.resolve(STREAMS.get(id));
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
if (!PENDING.has(id)) PENDING.set(id, []);
|
|
85
|
+
PENDING.get(id).push(resolve);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function attr(el, key) { return el.getAttribute(ATTRS[key]) ?? ''; }
|
|
90
|
+
function dispatch(el, name, detail) {
|
|
91
|
+
el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
|
92
|
+
}
|
|
93
|
+
function warnOnce(el, msg) {
|
|
94
|
+
if (WARNED.has(el)) return;
|
|
95
|
+
WARNED.add(el);
|
|
96
|
+
console.warn(`[data-stream] ${msg}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Stable string hash of a config for auto-id. djb2-style; collisions
|
|
100
|
+
would be benign (two streams share a transport that fetches the same
|
|
101
|
+
thing) but practically impossible for distinct configs. */
|
|
102
|
+
function configHash(parts) {
|
|
103
|
+
const s = parts.join('|');
|
|
104
|
+
let h = 5381;
|
|
105
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
106
|
+
return `auto:${(h >>> 0).toString(36)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function streamIdFor(el) {
|
|
110
|
+
const explicit = attr(el, 'id');
|
|
111
|
+
if (explicit) return `id:${explicit}`;
|
|
112
|
+
return configHash([
|
|
113
|
+
attr(el, 'src'), attr(el, 'mode'), attr(el, 'interval'),
|
|
114
|
+
attr(el, 'method'), attr(el, 'body'), attr(el, 'headers'),
|
|
115
|
+
attr(el, 'format'), attr(el, 'event'),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function streamConfigFor(el) {
|
|
120
|
+
return {
|
|
121
|
+
src: attr(el, 'src'),
|
|
122
|
+
mode: (attr(el, 'mode') || 'http').toLowerCase(),
|
|
123
|
+
interval: parseInt(attr(el, 'interval'), 10) || 0,
|
|
124
|
+
method: attr(el, 'method') || 'GET',
|
|
125
|
+
body: attr(el, 'body') || undefined,
|
|
126
|
+
headers: parseHeaders(attr(el, 'headers')),
|
|
127
|
+
format: attr(el, 'format'),
|
|
128
|
+
event: attr(el, 'event') || 'message',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseHeaders(raw) {
|
|
133
|
+
if (!raw) return undefined;
|
|
134
|
+
try { return JSON.parse(raw); } catch { return undefined; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ── Format parsing (CSV / TSV / JSONL / text / json) ──────────────── */
|
|
138
|
+
|
|
139
|
+
function detectFormat(src, contentType) {
|
|
140
|
+
const url = src.toLowerCase().split('?')[0];
|
|
141
|
+
if (url.endsWith('.csv')) return 'csv';
|
|
142
|
+
if (url.endsWith('.tsv')) return 'tsv';
|
|
143
|
+
if (url.endsWith('.jsonl') || url.endsWith('.ndjson')) return 'jsonl';
|
|
144
|
+
if (url.endsWith('.txt')) return 'text';
|
|
145
|
+
const ct = (contentType || '').toLowerCase();
|
|
146
|
+
if (ct.includes('text/csv')) return 'csv';
|
|
147
|
+
if (ct.includes('text/tab-separated-values')) return 'tsv';
|
|
148
|
+
if (ct.includes('application/x-ndjson') ||
|
|
149
|
+
ct.includes('application/jsonl')) return 'jsonl';
|
|
150
|
+
if (ct.includes('text/plain') && !ct.includes('json')) return 'text';
|
|
151
|
+
return 'json';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseDelimited(text, delimiter) {
|
|
155
|
+
const rows = []; let row = []; let cell = ''; let inQuote = false;
|
|
156
|
+
for (let i = 0; i < text.length; i++) {
|
|
157
|
+
const c = text[i];
|
|
158
|
+
if (inQuote) {
|
|
159
|
+
if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else inQuote = false; }
|
|
160
|
+
else cell += c;
|
|
161
|
+
} else {
|
|
162
|
+
if (c === '"' && cell === '') inQuote = true;
|
|
163
|
+
else if (c === delimiter) { row.push(cell); cell = ''; }
|
|
164
|
+
else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
|
|
165
|
+
else if (c === '\r') { /* skip */ }
|
|
166
|
+
else cell += c;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); }
|
|
170
|
+
if (rows.length === 0) return [];
|
|
171
|
+
const header = rows.shift();
|
|
172
|
+
return rows
|
|
173
|
+
.filter(r => !(r.length === 1 && r[0] === ''))
|
|
174
|
+
.filter(r => r.length === header.length)
|
|
175
|
+
.map(r => {
|
|
176
|
+
const obj = {};
|
|
177
|
+
for (let i = 0; i < header.length; i++) {
|
|
178
|
+
const v = r[i]; const n = +v;
|
|
179
|
+
obj[header[i]] = (v !== '' && Number.isFinite(n) && /^-?\d/.test(v)) ? n : v;
|
|
180
|
+
}
|
|
181
|
+
return obj;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseJSONL(text) {
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const line of text.split('\n')) {
|
|
188
|
+
const t = line.trim();
|
|
189
|
+
if (!t || t.startsWith('//')) continue;
|
|
190
|
+
try { out.push(JSON.parse(t)); } catch { /* skip */ }
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseBody(text, format) {
|
|
196
|
+
switch (format) {
|
|
197
|
+
case 'json': return JSON.parse(text);
|
|
198
|
+
case 'csv': return parseDelimited(text, ',');
|
|
199
|
+
case 'tsv': return parseDelimited(text, '\t');
|
|
200
|
+
case 'jsonl': return parseJSONL(text);
|
|
201
|
+
case 'text': return text;
|
|
202
|
+
default: return JSON.parse(text);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ── Transports (HTTP / SSE / WS) — write to stream.signal ─────────── */
|
|
207
|
+
|
|
208
|
+
function startTransport(stream) {
|
|
209
|
+
const { src, mode } = stream.opts;
|
|
210
|
+
if (!src) return { stop() {} };
|
|
211
|
+
if (mode === 'http') return startHTTP(stream);
|
|
212
|
+
if (mode === 'sse') return startSSE(stream);
|
|
213
|
+
if (mode === 'ws' || mode === 'websocket') return startWS(stream);
|
|
214
|
+
console.warn(`[data-stream] Unknown mode "${mode}". Use http, sse, or ws.`);
|
|
215
|
+
return { stop() {} };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function startHTTP(stream) {
|
|
219
|
+
const { src, interval, method, body, headers, format: explicit } = stream.opts;
|
|
220
|
+
const abort = new AbortController();
|
|
221
|
+
let timer = null;
|
|
222
|
+
let stopped = false;
|
|
223
|
+
|
|
224
|
+
const tick = async () => {
|
|
225
|
+
if (stopped) return;
|
|
226
|
+
try {
|
|
227
|
+
const r = await fetch(src, { method, headers, body, signal: abort.signal });
|
|
228
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
|
|
229
|
+
const text = await r.text();
|
|
230
|
+
if (stopped) return;
|
|
231
|
+
const ct = r.headers.get('content-type') || '';
|
|
232
|
+
const format = explicit || detectFormat(src, ct);
|
|
233
|
+
let data;
|
|
234
|
+
try { data = parseBody(text, format); }
|
|
235
|
+
catch (e) { stream.lastError = `Parse error (${format}): ${e.message}`; return; }
|
|
236
|
+
stream.signal.value = data;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e.name === 'AbortError') return;
|
|
239
|
+
stream.lastError = e.message;
|
|
240
|
+
stream.errorTick = (stream.errorTick || 0) + 1;
|
|
241
|
+
/* Bump error counter signal so subscribers see it too. */
|
|
242
|
+
if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
tick();
|
|
247
|
+
if (interval > 0) timer = setInterval(tick, interval);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
stop() {
|
|
251
|
+
stopped = true;
|
|
252
|
+
abort.abort();
|
|
253
|
+
if (timer) clearInterval(timer);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function startSSE(stream) {
|
|
259
|
+
const { src, event: eventName, format: explicit } = stream.opts;
|
|
260
|
+
if (typeof EventSource === 'undefined') {
|
|
261
|
+
console.warn('[data-stream] EventSource not available; SSE mode is a no-op here.');
|
|
262
|
+
return { stop() {} };
|
|
263
|
+
}
|
|
264
|
+
const es = new EventSource(src);
|
|
265
|
+
es.addEventListener(eventName, (e) => {
|
|
266
|
+
try {
|
|
267
|
+
const format = explicit || 'json';
|
|
268
|
+
stream.signal.value = parseBody(e.data, format);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
stream.lastError = `SSE parse error: ${err.message}`;
|
|
271
|
+
stream.errorTick = (stream.errorTick || 0) + 1;
|
|
272
|
+
if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
es.addEventListener('error', () => {
|
|
276
|
+
stream.lastError = 'SSE connection error';
|
|
277
|
+
stream.errorTick = (stream.errorTick || 0) + 1;
|
|
278
|
+
if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
|
|
279
|
+
});
|
|
280
|
+
return { stop() { es.close(); } };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function startWS(stream) {
|
|
284
|
+
const { src, format: explicit } = stream.opts;
|
|
285
|
+
if (typeof WebSocket === 'undefined') {
|
|
286
|
+
console.warn('[data-stream] WebSocket not available; ws mode is a no-op here.');
|
|
287
|
+
return { stop() {} };
|
|
288
|
+
}
|
|
289
|
+
const ws = new WebSocket(src);
|
|
290
|
+
ws.addEventListener('message', (e) => {
|
|
291
|
+
try {
|
|
292
|
+
const format = explicit || 'json';
|
|
293
|
+
stream.signal.value = parseBody(typeof e.data === 'string' ? e.data : String(e.data), format);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
stream.lastError = `WebSocket parse error: ${err.message}`;
|
|
296
|
+
stream.errorTick = (stream.errorTick || 0) + 1;
|
|
297
|
+
if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
ws.addEventListener('error', () => {
|
|
301
|
+
stream.lastError = 'WebSocket error';
|
|
302
|
+
stream.errorTick = (stream.errorTick || 0) + 1;
|
|
303
|
+
if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
|
|
304
|
+
});
|
|
305
|
+
return { stop() { ws.close(); } };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ── Stream registry ───────────────────────────────────────────────── */
|
|
309
|
+
|
|
310
|
+
function acquireStream(id, opts) {
|
|
311
|
+
let stream = STREAMS.get(id);
|
|
312
|
+
if (stream) {
|
|
313
|
+
stream.refs++;
|
|
314
|
+
return stream;
|
|
315
|
+
}
|
|
316
|
+
stream = {
|
|
317
|
+
id,
|
|
318
|
+
opts,
|
|
319
|
+
signal: signal(null),
|
|
320
|
+
errorSignal: signal(0), /* monotonic — bumps on each transport-level error */
|
|
321
|
+
refs: 1,
|
|
322
|
+
transport: null,
|
|
323
|
+
lastError: null,
|
|
324
|
+
errorTick: 0,
|
|
325
|
+
};
|
|
326
|
+
STREAMS.set(id, stream);
|
|
327
|
+
stream.transport = startTransport(stream);
|
|
328
|
+
if (PENDING.has(id)) {
|
|
329
|
+
const resolvers = PENDING.get(id);
|
|
330
|
+
PENDING.delete(id);
|
|
331
|
+
for (const r of resolvers) r(stream);
|
|
332
|
+
}
|
|
333
|
+
return stream;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function releaseStream(stream) {
|
|
337
|
+
stream.refs--;
|
|
338
|
+
if (stream.refs <= 0) {
|
|
339
|
+
stream.transport?.stop();
|
|
340
|
+
STREAMS.delete(stream.id);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* ── Apply incoming data to the element ────────────────────────────── */
|
|
345
|
+
|
|
346
|
+
function resolvePath(obj, path) {
|
|
347
|
+
if (!path) return obj;
|
|
348
|
+
let cursor = obj;
|
|
349
|
+
for (const seg of path.split('.')) {
|
|
350
|
+
if (cursor == null) return null;
|
|
351
|
+
cursor = cursor[seg];
|
|
352
|
+
}
|
|
353
|
+
return cursor;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function applyData(el, raw, opts) {
|
|
357
|
+
let data = resolvePath(raw, opts.path);
|
|
358
|
+
if (data == null) return;
|
|
359
|
+
|
|
360
|
+
const target = opts.target || 'data';
|
|
361
|
+
const merge = opts.merge || 'replace';
|
|
362
|
+
|
|
363
|
+
if (target === '*') {
|
|
364
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
365
|
+
Object.assign(el, data);
|
|
366
|
+
dispatch(el, 'stream-update', { data });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
el.data = data;
|
|
370
|
+
dispatch(el, 'stream-update', { data });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if ((merge === 'append' || merge === 'prepend') && Array.isArray(data) && Array.isArray(el[target])) {
|
|
375
|
+
el[target] = merge === 'append'
|
|
376
|
+
? [...el[target], ...data]
|
|
377
|
+
: [...data, ...el[target]];
|
|
378
|
+
} else {
|
|
379
|
+
el[target] = data;
|
|
380
|
+
}
|
|
381
|
+
dispatch(el, 'stream-update', { data });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* ── Per-element lifecycle ─────────────────────────────────────────── */
|
|
385
|
+
|
|
386
|
+
export function start(el) {
|
|
387
|
+
stop(el);
|
|
388
|
+
if (!el.isConnected) return;
|
|
389
|
+
const src = attr(el, 'src');
|
|
390
|
+
if (!src) return;
|
|
391
|
+
|
|
392
|
+
const id = streamIdFor(el);
|
|
393
|
+
const opts = streamConfigFor(el);
|
|
394
|
+
const stream = acquireStream(id, opts);
|
|
395
|
+
|
|
396
|
+
/* Per-element apply options — read fresh each effect run so live attr
|
|
397
|
+
edits to path/target/merge take effect without restart. */
|
|
398
|
+
const elState = { streamId: id, loaded: false, dispose: null };
|
|
399
|
+
ELEMENT_STREAMS.set(el, elState);
|
|
400
|
+
|
|
401
|
+
/* Subscribe to data + error signals via two effects so each can dispose
|
|
402
|
+
independently. The data effect short-circuits on initial null. */
|
|
403
|
+
const dataDispose = effect(() => {
|
|
404
|
+
const value = stream.signal.value;
|
|
405
|
+
if (value == null) return;
|
|
406
|
+
untracked(() => {
|
|
407
|
+
if (!elState.loaded) {
|
|
408
|
+
elState.loaded = true;
|
|
409
|
+
dispatch(el, 'stream-load', {});
|
|
410
|
+
}
|
|
411
|
+
const applyOpts = {
|
|
412
|
+
path: attr(el, 'path'),
|
|
413
|
+
target: attr(el, 'target') || 'data',
|
|
414
|
+
merge: attr(el, 'merge') || 'replace',
|
|
415
|
+
};
|
|
416
|
+
applyData(el, value, applyOpts);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const errorDispose = effect(() => {
|
|
421
|
+
const tick = stream.errorSignal.value;
|
|
422
|
+
if (tick === 0) return;
|
|
423
|
+
untracked(() => {
|
|
424
|
+
if (stream.lastError) dispatch(el, 'stream-error', { error: stream.lastError });
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
elState.dispose = () => { dataDispose(); errorDispose(); };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function stop(el) {
|
|
432
|
+
const entry = ELEMENT_STREAMS.get(el);
|
|
433
|
+
if (!entry) return;
|
|
434
|
+
entry.dispose?.();
|
|
435
|
+
const stream = STREAMS.get(entry.streamId);
|
|
436
|
+
if (stream) releaseStream(stream);
|
|
437
|
+
ELEMENT_STREAMS.delete(el);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/* ── Document-level observer ───────────────────────────────────────── */
|
|
441
|
+
|
|
442
|
+
const ATTR_FILTER = Object.values(ATTRS);
|
|
443
|
+
|
|
444
|
+
function isStreamingEl(node) {
|
|
445
|
+
return node && node.nodeType === 1 && node.hasAttribute && node.hasAttribute(ATTRS.src);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function visitSubtree(root, fn) {
|
|
449
|
+
if (root.nodeType !== 1) return;
|
|
450
|
+
if (isStreamingEl(root)) fn(root);
|
|
451
|
+
if (root.querySelectorAll) {
|
|
452
|
+
root.querySelectorAll(`[${ATTRS.src}]`).forEach(fn);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const observer = new MutationObserver((mutations) => {
|
|
457
|
+
for (const m of mutations) {
|
|
458
|
+
if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
|
|
459
|
+
const el = m.target;
|
|
460
|
+
if (isStreamingEl(el)) start(el);
|
|
461
|
+
else stop(el);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (m.type === 'childList') {
|
|
465
|
+
m.addedNodes.forEach(n => visitSubtree(n, start));
|
|
466
|
+
m.removedNodes.forEach(n => visitSubtree(n, stop));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
function bootstrap() {
|
|
472
|
+
if (typeof document === 'undefined') return;
|
|
473
|
+
observer.observe(document.documentElement, {
|
|
474
|
+
childList: true, subtree: true,
|
|
475
|
+
attributes: true, attributeFilter: ATTR_FILTER,
|
|
476
|
+
});
|
|
477
|
+
document.querySelectorAll(`[${ATTRS.src}]`).forEach(start);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (typeof document !== 'undefined') {
|
|
481
|
+
if (document.readyState === 'loading') {
|
|
482
|
+
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
|
483
|
+
} else {
|
|
484
|
+
bootstrap();
|
|
485
|
+
}
|
|
486
|
+
}
|
package/core/form.js
CHANGED
|
@@ -188,10 +188,15 @@ export class AdiaFormElement extends AdiaElement {
|
|
|
188
188
|
* slots; the per-control `label` attr renders via an inert shadow
|
|
189
189
|
* slot without `[for]`, so click-to-focus doesn't work.
|
|
190
190
|
*
|
|
191
|
+
* Subclasses that have promoted `label` to a first-class API with
|
|
192
|
+
* proper a11y wiring (e.g. input-ui's inline-leading caption with
|
|
193
|
+
* aria-labelledby) opt out by setting `static labelDeprecated = false`.
|
|
194
|
+
*
|
|
191
195
|
* One-shot per class so the console isn't spammed on docs pages.
|
|
192
196
|
*/
|
|
193
197
|
#warnDeprecatedLabel() {
|
|
194
198
|
const ctor = this.constructor;
|
|
199
|
+
if (ctor.labelDeprecated === false) return;
|
|
195
200
|
if (!ctor.properties?.label) return;
|
|
196
201
|
if (!this.hasAttribute('label')) return;
|
|
197
202
|
if (AdiaFormElement.#labelWarned.has(ctor)) return;
|
package/core/index.js
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* streams-bridge — proxy data-stream signals into A2UI surface data
|
|
3
|
+
* models (OD-CHART-16, option b).
|
|
4
|
+
*
|
|
5
|
+
* effect()
|
|
6
|
+
* data-stream signal ──────────────────────▶ renderer.process({
|
|
7
|
+
* (any consumer: type: 'updateDataModel',
|
|
8
|
+
* chart-ui, table-ui, surfaceId, path, value
|
|
9
|
+
* headless app code) })
|
|
10
|
+
*
|
|
11
|
+
* Lives in `web-components/core/` rather than `a2ui-utils/` because
|
|
12
|
+
* the dependency direction is web-components → a2ui-utils, not the
|
|
13
|
+
* reverse. The bridge duck-types the renderer (it needs `.process()`,
|
|
14
|
+
* nothing else), so it works with any A2UI-protocol consumer — the
|
|
15
|
+
* actual `A2UIRenderer` from `@adia-ai/a2ui-utils`, a custom renderer,
|
|
16
|
+
* or a test stub.
|
|
17
|
+
*
|
|
18
|
+
* Contract:
|
|
19
|
+
* - One-way only: stream → data model. A2UI writes back to its model
|
|
20
|
+
* (e.g. via `update-data-model` wiring actions) do NOT propagate
|
|
21
|
+
* into the stream signal. If you need bidirectional state, the
|
|
22
|
+
* stream is the wrong substrate — use a wiring action.
|
|
23
|
+
* - Tear down with the returned dispose function. Dispose detaches
|
|
24
|
+
* the effect; it does NOT release the stream's refcount, because
|
|
25
|
+
* the bridge isn't the source of truth for the stream's lifecycle
|
|
26
|
+
* (the DOM consumer or a separate `acquireStream` caller is).
|
|
27
|
+
* - `select` uses dot-separated path syntax (matches
|
|
28
|
+
* `data-stream-path` on the trait). `path` uses slash-separated
|
|
29
|
+
* syntax (matches A2UI bindings).
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* const dispose = bridgeStream(renderer, {
|
|
33
|
+
* surfaceId: 'main',
|
|
34
|
+
* streamId: 'id:rev', // explicit data-stream-id
|
|
35
|
+
* path: 'metrics/revenue', // A2UI data-model path
|
|
36
|
+
* select: 'data', // optional: sub-path within stream
|
|
37
|
+
* });
|
|
38
|
+
* // ... later
|
|
39
|
+
* dispose();
|
|
40
|
+
*
|
|
41
|
+
* // For DOM-first sources where the stream may register after the
|
|
42
|
+
* // bridge call returns:
|
|
43
|
+
* const dispose = await bridgeStreamAsync(renderer, opts);
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { effect } from './signals.js';
|
|
47
|
+
import { streams, whenStream } from './data-stream.js';
|
|
48
|
+
|
|
49
|
+
function resolveSelect(obj, dottedPath) {
|
|
50
|
+
if (!dottedPath) return obj;
|
|
51
|
+
let cursor = obj;
|
|
52
|
+
for (const seg of dottedPath.split('.')) {
|
|
53
|
+
if (cursor == null) return null;
|
|
54
|
+
cursor = cursor[seg];
|
|
55
|
+
}
|
|
56
|
+
return cursor;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attach(renderer, stream, { surfaceId, path, select }) {
|
|
60
|
+
return effect(() => {
|
|
61
|
+
const value = stream.signal.value;
|
|
62
|
+
if (value == null) return;
|
|
63
|
+
const out = select ? resolveSelect(value, select) : value;
|
|
64
|
+
renderer.process({ type: 'updateDataModel', surfaceId, path, value: out });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bridge a registered stream into a renderer's surface data model.
|
|
70
|
+
* If the stream is not yet registered, logs a warning and returns a
|
|
71
|
+
* no-op dispose. Use {@link bridgeStreamAsync} when the stream might
|
|
72
|
+
* register after this call (e.g. DOM source connecting on the same
|
|
73
|
+
* tick as the bridge wiring).
|
|
74
|
+
*/
|
|
75
|
+
export function bridgeStream(renderer, opts) {
|
|
76
|
+
const { streamId } = opts;
|
|
77
|
+
const stream = streams.get(streamId);
|
|
78
|
+
if (!stream) {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[streams-bridge] stream "${streamId}" not registered — ` +
|
|
81
|
+
`call bridgeStreamAsync() to wait, or ensure the DOM source is ` +
|
|
82
|
+
`connected before bridging.`,
|
|
83
|
+
);
|
|
84
|
+
return () => {};
|
|
85
|
+
}
|
|
86
|
+
return attach(renderer, stream, opts);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Async variant — awaits {@link whenStream} before attaching the
|
|
91
|
+
* effect. Resolves once the bridge is wired (returns the dispose fn).
|
|
92
|
+
*/
|
|
93
|
+
export async function bridgeStreamAsync(renderer, opts) {
|
|
94
|
+
const stream = await whenStream(opts.streamId);
|
|
95
|
+
return attach(renderer, stream, opts);
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
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": {
|
|
@@ -78,10 +78,14 @@
|
|
|
78
78
|
/* Text on neutral surfaces — low shade steps = dark text for light mode.
|
|
79
79
|
Dark-mode tint spread widened: text→subtle gap 25→35 steps,
|
|
80
80
|
subtle→disabled gap 10→15 steps, for clearer visual hierarchy. */
|
|
81
|
+
/* Subtle + muted lifted one fine-step shade-side to clear WCAG AA
|
|
82
|
+
4.5:1 on canvas-0/1/2 in both schemes. Subtle ↔ muted gap
|
|
83
|
+
compresses from 10 to 5 step-fractions — visual hierarchy
|
|
84
|
+
leans more on font-weight + size and less on shade contrast. */
|
|
81
85
|
--a-canvas-text-strong: light-dark(var(--a-neutral-05-shade), var(--a-neutral-05-tint));
|
|
82
86
|
--a-canvas-text: light-dark(var(--a-neutral-30-shade), var(--a-neutral-30-tint));
|
|
83
|
-
--a-canvas-text-subtle: light-dark(var(--a-neutral-
|
|
84
|
-
--a-canvas-text-muted: light-dark(var(--a-neutral-
|
|
87
|
+
--a-canvas-text-subtle: light-dark(var(--a-neutral-35-shade), var(--a-neutral-35-tint));
|
|
88
|
+
--a-canvas-text-muted: light-dark(var(--a-neutral-40-shade), var(--a-neutral-40-tint));
|
|
85
89
|
--a-canvas-text-disabled: light-dark(var(--a-neutral-60-shade), var(--a-neutral-60-tint));
|
|
86
90
|
--a-canvas-text-inverse: var(--a-neutral-10);
|
|
87
91
|
|
|
@@ -140,6 +144,20 @@
|
|
|
140
144
|
--a-accent-active: var(--a-accent-60);
|
|
141
145
|
--a-accent-selected: var(--a-accent-50);
|
|
142
146
|
|
|
147
|
+
/* Link family — derived from accent steps deeper than --a-accent-strong
|
|
148
|
+
(step-50) so links clear WCAG AA 4.5:1 on canvas-0/1/2 in both
|
|
149
|
+
schemes. Anchor color in `typography.css` reads --a-link rather
|
|
150
|
+
than --a-accent-strong; emphasis on hover comes from a tighter
|
|
151
|
+
chroma + darker step rather than full saturation jump. */
|
|
152
|
+
/* Polarity matches `--a-accent-N` family wrappers: at fine steps >50,
|
|
153
|
+
`tint` is the lower-L (deeper) primitive and `shade` is the higher-L
|
|
154
|
+
primitive — the naming inverts past the convergence point. Light
|
|
155
|
+
mode wants dark-on-light, so we pick `tint` (deep accent); dark
|
|
156
|
+
mode wants light-on-dark, so we pick `shade` (lifted accent). */
|
|
157
|
+
--a-link: light-dark(var(--a-accent-65-tint), var(--a-accent-65-shade));
|
|
158
|
+
--a-link-hover: light-dark(var(--a-accent-70-tint), var(--a-accent-70-shade));
|
|
159
|
+
--a-link-visited: light-dark(var(--a-accent-75-tint), var(--a-accent-75-shade));
|
|
160
|
+
|
|
143
161
|
/* Accent text on accent solid bg — needs light text on dark-ish accent */
|
|
144
162
|
--a-accent-text-strong: light-dark(var(--a-accent-05-shade), var(--a-accent-05-tint));
|
|
145
163
|
--a-accent-text: light-dark(var(--a-accent-10-shade), var(--a-accent-10-tint));
|
|
@@ -466,7 +484,7 @@
|
|
|
466
484
|
══════════════════════════════════════════════════════════════ */
|
|
467
485
|
|
|
468
486
|
--a-ui-bg: var(--a-canvas-0-scrim);
|
|
469
|
-
--a-ui-bg-hover: var(--a-canvas-
|
|
487
|
+
--a-ui-bg-hover: var(--a-canvas-2-scrim);
|
|
470
488
|
--a-ui-bg-active: var(--a-canvas-0);
|
|
471
489
|
--a-ui-bg-selected: var(--a-canvas-bright);
|
|
472
490
|
--a-ui-bg-disabled: var(--a-canvas-1);
|
package/styles/components.css
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
@import "../components/textarea/textarea.css";
|
|
18
18
|
@import "../components/check/check.css";
|
|
19
19
|
@import "../components/radio/radio.css";
|
|
20
|
+
@import "../components/option-card/option-card.css";
|
|
20
21
|
@import "../components/switch/switch.css";
|
|
21
22
|
@import "../components/slider/slider.css";
|
|
22
23
|
@import "../components/select/select.css";
|