claude-artifact-framework 0.5.2 → 0.7.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/README.md +84 -8
- package/dist/index.esm.js +216 -18
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +25 -9
- package/dist/index.global.js.map +3 -3
- package/package.json +1 -1
- package/src/app.js +63 -2
- package/src/blocks.js +47 -4
- package/src/chat.js +34 -3
- package/src/records.js +109 -9
- package/src/steps.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Utilities for building apps inside Claude Artifacts: platform storage, chat tool-calling loops, and other primitives verified against the real runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.esm.js",
|
package/src/app.js
CHANGED
|
@@ -36,7 +36,31 @@ function checkFields(fields, where) {
|
|
|
36
36
|
`claude-artifact-framework: field "${f.key}" in ${where} has unknown type "${f.type}". Valid types: ${FIELD_TYPES.join(", ")}.`
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
+
if (f.type === "select" && f.options !== undefined && !Array.isArray(f.options) && typeof f.options !== "function") {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`claude-artifact-framework: field "${f.key}" in ${where}: \`options\` must be an array or a function of data returning one.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ACTION_KEYS = ["label", "run", "when", "kind"];
|
|
48
|
+
|
|
49
|
+
function checkActions(actions, where) {
|
|
50
|
+
if (!Array.isArray(actions)) {
|
|
51
|
+
throw new Error(`claude-artifact-framework: ${where} must be an array of { label, run }.`);
|
|
39
52
|
}
|
|
53
|
+
actions.forEach((a, i) => {
|
|
54
|
+
if (!a || typeof a.label !== "string" || typeof a.run !== "function") {
|
|
55
|
+
throw new Error(`claude-artifact-framework: ${where}[${i}] needs a \`label\` string and a \`run\` function.`);
|
|
56
|
+
}
|
|
57
|
+
const unknown = Object.keys(a).filter((k) => !ACTION_KEYS.includes(k));
|
|
58
|
+
if (unknown.length) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`claude-artifact-framework: ${where}[${i}] has unknown keys (${unknown.join(", ")}). Valid keys: ${ACTION_KEYS.join(", ")}.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
function checkBlocks(blocks) {
|
|
@@ -64,6 +88,7 @@ function checkBlocks(blocks) {
|
|
|
64
88
|
);
|
|
65
89
|
}
|
|
66
90
|
checkFields(block.fields, `blocks[${i}]`);
|
|
91
|
+
if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
|
|
67
92
|
});
|
|
68
93
|
}
|
|
69
94
|
|
|
@@ -141,7 +166,7 @@ function validate(spec) {
|
|
|
141
166
|
'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
|
|
142
167
|
);
|
|
143
168
|
}
|
|
144
|
-
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
|
|
169
|
+
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
|
|
145
170
|
const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
|
|
146
171
|
if (unknownR.length) {
|
|
147
172
|
throw new Error(
|
|
@@ -156,8 +181,28 @@ function validate(spec) {
|
|
|
156
181
|
seen.add(f.key);
|
|
157
182
|
}
|
|
158
183
|
if (spec.blocks) checkBlocks(spec.blocks);
|
|
184
|
+
if (r.actions) checkActions(r.actions, "records.actions");
|
|
185
|
+
if (r.items) {
|
|
186
|
+
const ITEMS_KEYS = ["label", "fields", "empty"];
|
|
187
|
+
const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
|
|
188
|
+
if (unknownI.length) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
|
|
194
|
+
throw new Error("claude-artifact-framework: records.items needs `fields: [...]` — the same field declarations as everywhere else.");
|
|
195
|
+
}
|
|
196
|
+
checkFields(r.items.fields, "records.items");
|
|
197
|
+
for (const f of r.items.fields) {
|
|
198
|
+
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
|
|
199
|
+
}
|
|
200
|
+
if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
|
|
201
|
+
throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared — that key holds the child collection.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
159
204
|
for (const [key, c] of Object.entries(r.compute || {})) {
|
|
160
|
-
if (seen.has(key) || key === "id") {
|
|
205
|
+
if (seen.has(key) || key === "id" || (r.items && key === "items")) {
|
|
161
206
|
throw new Error(
|
|
162
207
|
`claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
|
|
163
208
|
);
|
|
@@ -449,6 +494,22 @@ const BASE_CSS = `
|
|
|
449
494
|
.caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
450
495
|
.caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
|
|
451
496
|
.caf-records-blocks { margin-top: 1rem; }
|
|
497
|
+
.caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
|
|
498
|
+
.caf-row-wrap .caf-row { flex: 1; min-width: 0; }
|
|
499
|
+
.caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
|
|
500
|
+
.caf-btn-sm { font-size: 0.78rem; padding: 0.3rem 0.6rem; border-radius: 6px; }
|
|
501
|
+
.caf-items { display: flex; flex-direction: column; gap: 0.5rem; border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
|
|
502
|
+
.caf-item-row { display: flex; align-items: flex-end; gap: 0.5rem; }
|
|
503
|
+
.caf-item-row .caf-field { flex: 1; min-width: 0; }
|
|
504
|
+
.caf-item-row .caf-field label { font-size: 0.68rem; }
|
|
505
|
+
.caf-item-remove { flex: none; margin-bottom: 0.15rem; }
|
|
506
|
+
.caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
|
|
507
|
+
.caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
|
|
508
|
+
.caf-code { background: var(--caf-sunken); border-radius: 6px; padding: 0.55rem 0.7rem; font-size: 0.8rem; overflow-x: auto; margin: 0.3rem 0; font-family: ui-monospace, monospace; }
|
|
509
|
+
.caf-msg code { background: color-mix(in srgb, currentColor 12%, transparent); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; font-family: ui-monospace, monospace; }
|
|
510
|
+
.caf-md-h { display: block; margin-top: 0.3rem; }
|
|
511
|
+
.caf-md-li { display: block; padding-left: 1rem; position: relative; }
|
|
512
|
+
.caf-md-li::before { content: "•"; position: absolute; left: 0.2rem; opacity: 0.6; }
|
|
452
513
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
453
514
|
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
454
515
|
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|
package/src/blocks.js
CHANGED
|
@@ -49,7 +49,7 @@ export function validateField(field, value) {
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function Field({ field, value, onChange }) {
|
|
52
|
+
export function Field({ field, value, onChange, data }) {
|
|
53
53
|
const error = validateField(field, value);
|
|
54
54
|
const id = `caf-f-${field.key}`;
|
|
55
55
|
const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
|
|
@@ -79,11 +79,14 @@ export function Field({ field, value, onChange }) {
|
|
|
79
79
|
if (field.type === "textarea") {
|
|
80
80
|
control = h("textarea", { ...common, rows: field.rows || 3 });
|
|
81
81
|
} else if (field.type === "select") {
|
|
82
|
+
// Options may be declared as a function of the app data, for selects
|
|
83
|
+
// whose choices come from what the user has entered elsewhere.
|
|
84
|
+
const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
|
|
82
85
|
control = h(
|
|
83
86
|
"select",
|
|
84
87
|
common,
|
|
85
88
|
h("option", { value: "" }, field.placeholder || "Select…"),
|
|
86
|
-
|
|
89
|
+
options.map((opt) => {
|
|
87
90
|
const val = typeof opt === "string" ? opt : opt.value;
|
|
88
91
|
const label = typeof opt === "string" ? opt : opt.label;
|
|
89
92
|
return h("option", { key: val, value: val }, label);
|
|
@@ -126,6 +129,7 @@ export function FieldsBlock({ spec, ctx }) {
|
|
|
126
129
|
h(Field, {
|
|
127
130
|
key: field.key,
|
|
128
131
|
field,
|
|
132
|
+
data: ctx.data,
|
|
129
133
|
value: ctx.data[field.key],
|
|
130
134
|
onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
|
|
131
135
|
})
|
|
@@ -170,13 +174,18 @@ export function TextBlock({ spec, ctx }) {
|
|
|
170
174
|
|
|
171
175
|
export function ListBlock({ spec, ctx }) {
|
|
172
176
|
const rows = (typeof spec.list === "function" ? spec.list(ctx.data) : spec.list) || [];
|
|
177
|
+
// `title` follows the same convention as every other block: a string is the
|
|
178
|
+
// block heading. A function is the per-row title. Both are supported —
|
|
179
|
+
// making list the odd one out was an API inconsistency a blind test caught.
|
|
180
|
+
const heading = typeof spec.title === "string" ? spec.title : null;
|
|
181
|
+
const rowTitle = typeof spec.title === "function" ? spec.title : (r) => r.title ?? r.name ?? r.label ?? r.text ?? String(r);
|
|
173
182
|
if (!rows.length) {
|
|
174
183
|
return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing here yet.");
|
|
175
184
|
}
|
|
176
|
-
const title = spec.title || ((r) => r.title ?? r.name ?? r.label ?? String(r));
|
|
177
185
|
return h(
|
|
178
186
|
"div",
|
|
179
187
|
{ className: "caf-block caf-list" },
|
|
188
|
+
heading ? h("h2", { className: "caf-block-title" }, heading) : null,
|
|
180
189
|
rows.map((row, i) =>
|
|
181
190
|
h(
|
|
182
191
|
"button",
|
|
@@ -186,7 +195,7 @@ export function ListBlock({ spec, ctx }) {
|
|
|
186
195
|
type: "button",
|
|
187
196
|
onClick: spec.onSelect ? () => spec.onSelect(row, ctx) : undefined,
|
|
188
197
|
},
|
|
189
|
-
h("span", { className: "caf-row-title" },
|
|
198
|
+
h("span", { className: "caf-row-title" }, rowTitle(row)),
|
|
190
199
|
spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(row)) : null
|
|
191
200
|
)
|
|
192
201
|
)
|
|
@@ -365,6 +374,39 @@ export function TimerBlock({ spec, ctx }) {
|
|
|
365
374
|
);
|
|
366
375
|
}
|
|
367
376
|
|
|
377
|
+
// Declarative buttons that mutate data — the one-tap interactions (counters,
|
|
378
|
+
// check-ins, +1s) that previously forced an escape to custom code.
|
|
379
|
+
export function ActionsBlock({ spec, ctx }) {
|
|
380
|
+
const actions = spec.actions || [];
|
|
381
|
+
const visible = actions.filter((a) => !a.when || a.when(ctx.data));
|
|
382
|
+
return h(
|
|
383
|
+
"div",
|
|
384
|
+
{ className: "caf-block caf-actions" },
|
|
385
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
386
|
+
h(
|
|
387
|
+
"div",
|
|
388
|
+
{ className: "caf-actions-row" },
|
|
389
|
+
visible.map((a) =>
|
|
390
|
+
h(
|
|
391
|
+
"button",
|
|
392
|
+
{
|
|
393
|
+
key: a.label,
|
|
394
|
+
type: "button",
|
|
395
|
+
className:
|
|
396
|
+
a.kind === "danger"
|
|
397
|
+
? "caf-btn caf-btn-danger"
|
|
398
|
+
: a.kind === "primary"
|
|
399
|
+
? "caf-btn caf-btn-primary"
|
|
400
|
+
: "caf-btn",
|
|
401
|
+
onClick: () => ctx.update((d) => a.run(d)),
|
|
402
|
+
},
|
|
403
|
+
a.label
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
368
410
|
// The escape hatch. Receives the same ctx every built-in block gets, so a
|
|
369
411
|
// custom block keeps persistence, theming, navigation and the error boundary.
|
|
370
412
|
export function CustomBlock({ spec, ctx }) {
|
|
@@ -382,6 +424,7 @@ export const BLOCKS = {
|
|
|
382
424
|
list: ListBlock,
|
|
383
425
|
chart: ChartBlock,
|
|
384
426
|
timer: TimerBlock,
|
|
427
|
+
actions: ActionsBlock,
|
|
385
428
|
custom: CustomBlock,
|
|
386
429
|
};
|
|
387
430
|
|
package/src/chat.js
CHANGED
|
@@ -125,6 +125,29 @@ function runTools(calls, tools, ctx) {
|
|
|
125
125
|
});
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Claude answers in markdown; showing raw asterisks makes every chat app
|
|
129
|
+
// look broken. Minimal renderer: HTML is escaped FIRST, then a small set of
|
|
130
|
+
// markdown forms is layered on, so model output can never inject markup.
|
|
131
|
+
const escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
132
|
+
|
|
133
|
+
export function renderMarkdown(text) {
|
|
134
|
+
const codeBlocks = [];
|
|
135
|
+
let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
|
|
136
|
+
codeBlocks.push(code.replace(/\n$/, ""));
|
|
137
|
+
return "\u0000" + (codeBlocks.length - 1) + "\u0000";
|
|
138
|
+
});
|
|
139
|
+
t = escHtml(t);
|
|
140
|
+
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
141
|
+
t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
142
|
+
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
143
|
+
t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
|
|
144
|
+
t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
|
|
145
|
+
t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
|
|
146
|
+
t = t.replace(/\n/g, "<br>");
|
|
147
|
+
t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
|
|
148
|
+
return t;
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
function Bubble({ m }) {
|
|
129
152
|
if (m.role === "tools") {
|
|
130
153
|
return h("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
|
|
@@ -138,7 +161,11 @@ function Bubble({ m }) {
|
|
|
138
161
|
h("span", { className: "caf-hint" }, "Your message is kept — send again to retry.")
|
|
139
162
|
);
|
|
140
163
|
}
|
|
141
|
-
|
|
164
|
+
if (m.role === "user") return h("div", { className: "caf-msg caf-msg-user" }, m.content);
|
|
165
|
+
return h("div", {
|
|
166
|
+
className: "caf-msg caf-msg-assistant",
|
|
167
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(m.content) },
|
|
168
|
+
});
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
export function ChatLayout({ spec, ctx }) {
|
|
@@ -220,9 +247,13 @@ export function ChatLayout({ spec, ctx }) {
|
|
|
220
247
|
h(
|
|
221
248
|
"div",
|
|
222
249
|
{ className: "caf-block caf-chat-log" },
|
|
223
|
-
c.greeting
|
|
250
|
+
c.greeting
|
|
251
|
+
? h("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } })
|
|
252
|
+
: null,
|
|
224
253
|
log.map((m, i) => h(Bubble, { key: i, m })),
|
|
225
|
-
live !== null
|
|
254
|
+
live !== null
|
|
255
|
+
? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "…") } })
|
|
256
|
+
: null,
|
|
226
257
|
busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
|
|
227
258
|
h("div", { ref: endRef })
|
|
228
259
|
),
|
package/src/records.js
CHANGED
|
@@ -99,6 +99,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
99
99
|
|
|
100
100
|
function add() {
|
|
101
101
|
const rec = { id: makeId(), ...recordDefaults(spec.fields) };
|
|
102
|
+
if (spec.items) rec.items = [];
|
|
102
103
|
ctx.update((d) => {
|
|
103
104
|
d.records = [...d.records, rec];
|
|
104
105
|
});
|
|
@@ -134,15 +135,42 @@ function ListScreen({ spec, ctx }) {
|
|
|
134
135
|
? h("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`)
|
|
135
136
|
: records.map((rec) =>
|
|
136
137
|
h(
|
|
137
|
-
"
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
"div",
|
|
139
|
+
{ key: rec.id, className: "caf-row-wrap" },
|
|
140
|
+
h(
|
|
141
|
+
"button",
|
|
142
|
+
{
|
|
143
|
+
type: "button",
|
|
144
|
+
className: "caf-row",
|
|
145
|
+
onClick: () => ctx.go("record", rec.id),
|
|
146
|
+
},
|
|
147
|
+
h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
|
|
148
|
+
spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
|
|
149
|
+
),
|
|
150
|
+
spec.actions
|
|
151
|
+
? h(
|
|
152
|
+
"div",
|
|
153
|
+
{ className: "caf-row-actions" },
|
|
154
|
+
spec.actions
|
|
155
|
+
.filter((a) => !a.when || a.when(rec))
|
|
156
|
+
.map((a) =>
|
|
157
|
+
h(
|
|
158
|
+
"button",
|
|
159
|
+
{
|
|
160
|
+
key: a.label,
|
|
161
|
+
type: "button",
|
|
162
|
+
className: a.kind === "danger" ? "caf-btn caf-btn-danger caf-btn-sm" : "caf-btn caf-btn-sm",
|
|
163
|
+
onClick: () =>
|
|
164
|
+
ctx.update((d) => {
|
|
165
|
+
const live = d.records.find((x) => x.id === rec.id);
|
|
166
|
+
if (live) a.run(live, d);
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
a.label
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
: null
|
|
146
174
|
)
|
|
147
175
|
)
|
|
148
176
|
);
|
|
@@ -171,6 +199,7 @@ function DetailScreen({ spec, ctx, record }) {
|
|
|
171
199
|
h(Field, {
|
|
172
200
|
key: field.key,
|
|
173
201
|
field,
|
|
202
|
+
data: ctx.data,
|
|
174
203
|
value: record[field.key],
|
|
175
204
|
onChange: (v) =>
|
|
176
205
|
ctx.update((d) => {
|
|
@@ -179,6 +208,7 @@ function DetailScreen({ spec, ctx, record }) {
|
|
|
179
208
|
}),
|
|
180
209
|
})
|
|
181
210
|
),
|
|
211
|
+
spec.items ? h(ItemsEditor, { spec, ctx, record }) : null,
|
|
182
212
|
spec.compute
|
|
183
213
|
? h(
|
|
184
214
|
"div",
|
|
@@ -197,6 +227,76 @@ function DetailScreen({ spec, ctx, record }) {
|
|
|
197
227
|
);
|
|
198
228
|
}
|
|
199
229
|
|
|
230
|
+
// The child collection edits INLINE on the parent's detail screen — one
|
|
231
|
+
// navigation level, no child screens. That bounds the feature honestly: items
|
|
232
|
+
// are for small rows (2-4 fields), not for records that deserve their own
|
|
233
|
+
// detail view.
|
|
234
|
+
function ItemsEditor({ spec, ctx, record }) {
|
|
235
|
+
const ispec = spec.items;
|
|
236
|
+
const label = ispec.label || "item";
|
|
237
|
+
|
|
238
|
+
function mutate(fn) {
|
|
239
|
+
ctx.update((d) => {
|
|
240
|
+
const rec = d.records.find((r) => r.id === record.id);
|
|
241
|
+
if (!rec) return;
|
|
242
|
+
if (!Array.isArray(rec.items)) rec.items = [];
|
|
243
|
+
fn(rec);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const items = Array.isArray(record.items) ? record.items : [];
|
|
248
|
+
|
|
249
|
+
return h(
|
|
250
|
+
"div",
|
|
251
|
+
{ className: "caf-items" },
|
|
252
|
+
h(
|
|
253
|
+
"div",
|
|
254
|
+
{ className: "caf-toolbar" },
|
|
255
|
+
h("span", { className: "caf-count" }, `${items.length} ${label}${items.length === 1 ? "" : "s"}`),
|
|
256
|
+
h(
|
|
257
|
+
"button",
|
|
258
|
+
{
|
|
259
|
+
type: "button",
|
|
260
|
+
className: "caf-btn caf-btn-sm",
|
|
261
|
+
onClick: () => mutate((rec) => rec.items.push({ id: makeId(), ...recordDefaults(ispec.fields) })),
|
|
262
|
+
},
|
|
263
|
+
`Add ${label}`
|
|
264
|
+
)
|
|
265
|
+
),
|
|
266
|
+
items.length === 0
|
|
267
|
+
? h("div", { className: "caf-empty caf-empty-sm" }, ispec.empty || `No ${label}s yet.`)
|
|
268
|
+
: items.map((item) =>
|
|
269
|
+
h(
|
|
270
|
+
"div",
|
|
271
|
+
{ key: item.id, className: "caf-item-row" },
|
|
272
|
+
ispec.fields.map((field) =>
|
|
273
|
+
h(Field, {
|
|
274
|
+
key: field.key,
|
|
275
|
+
field,
|
|
276
|
+
data: ctx.data,
|
|
277
|
+
value: item[field.key],
|
|
278
|
+
onChange: (v) =>
|
|
279
|
+
mutate((rec) => {
|
|
280
|
+
const it = rec.items.find((x) => x.id === item.id);
|
|
281
|
+
if (it) it[field.key] = v;
|
|
282
|
+
}),
|
|
283
|
+
})
|
|
284
|
+
),
|
|
285
|
+
h(
|
|
286
|
+
"button",
|
|
287
|
+
{
|
|
288
|
+
type: "button",
|
|
289
|
+
className: "caf-btn caf-btn-danger caf-btn-sm caf-item-remove",
|
|
290
|
+
"aria-label": `Remove ${label}`,
|
|
291
|
+
onClick: () => mutate((rec) => { rec.items = rec.items.filter((x) => x.id !== item.id); }),
|
|
292
|
+
},
|
|
293
|
+
"✕"
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
200
300
|
export function RecordsLayout({ spec, ctx }) {
|
|
201
301
|
const rspec = spec.records;
|
|
202
302
|
|