@adia-ai/a2ui-runtime 0.3.0
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 +215 -0
- package/README.md +87 -0
- package/controllers/accordion.js +73 -0
- package/controllers/base.js +68 -0
- package/controllers/data-stream.js +281 -0
- package/controllers/form.js +81 -0
- package/controllers/index.js +6 -0
- package/controllers/selection.js +82 -0
- package/controllers/state-machine.js +135 -0
- package/controllers/toggle.js +40 -0
- package/dockables/action.js +152 -0
- package/dockables/base.js +30 -0
- package/dockables/controller.js +97 -0
- package/dockables/data-source.js +103 -0
- package/dockables/index.js +6 -0
- package/dockables/lifecycle.js +84 -0
- package/dockables/provider.js +59 -0
- package/index.js +45 -0
- package/package.json +31 -0
- package/registry.js +205 -0
- package/renderer.js +395 -0
- package/stream.js +243 -0
- package/surface-manifest.js +294 -0
- package/surface.js +222 -0
- package/wire-factory.js +134 -0
- package/wiring-engine.js +209 -0
- package/wiring-registry.js +342 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { BaseController } from './base.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DataStreamController — pushes live data to chart components.
|
|
5
|
+
*
|
|
6
|
+
* Accepts: SSE URL, WebSocket URL, async iterable, polling function, or manual push.
|
|
7
|
+
* Accumulates data points in a buffer with configurable max size.
|
|
8
|
+
* Each new point triggers notify() → chart re-renders.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const ctrl = new DataStreamController({ max: 20 });
|
|
12
|
+
* chart.controller = ctrl;
|
|
13
|
+
*
|
|
14
|
+
* // Manual push
|
|
15
|
+
* ctrl.commands.push({ month: 'Jan', revenue: 4200 });
|
|
16
|
+
*
|
|
17
|
+
* // SSE
|
|
18
|
+
* ctrl.commands.connect('sse', '/api/metrics');
|
|
19
|
+
*
|
|
20
|
+
* // WebSocket
|
|
21
|
+
* ctrl.commands.connect('ws', 'wss://api.example.com/stream');
|
|
22
|
+
*
|
|
23
|
+
* // Polling
|
|
24
|
+
* ctrl.commands.poll('/api/latest', 2000);
|
|
25
|
+
*
|
|
26
|
+
* // Async iterable
|
|
27
|
+
* ctrl.commands.consume(asyncIterable);
|
|
28
|
+
*
|
|
29
|
+
* // Stop
|
|
30
|
+
* ctrl.commands.stop();
|
|
31
|
+
*/
|
|
32
|
+
export class DataStreamController extends BaseController {
|
|
33
|
+
static schema = Object.freeze({
|
|
34
|
+
name: 'data-stream',
|
|
35
|
+
state: { data: 'array', status: 'string', error: 'string' },
|
|
36
|
+
commands: ['push', 'pushMany', 'connect', 'poll', 'consume', 'stop', 'clear'],
|
|
37
|
+
attributes: ['data-stream-status'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
#data = [];
|
|
41
|
+
#max;
|
|
42
|
+
#status = 'idle'; // idle | connecting | streaming | error | stopped
|
|
43
|
+
#error = null;
|
|
44
|
+
#abort = null;
|
|
45
|
+
#throttle = 0; // ms — min interval between notify() calls. 0 = every push.
|
|
46
|
+
#sample = 1; // keep every Nth point. 1 = keep all.
|
|
47
|
+
#pendingNotify = null;
|
|
48
|
+
#pushCount = 0; // total pushes since connect (for sampling)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {Object} opts
|
|
52
|
+
* @param {number} [opts.max=100] — Maximum data points to retain (FIFO)
|
|
53
|
+
* @param {number} [opts.throttle=0] — Min ms between render notifications. 0 = every push.
|
|
54
|
+
* @param {number} [opts.sample=1] — Keep every Nth point. 1 = keep all.
|
|
55
|
+
* @param {Array} [opts.initial] — Initial data
|
|
56
|
+
*/
|
|
57
|
+
constructor({ max = 100, throttle = 0, sample = 1, initial = [] } = {}) {
|
|
58
|
+
super();
|
|
59
|
+
this.#max = max;
|
|
60
|
+
this.#throttle = throttle;
|
|
61
|
+
this.#sample = Math.max(1, Math.round(sample));
|
|
62
|
+
this.#data = initial.slice(-max);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getState() {
|
|
66
|
+
return {
|
|
67
|
+
data: this.#data,
|
|
68
|
+
status: this.#status,
|
|
69
|
+
error: this.#error,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
reflect() {
|
|
74
|
+
if (!this.host) return;
|
|
75
|
+
this.host.setAttribute('data-stream-status', this.#status);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onDisconnect() {
|
|
79
|
+
this.commands.stop();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#append(point) {
|
|
83
|
+
// Sampling: skip points when sample > 1
|
|
84
|
+
this.#pushCount++;
|
|
85
|
+
if (this.#sample > 1 && this.#pushCount % this.#sample !== 0) return;
|
|
86
|
+
|
|
87
|
+
this.#data.push(point);
|
|
88
|
+
if (this.#data.length > this.#max) {
|
|
89
|
+
this.#data = this.#data.slice(-this.#max);
|
|
90
|
+
}
|
|
91
|
+
this.#scheduleNotify();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#appendMany(points) {
|
|
95
|
+
// Sampling: filter points when sample > 1
|
|
96
|
+
if (this.#sample > 1) {
|
|
97
|
+
const sampled = [];
|
|
98
|
+
for (const p of points) {
|
|
99
|
+
this.#pushCount++;
|
|
100
|
+
if (this.#pushCount % this.#sample === 0) sampled.push(p);
|
|
101
|
+
}
|
|
102
|
+
this.#data.push(...sampled);
|
|
103
|
+
} else {
|
|
104
|
+
this.#data.push(...points);
|
|
105
|
+
}
|
|
106
|
+
if (this.#data.length > this.#max) {
|
|
107
|
+
this.#data = this.#data.slice(-this.#max);
|
|
108
|
+
}
|
|
109
|
+
this.#scheduleNotify();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#scheduleNotify() {
|
|
113
|
+
// No throttle: immediate
|
|
114
|
+
if (this.#throttle <= 0) {
|
|
115
|
+
this.notify();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Throttled: batch into one notify per interval
|
|
119
|
+
if (this.#pendingNotify !== null) return;
|
|
120
|
+
this.#pendingNotify = setTimeout(() => {
|
|
121
|
+
this.#pendingNotify = null;
|
|
122
|
+
this.notify();
|
|
123
|
+
}, this.#throttle);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#setStatus(status, error = null) {
|
|
127
|
+
this.#status = status;
|
|
128
|
+
this.#error = error;
|
|
129
|
+
this.notify();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
commands = {
|
|
133
|
+
/** Push a single data point */
|
|
134
|
+
push: (point) => {
|
|
135
|
+
this.#append(point);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** Push multiple data points at once */
|
|
139
|
+
pushMany: (points) => {
|
|
140
|
+
this.#appendMany(points);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** Connect to SSE or WebSocket stream */
|
|
144
|
+
connect: (type, url) => {
|
|
145
|
+
this.commands.stop();
|
|
146
|
+
this.#abort = new AbortController();
|
|
147
|
+
|
|
148
|
+
if (type === 'sse') {
|
|
149
|
+
this.#connectSSE(url);
|
|
150
|
+
} else if (type === 'ws') {
|
|
151
|
+
this.#connectWS(url);
|
|
152
|
+
} else {
|
|
153
|
+
this.#setStatus('error', `Unknown stream type: ${type}`);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** Poll a URL at an interval (ms) */
|
|
158
|
+
poll: (url, interval = 2000) => {
|
|
159
|
+
this.commands.stop();
|
|
160
|
+
this.#abort = new AbortController();
|
|
161
|
+
this.#startPolling(url, interval);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/** Consume an async iterable */
|
|
165
|
+
consume: (iterable) => {
|
|
166
|
+
this.commands.stop();
|
|
167
|
+
this.#abort = new AbortController();
|
|
168
|
+
this.#consumeIterable(iterable);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Stop all active streams */
|
|
172
|
+
stop: () => {
|
|
173
|
+
if (this.#abort) {
|
|
174
|
+
this.#abort.abort();
|
|
175
|
+
this.#abort = null;
|
|
176
|
+
}
|
|
177
|
+
if (this.#pendingNotify !== null) {
|
|
178
|
+
clearTimeout(this.#pendingNotify);
|
|
179
|
+
this.#pendingNotify = null;
|
|
180
|
+
}
|
|
181
|
+
if (this.#status === 'streaming' || this.#status === 'connecting') {
|
|
182
|
+
this.#setStatus('stopped');
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/** Clear all data */
|
|
187
|
+
clear: () => {
|
|
188
|
+
this.#data = [];
|
|
189
|
+
this.notify();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
#connectSSE(url) {
|
|
194
|
+
this.#setStatus('connecting');
|
|
195
|
+
const es = new EventSource(url);
|
|
196
|
+
const signal = this.#abort.signal;
|
|
197
|
+
|
|
198
|
+
signal.addEventListener('abort', () => es.close());
|
|
199
|
+
|
|
200
|
+
es.onopen = () => this.#setStatus('streaming');
|
|
201
|
+
|
|
202
|
+
es.onmessage = (e) => {
|
|
203
|
+
try {
|
|
204
|
+
const point = JSON.parse(e.data);
|
|
205
|
+
this.#append(point);
|
|
206
|
+
} catch { /* skip malformed */ }
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
es.onerror = () => {
|
|
210
|
+
if (!signal.aborted) {
|
|
211
|
+
this.#setStatus('error', 'SSE connection lost');
|
|
212
|
+
es.close();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#connectWS(url) {
|
|
218
|
+
this.#setStatus('connecting');
|
|
219
|
+
const ws = new WebSocket(url);
|
|
220
|
+
const signal = this.#abort.signal;
|
|
221
|
+
|
|
222
|
+
signal.addEventListener('abort', () => ws.close());
|
|
223
|
+
|
|
224
|
+
ws.onopen = () => this.#setStatus('streaming');
|
|
225
|
+
|
|
226
|
+
ws.onmessage = (e) => {
|
|
227
|
+
try {
|
|
228
|
+
const msg = JSON.parse(e.data);
|
|
229
|
+
if (Array.isArray(msg)) this.#appendMany(msg);
|
|
230
|
+
else this.#append(msg);
|
|
231
|
+
} catch { /* skip malformed */ }
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
ws.onerror = () => {
|
|
235
|
+
if (!signal.aborted) {
|
|
236
|
+
this.#setStatus('error', 'WebSocket error');
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
ws.onclose = () => {
|
|
241
|
+
if (!signal.aborted) {
|
|
242
|
+
this.#setStatus('stopped');
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async #startPolling(url, interval) {
|
|
248
|
+
this.#setStatus('streaming');
|
|
249
|
+
const signal = this.#abort.signal;
|
|
250
|
+
|
|
251
|
+
while (!signal.aborted) {
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(url, { signal });
|
|
254
|
+
const json = await res.json();
|
|
255
|
+
if (Array.isArray(json)) this.#appendMany(json);
|
|
256
|
+
else this.#append(json);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
if (signal.aborted) break;
|
|
259
|
+
this.#setStatus('error', e.message);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
await new Promise(r => setTimeout(r, interval));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async #consumeIterable(iterable) {
|
|
267
|
+
this.#setStatus('streaming');
|
|
268
|
+
const signal = this.#abort.signal;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
for await (const point of iterable) {
|
|
272
|
+
if (signal.aborted) break;
|
|
273
|
+
if (Array.isArray(point)) this.#appendMany(point);
|
|
274
|
+
else this.#append(point);
|
|
275
|
+
}
|
|
276
|
+
if (!signal.aborted) this.#setStatus('idle');
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (!signal.aborted) this.#setStatus('error', e.message);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { BaseController } from './base.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Form controller — tracks field values, validation, and dirty state.
|
|
5
|
+
* Sets [data-form-valid], [data-form-invalid], [data-form-dirty] on host.
|
|
6
|
+
*/
|
|
7
|
+
export class FormController extends BaseController {
|
|
8
|
+
static schema = Object.freeze({
|
|
9
|
+
name: 'form',
|
|
10
|
+
state: { values: 'object', errors: 'object', dirty: 'boolean', valid: 'boolean' },
|
|
11
|
+
commands: ['set', 'validate', 'reset', 'setInitial'],
|
|
12
|
+
attributes: ['data-form-valid', 'data-form-invalid', 'data-form-dirty'],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
#initial = {};
|
|
16
|
+
#values = {};
|
|
17
|
+
#errors = {};
|
|
18
|
+
#validators = {};
|
|
19
|
+
|
|
20
|
+
constructor(initial = {}) {
|
|
21
|
+
super();
|
|
22
|
+
this.#initial = { ...initial };
|
|
23
|
+
this.#values = { ...initial };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onDisconnect(host) {
|
|
27
|
+
host.removeAttribute('data-form-valid');
|
|
28
|
+
host.removeAttribute('data-form-invalid');
|
|
29
|
+
host.removeAttribute('data-form-dirty');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getState() {
|
|
33
|
+
const dirty = Object.keys(this.#values).some(k => this.#values[k] !== this.#initial[k]);
|
|
34
|
+
const valid = Object.keys(this.#errors).length === 0;
|
|
35
|
+
return { values: { ...this.#values }, errors: { ...this.#errors }, dirty, valid };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
reflect() {
|
|
39
|
+
if (!this.host) return;
|
|
40
|
+
const { dirty, valid } = this.getState();
|
|
41
|
+
if (valid) {
|
|
42
|
+
this.host.setAttribute('data-form-valid', '');
|
|
43
|
+
this.host.removeAttribute('data-form-invalid');
|
|
44
|
+
} else {
|
|
45
|
+
this.host.setAttribute('data-form-invalid', '');
|
|
46
|
+
this.host.removeAttribute('data-form-valid');
|
|
47
|
+
}
|
|
48
|
+
if (dirty) this.host.setAttribute('data-form-dirty', '');
|
|
49
|
+
else this.host.removeAttribute('data-form-dirty');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#runValidation(field) {
|
|
53
|
+
const fn = this.#validators[field];
|
|
54
|
+
if (!fn) { delete this.#errors[field]; return; }
|
|
55
|
+
const result = fn(this.#values[field]);
|
|
56
|
+
if (result === true || result == null) delete this.#errors[field];
|
|
57
|
+
else this.#errors[field] = typeof result === 'string' ? result : 'Invalid';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
commands = {
|
|
61
|
+
set: (field, value) => {
|
|
62
|
+
this.#values[field] = value;
|
|
63
|
+
this.#runValidation(field);
|
|
64
|
+
this.notify();
|
|
65
|
+
},
|
|
66
|
+
validate: (field, fn) => {
|
|
67
|
+
this.#validators[field] = fn;
|
|
68
|
+
this.#runValidation(field);
|
|
69
|
+
this.notify();
|
|
70
|
+
},
|
|
71
|
+
reset: () => {
|
|
72
|
+
this.#values = { ...this.#initial };
|
|
73
|
+
this.#errors = {};
|
|
74
|
+
for (const field of Object.keys(this.#validators)) this.#runValidation(field);
|
|
75
|
+
this.notify();
|
|
76
|
+
},
|
|
77
|
+
setInitial: (values) => {
|
|
78
|
+
this.#initial = { ...values };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { BaseController } from './base.js';
|
|
2
|
+
export { ToggleController } from './toggle.js';
|
|
3
|
+
export { SelectionController } from './selection.js';
|
|
4
|
+
export { FormController } from './form.js';
|
|
5
|
+
export { AccordionController } from './accordion.js';
|
|
6
|
+
export { DataStreamController } from './data-stream.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { BaseController } from './base.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Selection controller — manages single or multi-select state.
|
|
5
|
+
* Sets [data-selection-active] on host, [data-selection-selected] on items.
|
|
6
|
+
*/
|
|
7
|
+
export class SelectionController extends BaseController {
|
|
8
|
+
static schema = Object.freeze({
|
|
9
|
+
name: 'selection',
|
|
10
|
+
state: { selected: 'Set', multiple: 'boolean' },
|
|
11
|
+
commands: ['select', 'deselect', 'toggle', 'clear', 'selectAll'],
|
|
12
|
+
attributes: ['data-selection-active', 'data-selection-selected'],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
#selected = new Set();
|
|
16
|
+
#multiple = false;
|
|
17
|
+
|
|
18
|
+
constructor({ multiple = false, initial = [] } = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.#multiple = multiple;
|
|
21
|
+
for (const key of initial) this.#selected.add(String(key));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onConnect(host) {
|
|
25
|
+
host.setAttribute('data-selection-active', '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onDisconnect(host) {
|
|
29
|
+
this.#clearReflection(host);
|
|
30
|
+
host.removeAttribute('data-selection-active');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getState() {
|
|
34
|
+
return { selected: new Set(this.#selected), multiple: this.#multiple };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
reflect() {
|
|
38
|
+
if (!this.host) return;
|
|
39
|
+
const items = this.host.querySelectorAll('[data-selection-key]');
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const key = item.getAttribute('data-selection-key');
|
|
42
|
+
if (this.#selected.has(key)) item.setAttribute('data-selection-selected', '');
|
|
43
|
+
else item.removeAttribute('data-selection-selected');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#clearReflection(host) {
|
|
48
|
+
const items = host.querySelectorAll('[data-selection-selected]');
|
|
49
|
+
for (const item of items) item.removeAttribute('data-selection-selected');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
commands = {
|
|
53
|
+
select: (key) => {
|
|
54
|
+
key = String(key);
|
|
55
|
+
if (!this.#multiple) this.#selected.clear();
|
|
56
|
+
this.#selected.add(key);
|
|
57
|
+
this.notify();
|
|
58
|
+
},
|
|
59
|
+
deselect: (key) => {
|
|
60
|
+
this.#selected.delete(String(key));
|
|
61
|
+
this.notify();
|
|
62
|
+
},
|
|
63
|
+
toggle: (key) => {
|
|
64
|
+
key = String(key);
|
|
65
|
+
if (this.#selected.has(key)) this.#selected.delete(key);
|
|
66
|
+
else {
|
|
67
|
+
if (!this.#multiple) this.#selected.clear();
|
|
68
|
+
this.#selected.add(key);
|
|
69
|
+
}
|
|
70
|
+
this.notify();
|
|
71
|
+
},
|
|
72
|
+
clear: () => {
|
|
73
|
+
this.#selected.clear();
|
|
74
|
+
this.notify();
|
|
75
|
+
},
|
|
76
|
+
selectAll: (keys) => {
|
|
77
|
+
if (!this.#multiple) return;
|
|
78
|
+
for (const k of keys) this.#selected.add(String(k));
|
|
79
|
+
this.notify();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateMachineController — a minimal XState-lite state chart for A2UI wiring.
|
|
3
|
+
*
|
|
4
|
+
* The LLM declares states + transitions in wireComponents. The runtime
|
|
5
|
+
* tracks current state, exposes it to CSS via `[data-state="…"]` on the
|
|
6
|
+
* host, and fires `stateEntered` / `stateExited` events. Transitions are
|
|
7
|
+
* triggered via commands (`send` named events or `transition` to a state).
|
|
8
|
+
*
|
|
9
|
+
* Goals (vs. the rest of the wiring system):
|
|
10
|
+
* - Expands the LLM's output surface beyond pure structure: it can emit a
|
|
11
|
+
* multi-step wizard, a validation flow, a loading/ready/error trio, etc.,
|
|
12
|
+
* without writing behavior code.
|
|
13
|
+
* - Declarative only — the catalog schema bounds what a state machine can
|
|
14
|
+
* express. No arbitrary JS. Side effects are wiring actions (like every
|
|
15
|
+
* other AdiaUI interactive surface).
|
|
16
|
+
*
|
|
17
|
+
* What's supported in this first pass (subset of statechart semantics):
|
|
18
|
+
* - Finite states with named transitions (`on: { NEXT: "review" }`)
|
|
19
|
+
* - Initial state (`initial: "welcome"`)
|
|
20
|
+
* - `send(event)` to fire a transition
|
|
21
|
+
* - `transition(state)` to jump to a state unconditionally
|
|
22
|
+
* - CSS reflection via `data-state` on the host
|
|
23
|
+
* - Entry/exit custom events
|
|
24
|
+
*
|
|
25
|
+
* Not yet (deliberately): guards, actions on transitions, hierarchical
|
|
26
|
+
* states, parallel states, history. Add when real exemplars demand them.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { BaseController } from './base.js';
|
|
30
|
+
|
|
31
|
+
export class StateMachineController extends BaseController {
|
|
32
|
+
static schema = {
|
|
33
|
+
name: 'state-machine',
|
|
34
|
+
state: {
|
|
35
|
+
current: 'string',
|
|
36
|
+
history: 'string[]',
|
|
37
|
+
},
|
|
38
|
+
commands: ['send', 'transition', 'reset'],
|
|
39
|
+
attributes: ['data-state'],
|
|
40
|
+
config: {
|
|
41
|
+
initial: 'string',
|
|
42
|
+
states: 'object', // { [name]: { on: { [event]: targetState } } }
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
#current = '';
|
|
47
|
+
#initial = '';
|
|
48
|
+
#states = {};
|
|
49
|
+
#history = [];
|
|
50
|
+
|
|
51
|
+
onConnect(host) {
|
|
52
|
+
// Config read from the controller declaration. Falls back to any
|
|
53
|
+
// data-initial/data-states on the host for hand-authored surfaces.
|
|
54
|
+
const config = this.config || {};
|
|
55
|
+
this.#initial = config.initial
|
|
56
|
+
|| host.dataset?.initial
|
|
57
|
+
|| Object.keys(config.states || {})[0]
|
|
58
|
+
|| '';
|
|
59
|
+
this.#states = this._validateStates(config.states || {});
|
|
60
|
+
this.#current = this.#initial;
|
|
61
|
+
this.#history = this.#current ? [this.#current] : [];
|
|
62
|
+
if (this.#current) this._emit('stateEntered', { state: this.#current, initial: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Public state. */
|
|
66
|
+
getState() {
|
|
67
|
+
return { current: this.#current, history: [...this.#history] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reflect() {
|
|
71
|
+
if (!this.host) return;
|
|
72
|
+
if (this.#current) this.host.setAttribute('data-state', this.#current);
|
|
73
|
+
else this.host.removeAttribute('data-state');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
commands = {
|
|
77
|
+
/** Fire a named event; if current state defines a transition for it, move. */
|
|
78
|
+
send: (eventName) => {
|
|
79
|
+
if (!eventName || !this.#current) return;
|
|
80
|
+
const transitions = this.#states[this.#current]?.on || {};
|
|
81
|
+
const target = transitions[eventName];
|
|
82
|
+
if (!target) return; // unknown event in this state — silent no-op
|
|
83
|
+
this._moveTo(target, { event: eventName });
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** Unconditional jump to a declared state. */
|
|
87
|
+
transition: (target) => {
|
|
88
|
+
if (!target || !(target in this.#states)) return;
|
|
89
|
+
this._moveTo(target, { forced: true });
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** Return to initial state. Fires exit + enter events. */
|
|
93
|
+
reset: () => {
|
|
94
|
+
if (!this.#initial) return;
|
|
95
|
+
this._moveTo(this.#initial, { reset: true });
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ── internals ──
|
|
100
|
+
|
|
101
|
+
_moveTo(target, detail) {
|
|
102
|
+
if (target === this.#current) return;
|
|
103
|
+
const prev = this.#current;
|
|
104
|
+
if (prev) this._emit('stateExited', { state: prev, target, ...detail });
|
|
105
|
+
this.#current = target;
|
|
106
|
+
this.#history.push(target);
|
|
107
|
+
this.notify(); // triggers reflect() + subscribers
|
|
108
|
+
this._emit('stateEntered', { state: target, from: prev, ...detail });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_emit(name, detail) {
|
|
112
|
+
if (!this.host) return;
|
|
113
|
+
this.host.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_validateStates(states) {
|
|
117
|
+
// Shallow schema check: each state entry is an object; `on` is an object
|
|
118
|
+
// mapping event names → target state strings that exist in the map.
|
|
119
|
+
const out = {};
|
|
120
|
+
const names = Object.keys(states);
|
|
121
|
+
for (const name of names) {
|
|
122
|
+
const def = states[name] || {};
|
|
123
|
+
const on = {};
|
|
124
|
+
for (const [ev, target] of Object.entries(def.on || {})) {
|
|
125
|
+
if (typeof target !== 'string' || !(target in states)) {
|
|
126
|
+
console.warn(`[state-machine] state "${name}" transition "${ev}" → "${target}" — target not declared`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
on[ev] = target;
|
|
130
|
+
}
|
|
131
|
+
out[name] = { on };
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BaseController } from './base.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toggle controller — manages on/off state.
|
|
5
|
+
* Sets [data-toggle-on] on host when active.
|
|
6
|
+
*/
|
|
7
|
+
export class ToggleController extends BaseController {
|
|
8
|
+
static schema = Object.freeze({
|
|
9
|
+
name: 'toggle',
|
|
10
|
+
state: { on: 'boolean' },
|
|
11
|
+
commands: ['toggle', 'set'],
|
|
12
|
+
attributes: ['data-toggle-on'],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
#on = false;
|
|
16
|
+
|
|
17
|
+
constructor(initial = false) {
|
|
18
|
+
super();
|
|
19
|
+
this.#on = initial;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getState() {
|
|
23
|
+
return { on: this.#on };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onDisconnect(host) {
|
|
27
|
+
host.removeAttribute('data-toggle-on');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
reflect() {
|
|
31
|
+
if (!this.host) return;
|
|
32
|
+
if (this.#on) this.host.setAttribute('data-toggle-on', '');
|
|
33
|
+
else this.host.removeAttribute('data-toggle-on');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
commands = {
|
|
37
|
+
toggle: () => { this.#on = !this.#on; this.notify(); },
|
|
38
|
+
set: (v) => { this.#on = !!v; this.notify(); },
|
|
39
|
+
};
|
|
40
|
+
}
|