@veil-runtime/ui 0.1.0 → 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 +41 -0
- package/README.md +9 -5
- package/dist/build-manifest.json +25 -12
- package/dist/composition.d.ts +43 -0
- package/dist/composition.js +227 -0
- package/dist/dom.d.ts +31 -0
- package/dist/dom.js +554 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/input-schema.d.ts +4 -2
- package/dist/interface-transaction.d.ts +9 -0
- package/dist/interface-transaction.js +14 -0
- package/dist/interface.d.ts +196 -0
- package/dist/interface.js +375 -0
- package/dist/presentation-composition.d.ts +1 -1
- package/dist/presentation-composition.js +3 -1
- package/docs/API.md +4 -2
- package/docs/COMPOSITION.md +67 -0
- package/docs/RELEASE-0.3.0-rc.1.md +25 -0
- package/docs/RELEASE-0.3.0-rc.2.md +9 -0
- package/docs/RELEASE-0.3.0.md +7 -0
- package/docs/UNIVERSAL-INTERFACE.md +105 -0
- package/examples/composed/app.mjs +44 -0
- package/examples/composed/build.mjs +7 -0
- package/examples/composed/index.html +34 -0
- package/examples/universal/agent.mjs +52 -0
- package/examples/universal/application.mjs +16 -0
- package/examples/universal/build.mjs +6 -0
- package/examples/universal/human.mjs +16 -0
- package/examples/universal/index.html +4 -0
- package/examples/universal/main.mjs +7 -0
- package/package.json +24 -3
package/dist/dom.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { createInterfaceController, resolveInterfaceBinding } from "./interface.js";
|
|
2
|
+
import { registerInterfaceRenderer } from "./interface-transaction.js";
|
|
3
|
+
let instance = 0;
|
|
4
|
+
const mounted = new WeakMap();
|
|
5
|
+
/** Mount one composed screen. Caller owns the controller, mount point, and disposal. */
|
|
6
|
+
export function mountComposedApplication(target, controller, components = {}) {
|
|
7
|
+
const definition = controller.definition;
|
|
8
|
+
const id = `veil-composed-${++instance}`;
|
|
9
|
+
const updates = [];
|
|
10
|
+
const cleanup = [];
|
|
11
|
+
let active = true;
|
|
12
|
+
const element = (tag, className) => {
|
|
13
|
+
const result = document.createElement(tag);
|
|
14
|
+
result.className = className;
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
function render(node) {
|
|
18
|
+
const children = () => (node.children ?? []).map(render);
|
|
19
|
+
if (node.component === "Stack") {
|
|
20
|
+
const stack = element("div", "veil-stack");
|
|
21
|
+
stack.style.display = "grid";
|
|
22
|
+
stack.style.gap = "1rem";
|
|
23
|
+
stack.append(...children());
|
|
24
|
+
return stack;
|
|
25
|
+
}
|
|
26
|
+
if (node.component === "Form") {
|
|
27
|
+
const form = document.createElement("form");
|
|
28
|
+
form.noValidate = true;
|
|
29
|
+
form.style.display = "grid";
|
|
30
|
+
form.style.gap = "1rem";
|
|
31
|
+
const onSubmit = (event) => {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
if (!active)
|
|
34
|
+
return;
|
|
35
|
+
const submitter = event.submitter;
|
|
36
|
+
const action = submitter?.form === form ? submitter.dataset["veilAction"] : form.querySelector('button[type="submit"][data-veil-action]')?.dataset["veilAction"];
|
|
37
|
+
if (action)
|
|
38
|
+
void controller.submit(action).then((ok) => {
|
|
39
|
+
if (!ok && active)
|
|
40
|
+
form.querySelector('[aria-invalid="true"]')?.focus();
|
|
41
|
+
}).catch(() => { });
|
|
42
|
+
};
|
|
43
|
+
form.addEventListener("submit", onSubmit);
|
|
44
|
+
cleanup.push(() => form.removeEventListener("submit", onSubmit));
|
|
45
|
+
form.append(...children());
|
|
46
|
+
return form;
|
|
47
|
+
}
|
|
48
|
+
if (node.component === "Text") {
|
|
49
|
+
const text = element("p", "veil-text");
|
|
50
|
+
text.textContent = node.text ?? "";
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
if (node.component === "Status") {
|
|
54
|
+
const status = element("p", "veil-status");
|
|
55
|
+
status.setAttribute("role", "status");
|
|
56
|
+
status.setAttribute("aria-live", "polite");
|
|
57
|
+
updates.push((state) => { status.textContent = state.message; status.dataset["status"] = state.status; });
|
|
58
|
+
return status;
|
|
59
|
+
}
|
|
60
|
+
if (node.component === "Field") {
|
|
61
|
+
const name = node.field;
|
|
62
|
+
const field = controller.field(name);
|
|
63
|
+
const wrapper = element("div", "veil-field");
|
|
64
|
+
wrapper.style.display = "grid";
|
|
65
|
+
wrapper.style.gap = "0.35rem";
|
|
66
|
+
const label = document.createElement("label");
|
|
67
|
+
const inputId = `${id}-control-${name}`;
|
|
68
|
+
const errorId = `${id}-error-${name}`;
|
|
69
|
+
label.htmlFor = inputId;
|
|
70
|
+
label.textContent = node.text ?? field.label;
|
|
71
|
+
if (field.required)
|
|
72
|
+
label.textContent += " *";
|
|
73
|
+
const input = field.type === "select" ? document.createElement("select") : field.type === "textarea" ? document.createElement("textarea") : document.createElement("input");
|
|
74
|
+
input.id = inputId;
|
|
75
|
+
input.name = name;
|
|
76
|
+
input.style.boxSizing = "border-box";
|
|
77
|
+
input.style.width = "100%";
|
|
78
|
+
input.style.minHeight = "2.75rem";
|
|
79
|
+
input.style.fontSize = "1rem";
|
|
80
|
+
input.setAttribute("aria-describedby", errorId);
|
|
81
|
+
if (field.required)
|
|
82
|
+
input.setAttribute("aria-required", "true");
|
|
83
|
+
if (input instanceof HTMLInputElement) {
|
|
84
|
+
input.type = field.type === "checkbox" ? "checkbox" : field.type === "number" || field.type === "currency" ? "number" : field.type === "date" ? "date" : field.type === "email" ? "email" : "text";
|
|
85
|
+
if (field.type === "number" || field.type === "currency")
|
|
86
|
+
input.step = field.authoritativeType === "integer" ? "1" : "any";
|
|
87
|
+
if (field.minimum !== undefined)
|
|
88
|
+
input.min = String(field.minimum);
|
|
89
|
+
if (field.maximum !== undefined)
|
|
90
|
+
input.max = String(field.maximum);
|
|
91
|
+
}
|
|
92
|
+
if (input instanceof HTMLSelectElement) {
|
|
93
|
+
const empty = document.createElement("option");
|
|
94
|
+
empty.value = "";
|
|
95
|
+
empty.textContent = "Select…";
|
|
96
|
+
input.append(empty);
|
|
97
|
+
for (const option of field.options ?? []) {
|
|
98
|
+
const choice = document.createElement("option");
|
|
99
|
+
choice.value = String(option);
|
|
100
|
+
choice.textContent = String(option);
|
|
101
|
+
input.append(choice);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const current = controller.snapshot().values[name];
|
|
105
|
+
if (input instanceof HTMLInputElement && input.type === "checkbox")
|
|
106
|
+
input.checked = current === true;
|
|
107
|
+
else
|
|
108
|
+
input.value = current === undefined || current === null ? "" : String(current);
|
|
109
|
+
const update = () => {
|
|
110
|
+
if (!active)
|
|
111
|
+
return;
|
|
112
|
+
let value = input.value;
|
|
113
|
+
if (input instanceof HTMLInputElement && input.type === "checkbox")
|
|
114
|
+
value = input.checked;
|
|
115
|
+
else if (input.value !== "" && (field.authoritativeType === "number" || field.authoritativeType === "integer")) {
|
|
116
|
+
const parsed = Number(input.value);
|
|
117
|
+
value = Number.isFinite(parsed) ? parsed : input.value;
|
|
118
|
+
}
|
|
119
|
+
else if (input.value !== "" && field.options && field.authoritativeType === "boolean")
|
|
120
|
+
value = input.value === "true";
|
|
121
|
+
controller.setField(name, value);
|
|
122
|
+
};
|
|
123
|
+
input.addEventListener("input", update);
|
|
124
|
+
input.addEventListener("change", update);
|
|
125
|
+
cleanup.push(() => { input.removeEventListener("input", update); input.removeEventListener("change", update); });
|
|
126
|
+
const error = element("small", "veil-error");
|
|
127
|
+
error.id = errorId;
|
|
128
|
+
updates.push((state) => {
|
|
129
|
+
const currentValue = state.values[name];
|
|
130
|
+
if (input instanceof HTMLInputElement && input.type === "checkbox")
|
|
131
|
+
input.checked = currentValue === true;
|
|
132
|
+
else {
|
|
133
|
+
const display = currentValue === undefined || currentValue === null ? "" : String(currentValue);
|
|
134
|
+
const sameNumber = input instanceof HTMLInputElement && input.type === "number" && input.value !== "" && typeof currentValue === "number" && Number(input.value) === currentValue;
|
|
135
|
+
if (!sameNumber && input.value !== display)
|
|
136
|
+
input.value = display;
|
|
137
|
+
}
|
|
138
|
+
error.textContent = state.errors[name] ?? "";
|
|
139
|
+
input.setAttribute("aria-invalid", state.errors[name] ? "true" : "false");
|
|
140
|
+
input.disabled = state.status === "submitting";
|
|
141
|
+
});
|
|
142
|
+
wrapper.append(label, input, error);
|
|
143
|
+
return wrapper;
|
|
144
|
+
}
|
|
145
|
+
if (node.component === "Submit") {
|
|
146
|
+
const button = document.createElement("button");
|
|
147
|
+
button.type = "submit";
|
|
148
|
+
button.dataset["veilAction"] = node.action;
|
|
149
|
+
button.textContent = node.text ?? "Submit";
|
|
150
|
+
button.style.minHeight = "2.75rem";
|
|
151
|
+
button.style.fontSize = "1rem";
|
|
152
|
+
updates.push((state) => { button.disabled = state.status === "submitting"; });
|
|
153
|
+
return button;
|
|
154
|
+
}
|
|
155
|
+
const custom = Object.hasOwn(components, node.component) ? components[node.component] : undefined;
|
|
156
|
+
if (!custom)
|
|
157
|
+
throw new Error(`Unknown component: ${node.component}`);
|
|
158
|
+
const result = custom(node, { controller, renderChildren: children });
|
|
159
|
+
if (!(result instanceof HTMLElement))
|
|
160
|
+
throw new Error(`Component ${node.component} must return an HTMLElement`);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
const screen = render(definition.screen);
|
|
164
|
+
screen.style.maxWidth = "36rem";
|
|
165
|
+
screen.style.width = "100%";
|
|
166
|
+
screen.style.boxSizing = "border-box";
|
|
167
|
+
const unsubscribe = controller.subscribe((state) => { if (active)
|
|
168
|
+
for (const update of updates)
|
|
169
|
+
update(state); });
|
|
170
|
+
mounted.get(target)?.();
|
|
171
|
+
target.replaceChildren(screen);
|
|
172
|
+
const dispose = () => {
|
|
173
|
+
if (!active)
|
|
174
|
+
return;
|
|
175
|
+
active = false;
|
|
176
|
+
unsubscribe();
|
|
177
|
+
for (const remove of cleanup)
|
|
178
|
+
remove();
|
|
179
|
+
if (screen.parentNode === target)
|
|
180
|
+
target.removeChild(screen);
|
|
181
|
+
if (mounted.get(target) === dispose)
|
|
182
|
+
mounted.delete(target);
|
|
183
|
+
};
|
|
184
|
+
mounted.set(target, dispose);
|
|
185
|
+
return dispose;
|
|
186
|
+
}
|
|
187
|
+
/** Own a controller and one DOM mount with a single teardown. Component factories remain trusted host code. */
|
|
188
|
+
export function createMountedInterface(target, definition, options = {}) {
|
|
189
|
+
const components = options.components ?? {};
|
|
190
|
+
const controller = createInterfaceController(definition, {
|
|
191
|
+
components: Object.keys(components),
|
|
192
|
+
...(options.allowedActions ? { allowedActions: options.allowedActions } : {}),
|
|
193
|
+
...(options.onEvent ? { onEvent: options.onEvent } : {}),
|
|
194
|
+
...(options.onError ? { onError: options.onError } : {}),
|
|
195
|
+
});
|
|
196
|
+
let unmount;
|
|
197
|
+
try {
|
|
198
|
+
unmount = mountInterface(target, controller, components, options.onError);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
controller.dispose();
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
let disposed = false;
|
|
205
|
+
const disposeController = controller.dispose.bind(controller);
|
|
206
|
+
return Object.assign(controller, {
|
|
207
|
+
unmount,
|
|
208
|
+
dispose() { if (disposed)
|
|
209
|
+
return; disposed = true; unmount(); disposeController(); },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** Mount a framework-neutral interface. The caller owns and disposes the controller separately. */
|
|
213
|
+
export function mountInterface(target, controller, components = {}, onError) {
|
|
214
|
+
let active = true;
|
|
215
|
+
let root;
|
|
216
|
+
let unregister;
|
|
217
|
+
const reported = new WeakSet();
|
|
218
|
+
const report = (error) => {
|
|
219
|
+
if (error !== null && typeof error === "object") {
|
|
220
|
+
if (reported.has(error))
|
|
221
|
+
return;
|
|
222
|
+
reported.add(error);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
onError?.(error);
|
|
226
|
+
}
|
|
227
|
+
catch { /* Reporting cannot interrupt a transaction. */ }
|
|
228
|
+
};
|
|
229
|
+
const domId = `veil-interface-${++instance}`;
|
|
230
|
+
const make = (tag, className) => { const el = document.createElement(tag); el.className = className; return el; };
|
|
231
|
+
const display = (value) => value === undefined || value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
232
|
+
const resolveText = (node, snapshot) => typeof node.text === "string" ? node.text : node.text ? display(resolveInterfaceBinding(snapshot, node.text)) : "";
|
|
233
|
+
const visible = (node, snapshot) => !node.visible || resolveInterfaceBinding(snapshot, node.visible.binding) === node.visible.equals;
|
|
234
|
+
function create(node, snapshot, candidate = false) {
|
|
235
|
+
const children = [];
|
|
236
|
+
let element;
|
|
237
|
+
let dispose = () => { };
|
|
238
|
+
let updateCustom;
|
|
239
|
+
let live = true;
|
|
240
|
+
const lifetime = new AbortController();
|
|
241
|
+
if (node.component === "Stack" || node.component === "Row")
|
|
242
|
+
element = make("div", `veil-interface-${node.component.toLowerCase()}`);
|
|
243
|
+
else if (node.component === "Text")
|
|
244
|
+
element = make("p", "veil-interface-text");
|
|
245
|
+
else if (node.component === "Heading")
|
|
246
|
+
element = make("h2", "veil-interface-heading");
|
|
247
|
+
else if (node.component === "Status") {
|
|
248
|
+
element = make("p", "veil-interface-status");
|
|
249
|
+
element.setAttribute("role", "status");
|
|
250
|
+
element.setAttribute("aria-live", "polite");
|
|
251
|
+
}
|
|
252
|
+
else if (node.component === "Button") {
|
|
253
|
+
const button = document.createElement("button");
|
|
254
|
+
button.type = "button";
|
|
255
|
+
button.className = "veil-interface-button";
|
|
256
|
+
const click = () => { if (active && live) {
|
|
257
|
+
try {
|
|
258
|
+
controller.dispatch(node.action, node.key);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
report(error);
|
|
262
|
+
}
|
|
263
|
+
} };
|
|
264
|
+
button.addEventListener("click", click);
|
|
265
|
+
dispose = () => button.removeEventListener("click", click);
|
|
266
|
+
element = button;
|
|
267
|
+
}
|
|
268
|
+
else if (node.component === "Input") {
|
|
269
|
+
const wrapper = make("div", "veil-interface-input");
|
|
270
|
+
const label = document.createElement("label");
|
|
271
|
+
const input = document.createElement("input");
|
|
272
|
+
input.id = `${domId}-${node.key}`;
|
|
273
|
+
label.htmlFor = input.id;
|
|
274
|
+
input.type = node.inputType ?? "text";
|
|
275
|
+
input.style.boxSizing = "border-box";
|
|
276
|
+
input.style.maxWidth = "100%";
|
|
277
|
+
input.style.minWidth = "0";
|
|
278
|
+
const change = () => {
|
|
279
|
+
if (!active || !live)
|
|
280
|
+
return;
|
|
281
|
+
const value = input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
|
|
282
|
+
try {
|
|
283
|
+
controller.setState(node.binding.path, value);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
const accepted = resolveInterfaceBinding(controller.snapshot(), node.binding);
|
|
287
|
+
if (input.type === "checkbox")
|
|
288
|
+
input.checked = accepted === true;
|
|
289
|
+
else
|
|
290
|
+
input.value = display(accepted);
|
|
291
|
+
report(error);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
input.addEventListener("input", change);
|
|
295
|
+
input.addEventListener("change", change);
|
|
296
|
+
dispose = () => { input.removeEventListener("input", change); input.removeEventListener("change", change); };
|
|
297
|
+
wrapper.append(label, input);
|
|
298
|
+
element = wrapper;
|
|
299
|
+
}
|
|
300
|
+
else if (node.component === "Table") {
|
|
301
|
+
element = make("table", "veil-interface-table");
|
|
302
|
+
element.style.display = "block";
|
|
303
|
+
element.style.maxWidth = "100%";
|
|
304
|
+
element.style.overflowX = "auto";
|
|
305
|
+
}
|
|
306
|
+
else if (node.component === "List")
|
|
307
|
+
element = make("ul", "veil-interface-list");
|
|
308
|
+
else {
|
|
309
|
+
const custom = Object.hasOwn(components, node.component) ? components[node.component] : undefined;
|
|
310
|
+
if (!custom)
|
|
311
|
+
throw new Error(`Unknown component: ${node.component}`);
|
|
312
|
+
let created;
|
|
313
|
+
let renderedChildren = false;
|
|
314
|
+
try {
|
|
315
|
+
created = custom(node, { snapshot, signal: lifetime.signal, renderChildren: () => {
|
|
316
|
+
if (renderedChildren)
|
|
317
|
+
throw new Error("renderChildren may be called once");
|
|
318
|
+
renderedChildren = true;
|
|
319
|
+
for (const child of node.children ?? [])
|
|
320
|
+
children.push(create(child, snapshot, candidate));
|
|
321
|
+
return children.map((child) => child.element);
|
|
322
|
+
} });
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
lifetime.abort();
|
|
326
|
+
for (const child of children)
|
|
327
|
+
destroy(child);
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
if (!created || typeof created !== "object") {
|
|
331
|
+
lifetime.abort();
|
|
332
|
+
for (const child of children)
|
|
333
|
+
destroy(child);
|
|
334
|
+
throw new Error(`Component ${node.component} must return an HTMLElement`);
|
|
335
|
+
}
|
|
336
|
+
const instance = created instanceof HTMLElement ? { element: created } : created;
|
|
337
|
+
element = instance.element;
|
|
338
|
+
updateCustom = instance.update;
|
|
339
|
+
dispose = () => instance.dispose?.();
|
|
340
|
+
if (!(element instanceof HTMLElement)) {
|
|
341
|
+
lifetime.abort();
|
|
342
|
+
for (const child of children)
|
|
343
|
+
destroy(child);
|
|
344
|
+
throw new Error(`Component ${node.component} must return an HTMLElement`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const result = { node, element, children, dispose: () => { live = false; lifetime.abort(); dispose(); }, updateCustom, visibleDisplay: element.style.display, live: true };
|
|
348
|
+
if (node.component === "Stack" || node.component === "Row") {
|
|
349
|
+
element.style.display = node.component === "Stack" ? "grid" : "flex";
|
|
350
|
+
element.style.gap = "0.75rem";
|
|
351
|
+
element.style.minWidth = "0";
|
|
352
|
+
if (node.component === "Stack")
|
|
353
|
+
element.style.gridTemplateColumns = "minmax(0, 1fr)";
|
|
354
|
+
if (node.component === "Row")
|
|
355
|
+
element.style.flexWrap = "wrap";
|
|
356
|
+
try {
|
|
357
|
+
for (const child of node.children ?? [])
|
|
358
|
+
children.push(create(child, snapshot, candidate));
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
for (const child of children)
|
|
362
|
+
destroy(child);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
element.append(...children.map((child) => child.element));
|
|
366
|
+
}
|
|
367
|
+
result.visibleDisplay = element.style.display;
|
|
368
|
+
update(result, snapshot);
|
|
369
|
+
if (candidate && updateCustom) {
|
|
370
|
+
try {
|
|
371
|
+
updateCustom(node, snapshot);
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
destroy(result);
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
function update(rendered, snapshot) {
|
|
381
|
+
const { node, element } = rendered;
|
|
382
|
+
const isVisible = visible(node, snapshot);
|
|
383
|
+
element.hidden = !isVisible;
|
|
384
|
+
element.style.display = isVisible ? rendered.visibleDisplay : "none";
|
|
385
|
+
if (node.ariaLabel)
|
|
386
|
+
element.setAttribute("aria-label", node.ariaLabel);
|
|
387
|
+
else
|
|
388
|
+
element.removeAttribute("aria-label");
|
|
389
|
+
if (["Text", "Heading", "Status", "Button"].includes(node.component))
|
|
390
|
+
element.textContent = resolveText(node, snapshot);
|
|
391
|
+
if (node.component === "List") {
|
|
392
|
+
const items = resolveInterfaceBinding(snapshot, node.binding);
|
|
393
|
+
if (items !== undefined && (!Array.isArray(items) || items.length > 100))
|
|
394
|
+
throw new Error("List item limit exceeded");
|
|
395
|
+
const entries = (items ?? []).map((item) => { const li = document.createElement("li"); li.textContent = display(item); return li; });
|
|
396
|
+
element.replaceChildren(...entries);
|
|
397
|
+
}
|
|
398
|
+
if (node.component === "Input") {
|
|
399
|
+
const label = element.querySelector("label");
|
|
400
|
+
const input = element.querySelector("input");
|
|
401
|
+
label.textContent = node.label ?? "";
|
|
402
|
+
const value = resolveInterfaceBinding(snapshot, node.binding);
|
|
403
|
+
if (input.type === "checkbox")
|
|
404
|
+
input.checked = value === true;
|
|
405
|
+
else {
|
|
406
|
+
const shown = display(value);
|
|
407
|
+
const sameNumber = input.type === "number" && input.value !== "" && typeof value === "number" && Number(input.value) === value;
|
|
408
|
+
if (!sameNumber && input.value !== shown)
|
|
409
|
+
input.value = shown;
|
|
410
|
+
}
|
|
411
|
+
if (node.ariaLabel)
|
|
412
|
+
input.setAttribute("aria-label", node.ariaLabel);
|
|
413
|
+
else
|
|
414
|
+
input.removeAttribute("aria-label");
|
|
415
|
+
}
|
|
416
|
+
if (node.component === "Table") {
|
|
417
|
+
const rows = resolveInterfaceBinding(snapshot, node.binding);
|
|
418
|
+
if (rows !== undefined && (!Array.isArray(rows) || rows.length > 100))
|
|
419
|
+
throw new Error("Table row limit exceeded");
|
|
420
|
+
const head = document.createElement("thead");
|
|
421
|
+
const header = document.createElement("tr");
|
|
422
|
+
for (const col of node.columns ?? []) {
|
|
423
|
+
const cell = document.createElement("th");
|
|
424
|
+
cell.scope = "col";
|
|
425
|
+
cell.textContent = col.label;
|
|
426
|
+
header.append(cell);
|
|
427
|
+
}
|
|
428
|
+
head.append(header);
|
|
429
|
+
const body = document.createElement("tbody");
|
|
430
|
+
for (const row of rows ?? []) {
|
|
431
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
432
|
+
throw new Error("Invalid table row");
|
|
433
|
+
const tr = document.createElement("tr");
|
|
434
|
+
for (const col of node.columns ?? []) {
|
|
435
|
+
const cell = document.createElement("td");
|
|
436
|
+
cell.textContent = display(Object.hasOwn(row, col.key) ? row[col.key] : undefined);
|
|
437
|
+
tr.append(cell);
|
|
438
|
+
}
|
|
439
|
+
body.append(tr);
|
|
440
|
+
}
|
|
441
|
+
element.replaceChildren(head, body);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function destroy(rendered) {
|
|
445
|
+
if (!rendered.live)
|
|
446
|
+
return;
|
|
447
|
+
rendered.live = false;
|
|
448
|
+
try {
|
|
449
|
+
rendered.dispose();
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
report(error);
|
|
453
|
+
}
|
|
454
|
+
for (const child of rendered.children)
|
|
455
|
+
destroy(child);
|
|
456
|
+
}
|
|
457
|
+
// Allocate every new subtree before changing the mounted tree. A failed factory leaves it intact.
|
|
458
|
+
function prepare(previous, node, snapshot, staged) {
|
|
459
|
+
const same = previous.node.key === node.key && previous.node.component === node.component &&
|
|
460
|
+
(node.component !== "Button" || previous.node.action === node.action) &&
|
|
461
|
+
(node.component !== "Input" || (previous.node.binding?.path === node.binding?.path && previous.node.inputType === node.inputType)) &&
|
|
462
|
+
["Stack", "Row", "Text", "Heading", "Status", "Input", "Button", "Table", "List"].includes(node.component);
|
|
463
|
+
if (!same) {
|
|
464
|
+
const next = create(node, snapshot, true);
|
|
465
|
+
staged.push(next);
|
|
466
|
+
return () => { previous.element.replaceWith(next.element); destroy(previous); return next; };
|
|
467
|
+
}
|
|
468
|
+
const prior = node.component === "Stack" || node.component === "Row" ? new Map(previous.children.map((child) => [child.node.key, child])) : undefined;
|
|
469
|
+
const childPlans = (node.children ?? []).map((child) => {
|
|
470
|
+
const old = prior?.get(child.key);
|
|
471
|
+
if (old) {
|
|
472
|
+
prior.delete(child.key);
|
|
473
|
+
return prepare(old, child, snapshot, staged);
|
|
474
|
+
}
|
|
475
|
+
const next = create(child, snapshot, true);
|
|
476
|
+
staged.push(next);
|
|
477
|
+
return () => next;
|
|
478
|
+
});
|
|
479
|
+
return () => {
|
|
480
|
+
previous.node = node;
|
|
481
|
+
update(previous, snapshot);
|
|
482
|
+
if (node.component === "Stack" || node.component === "Row") {
|
|
483
|
+
const children = childPlans.map((plan) => plan());
|
|
484
|
+
previous.children = children;
|
|
485
|
+
for (let index = 0; index < children.length; index++) {
|
|
486
|
+
const desired = children[index].element;
|
|
487
|
+
if (previous.element.children[index] !== desired)
|
|
488
|
+
previous.element.insertBefore(desired, previous.element.children[index] ?? null);
|
|
489
|
+
}
|
|
490
|
+
while (previous.element.children.length > children.length)
|
|
491
|
+
previous.element.lastElementChild?.remove();
|
|
492
|
+
for (const removed of prior.values())
|
|
493
|
+
destroy(removed);
|
|
494
|
+
}
|
|
495
|
+
return previous;
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const first = controller.snapshot();
|
|
500
|
+
root = create(first.definition.screen, first);
|
|
501
|
+
unregister = registerInterfaceRenderer(controller, (snapshot) => {
|
|
502
|
+
const staged = [];
|
|
503
|
+
try {
|
|
504
|
+
const commit = prepare(root, snapshot.definition.screen, snapshot, staged);
|
|
505
|
+
return { commit: () => {
|
|
506
|
+
if (!active) {
|
|
507
|
+
for (const node of staged)
|
|
508
|
+
destroy(node);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const focused = root?.element.contains(document.activeElement) ? document.activeElement : undefined;
|
|
512
|
+
const selection = focused instanceof HTMLInputElement || focused instanceof HTMLTextAreaElement ? { start: focused.selectionStart, end: focused.selectionEnd, direction: focused.selectionDirection } : undefined;
|
|
513
|
+
root = commit();
|
|
514
|
+
if (focused?.isConnected && document.activeElement !== focused) {
|
|
515
|
+
focused.focus({ preventScroll: true });
|
|
516
|
+
if (selection && selection.start !== null && selection.end !== null && (focused instanceof HTMLInputElement || focused instanceof HTMLTextAreaElement))
|
|
517
|
+
focused.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
|
|
518
|
+
}
|
|
519
|
+
}, discard: () => { for (const node of staged)
|
|
520
|
+
destroy(node); } };
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
for (const node of staged)
|
|
524
|
+
destroy(node);
|
|
525
|
+
report(error);
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
mounted.get(target)?.();
|
|
530
|
+
target.replaceChildren(root.element);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
unregister?.();
|
|
534
|
+
if (root)
|
|
535
|
+
destroy(root);
|
|
536
|
+
active = false;
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
const dispose = () => {
|
|
540
|
+
if (!active)
|
|
541
|
+
return;
|
|
542
|
+
active = false;
|
|
543
|
+
unregister?.();
|
|
544
|
+
if (root) {
|
|
545
|
+
destroy(root);
|
|
546
|
+
if (root.element.parentNode === target)
|
|
547
|
+
target.removeChild(root.element);
|
|
548
|
+
}
|
|
549
|
+
if (mounted.get(target) === dispose)
|
|
550
|
+
mounted.delete(target);
|
|
551
|
+
};
|
|
552
|
+
mounted.set(target, dispose);
|
|
553
|
+
return dispose;
|
|
554
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/input-schema.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { CapabilityInputField } from "@veil-runtime/core";
|
|
2
1
|
export type RichStringFormat = "date" | "date-time" | "multiline";
|
|
3
|
-
|
|
2
|
+
/** The shared field shape is structural so composition needs no Core installation. */
|
|
3
|
+
export interface RichCapabilityInputField {
|
|
4
4
|
type: "string" | "number" | "integer" | "boolean" | "object" | "array";
|
|
5
|
+
required: boolean;
|
|
6
|
+
description: string;
|
|
5
7
|
enum?: Array<string | number | boolean>;
|
|
6
8
|
format?: RichStringFormat;
|
|
7
9
|
semantic?: "currency";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Internal bridge between a neutral controller and any mounted renderers. */
|
|
2
|
+
export interface PreparedInterfaceRender {
|
|
3
|
+
commit(): void;
|
|
4
|
+
discard(): void;
|
|
5
|
+
}
|
|
6
|
+
export type InterfaceRenderParticipant<T> = (candidate: T) => PreparedInterfaceRender;
|
|
7
|
+
export declare function attachInterfaceRenderers<T>(controller: object, participants: Set<InterfaceRenderParticipant<T>>): void;
|
|
8
|
+
export declare function detachInterfaceRenderers(controller: object): void;
|
|
9
|
+
export declare function registerInterfaceRenderer<T>(controller: object, renderer: InterfaceRenderParticipant<T>): () => void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const renderers = new WeakMap();
|
|
2
|
+
export function attachInterfaceRenderers(controller, participants) {
|
|
3
|
+
renderers.set(controller, participants);
|
|
4
|
+
}
|
|
5
|
+
export function detachInterfaceRenderers(controller) {
|
|
6
|
+
renderers.delete(controller);
|
|
7
|
+
}
|
|
8
|
+
export function registerInterfaceRenderer(controller, renderer) {
|
|
9
|
+
const participants = renderers.get(controller);
|
|
10
|
+
if (!participants)
|
|
11
|
+
throw new Error("Interface controller is disposed or unsupported");
|
|
12
|
+
participants.add(renderer);
|
|
13
|
+
return () => { participants.delete(renderer); };
|
|
14
|
+
}
|