@jxsuite/studio 0.22.1 → 0.23.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.
@@ -0,0 +1,528 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { live } from "lit-html/directives/live.js";
3
+ import { renderFieldRow } from "./field-row.js";
4
+
5
+ // ─── Operator Categories ────────────────────────────────────────────────────
6
+
7
+ const UNARY_OPS = new Set(["!", "-"]);
8
+ const BINARY_OPS = new Set([
9
+ "+",
10
+ "-",
11
+ "*",
12
+ "/",
13
+ "%",
14
+ "===",
15
+ "!==",
16
+ "<",
17
+ "<=",
18
+ ">",
19
+ ">=",
20
+ "&&",
21
+ "||",
22
+ ]);
23
+ const ASSIGN_OPS = new Set(["=", "+=", "-=", "*=", "/="]);
24
+ const NO_ARG_OPS = new Set(["pop", "shift"]);
25
+ const ONE_ARG_OPS = new Set(["push", "unshift"]);
26
+
27
+ const OPERATOR_GROUPS = [
28
+ { label: "Assignment", ops: ["=", "+=", "-=", "*=", "/="] },
29
+ { label: "Unary", ops: ["!", "-"] },
30
+ { label: "Arithmetic", ops: ["+", "-", "*", "/", "%"] },
31
+ { label: "Comparison", ops: ["===", "!==", "<", "<=", ">", ">="] },
32
+ { label: "Logical", ops: ["&&", "||"] },
33
+ { label: "Array methods", ops: ["push", "pop", "shift", "unshift", "splice"] },
34
+ { label: "Aggregate", ops: ["reduce", "map", "filter"] },
35
+ ];
36
+
37
+ /**
38
+ * @param {string} op
39
+ * @returns {{
40
+ * needsValue: boolean;
41
+ * needsInitial: boolean;
42
+ * targetMustBeRef: boolean;
43
+ * spliceArray: boolean;
44
+ * valueIsNode: boolean;
45
+ * }}
46
+ */
47
+ function operatorInfo(op) {
48
+ if (UNARY_OPS.has(op))
49
+ return {
50
+ needsValue: false,
51
+ needsInitial: false,
52
+ targetMustBeRef: false,
53
+ spliceArray: false,
54
+ valueIsNode: false,
55
+ };
56
+ if (BINARY_OPS.has(op))
57
+ return {
58
+ needsValue: true,
59
+ needsInitial: false,
60
+ targetMustBeRef: false,
61
+ spliceArray: false,
62
+ valueIsNode: false,
63
+ };
64
+ if (ASSIGN_OPS.has(op))
65
+ return {
66
+ needsValue: true,
67
+ needsInitial: false,
68
+ targetMustBeRef: true,
69
+ spliceArray: false,
70
+ valueIsNode: false,
71
+ };
72
+ if (NO_ARG_OPS.has(op))
73
+ return {
74
+ needsValue: false,
75
+ needsInitial: false,
76
+ targetMustBeRef: true,
77
+ spliceArray: false,
78
+ valueIsNode: false,
79
+ };
80
+ if (ONE_ARG_OPS.has(op))
81
+ return {
82
+ needsValue: true,
83
+ needsInitial: false,
84
+ targetMustBeRef: true,
85
+ spliceArray: false,
86
+ valueIsNode: false,
87
+ };
88
+ if (op === "splice")
89
+ return {
90
+ needsValue: true,
91
+ needsInitial: false,
92
+ targetMustBeRef: true,
93
+ spliceArray: true,
94
+ valueIsNode: false,
95
+ };
96
+ if (op === "reduce")
97
+ return {
98
+ needsValue: true,
99
+ needsInitial: true,
100
+ targetMustBeRef: true,
101
+ spliceArray: false,
102
+ valueIsNode: true,
103
+ };
104
+ if (op === "map" || op === "filter")
105
+ return {
106
+ needsValue: true,
107
+ needsInitial: false,
108
+ targetMustBeRef: true,
109
+ spliceArray: false,
110
+ valueIsNode: true,
111
+ };
112
+ return {
113
+ needsValue: false,
114
+ needsInitial: false,
115
+ targetMustBeRef: false,
116
+ spliceArray: false,
117
+ valueIsNode: false,
118
+ };
119
+ }
120
+
121
+ // ─── Operand Mode Detection ─────────────────────────────────────────────────
122
+
123
+ /**
124
+ * @param {any} operand
125
+ * @returns {"ref" | "expression" | "literal"}
126
+ */
127
+ function operandMode(operand) {
128
+ if (operand && typeof operand === "object") {
129
+ if ("$ref" in operand) return "ref";
130
+ if ("operator" in operand) return "expression";
131
+ }
132
+ return "literal";
133
+ }
134
+
135
+ /**
136
+ * @param {string} mode
137
+ * @returns {any}
138
+ */
139
+ function defaultForMode(mode) {
140
+ if (mode === "ref") return { $ref: "" };
141
+ if (mode === "expression") return { operator: "!", target: null };
142
+ return null;
143
+ }
144
+
145
+ // ─── Literal Type Detection ─────────────────────────────────────────────────
146
+
147
+ /**
148
+ * @param {any} val
149
+ * @returns {"string" | "number" | "boolean" | "null"}
150
+ */
151
+ function literalType(val) {
152
+ if (val === null || val === undefined) return "null";
153
+ if (typeof val === "boolean") return "boolean";
154
+ if (typeof val === "number") return "number";
155
+ return "string";
156
+ }
157
+
158
+ /**
159
+ * @param {string} type
160
+ * @returns {any}
161
+ */
162
+ function defaultForLiteralType(type) {
163
+ if (type === "number") return 0;
164
+ if (type === "boolean") return false;
165
+ if (type === "null") return null;
166
+ return "";
167
+ }
168
+
169
+ // ─── Hint (one-line summary for signal rows) ────────────────────────────────
170
+
171
+ /**
172
+ * @param {any} node
173
+ * @returns {string}
174
+ */
175
+ export function expressionHint(node) {
176
+ if (!node || !node.operator) return "$expression";
177
+ const op = node.operator;
178
+ const targetLabel = node.target?.$ref
179
+ ? node.target.$ref.replace("#/state/", "")
180
+ : node.target?.operator
181
+ ? `(${node.target.operator}…)`
182
+ : String(node.target ?? "?");
183
+
184
+ if (ASSIGN_OPS.has(op) || ONE_ARG_OPS.has(op)) return `${op} ${targetLabel}`;
185
+ if (NO_ARG_OPS.has(op)) return `${op}(${targetLabel})`;
186
+ if (op === "splice") return `splice(${targetLabel})`;
187
+ if (op === "reduce" || op === "map" || op === "filter") return `${op}(${targetLabel})`;
188
+ if (UNARY_OPS.has(op)) return `${op}${targetLabel}`;
189
+ return `${targetLabel} ${op} …`;
190
+ }
191
+
192
+ // ─── Ref Picker ─────────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * @param {string} refVal
196
+ * @param {(newRef: string) => void} onRefChange
197
+ * @param {{ stateDefs: string[]; allowEventRef: boolean }} opts
198
+ * @returns {import("lit-html").TemplateResult}
199
+ */
200
+ function renderRefPicker(refVal, onRefChange, opts) {
201
+ const stateRefs = (opts.stateDefs || []).map((k) => `#/state/${k}`);
202
+ const eventRefs = opts.allowEventRef ? ["event#/detail", "event#/target/value"] : [];
203
+ const allRefs = [...stateRefs, ...eventRefs];
204
+ const isCustom = refVal && !allRefs.includes(refVal);
205
+
206
+ return html`
207
+ <sp-picker
208
+ size="s"
209
+ style="flex:1"
210
+ placeholder="Select…"
211
+ .value=${live(isCustom ? "__custom__" : refVal || "")}
212
+ @change=${(/** @type {Event} */ e) => {
213
+ const val = /** @type {HTMLInputElement} */ (e.target).value;
214
+ if (val === "__custom__") return;
215
+ onRefChange(val);
216
+ }}
217
+ >
218
+ ${stateRefs.length > 0
219
+ ? stateRefs.map(
220
+ (r) => html`<sp-menu-item value=${r}>${r.replace("#/state/", "")}</sp-menu-item>`,
221
+ )
222
+ : html`<sp-menu-item disabled>No state defined</sp-menu-item>`}
223
+ ${eventRefs.length > 0
224
+ ? html`
225
+ <sp-menu-divider></sp-menu-divider>
226
+ ${eventRefs.map((r) => html`<sp-menu-item value=${r}>${r}</sp-menu-item>`)}
227
+ `
228
+ : nothing}
229
+ </sp-picker>
230
+ `;
231
+ }
232
+
233
+ // ─── Literal Editor ─────────────────────────────────────────────────────────
234
+
235
+ /**
236
+ * @param {any} operand
237
+ * @param {(newVal: any) => void} onChange
238
+ * @returns {import("lit-html").TemplateResult}
239
+ */
240
+ function renderLiteralEditor(operand, onChange) {
241
+ const type = literalType(operand);
242
+ return html`
243
+ <div style="display:flex;gap:4px;align-items:center;flex:1">
244
+ <sp-picker
245
+ size="s"
246
+ style="min-width:56px"
247
+ .value=${live(type)}
248
+ @change=${(/** @type {Event} */ e) => {
249
+ const newType = /** @type {HTMLInputElement} */ (e.target).value;
250
+ onChange(defaultForLiteralType(newType));
251
+ }}
252
+ >
253
+ <sp-menu-item value="string">str</sp-menu-item>
254
+ <sp-menu-item value="number">num</sp-menu-item>
255
+ <sp-menu-item value="boolean">bool</sp-menu-item>
256
+ <sp-menu-item value="null">null</sp-menu-item>
257
+ </sp-picker>
258
+ ${type === "string"
259
+ ? html`<sp-textfield
260
+ size="s"
261
+ style="flex:1"
262
+ .value=${live(String(operand ?? ""))}
263
+ @input=${(/** @type {Event} */ e) =>
264
+ onChange(/** @type {HTMLInputElement} */ (e.target).value)}
265
+ ></sp-textfield>`
266
+ : type === "number"
267
+ ? html`<sp-number-field
268
+ size="s"
269
+ style="flex:1"
270
+ .value=${live(Number(operand ?? 0))}
271
+ @change=${(/** @type {Event} */ e) =>
272
+ onChange(Number(/** @type {HTMLInputElement} */ (e.target).value))}
273
+ ></sp-number-field>`
274
+ : type === "boolean"
275
+ ? html`<sp-checkbox
276
+ size="s"
277
+ ?checked=${!!operand}
278
+ @change=${(/** @type {Event} */ e) =>
279
+ onChange(/** @type {HTMLInputElement} */ (e.target).checked)}
280
+ >true</sp-checkbox
281
+ >`
282
+ : html`<span style="font-size:12px;color:var(--spectrum-global-color-gray-600)"
283
+ >null</span
284
+ >`}
285
+ </div>
286
+ `;
287
+ }
288
+
289
+ // ─── Operand Editor ─────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * @param {any} operand
293
+ * @param {(newOperand: any) => void} onChange
294
+ * @param {{
295
+ * stateDefs: string[];
296
+ * allowEventRef: boolean;
297
+ * depth: number;
298
+ * mustBeRef?: boolean;
299
+ * }} opts
300
+ * @returns {import("lit-html").TemplateResult}
301
+ */
302
+ function renderOperandEditor(operand, onChange, opts) {
303
+ if (opts.mustBeRef) {
304
+ const refVal = operand?.$ref ?? "";
305
+ return html`
306
+ <div style="flex:1">${renderRefPicker(refVal, (r) => onChange({ $ref: r }), opts)}</div>
307
+ `;
308
+ }
309
+
310
+ const mode = operandMode(operand);
311
+ return html`
312
+ <div style="display:flex;gap:4px;align-items:flex-start;flex:1">
313
+ <sp-picker
314
+ size="s"
315
+ style="min-width:60px"
316
+ .value=${live(mode)}
317
+ @change=${(/** @type {Event} */ e) => {
318
+ const newMode = /** @type {HTMLInputElement} */ (e.target).value;
319
+ onChange(defaultForMode(newMode));
320
+ }}
321
+ >
322
+ <sp-menu-item value="literal">lit</sp-menu-item>
323
+ <sp-menu-item value="ref">$ref</sp-menu-item>
324
+ <sp-menu-item value="expression">expr</sp-menu-item>
325
+ </sp-picker>
326
+ ${mode === "literal"
327
+ ? renderLiteralEditor(operand, onChange)
328
+ : mode === "ref"
329
+ ? renderRefPicker(operand?.$ref ?? "", (r) => onChange({ $ref: r }), opts)
330
+ : renderExpressionEditor(operand, onChange, { ...opts, depth: opts.depth + 1 })}
331
+ </div>
332
+ `;
333
+ }
334
+
335
+ // ─── Splice Args Editor ─────────────────────────────────────────────────────
336
+
337
+ /**
338
+ * @param {any[]} args
339
+ * @param {(newArgs: any[]) => void} onChange
340
+ * @param {{ stateDefs: string[]; allowEventRef: boolean; depth: number }} opts
341
+ * @returns {import("lit-html").TemplateResult}
342
+ */
343
+ function renderSpliceArgsEditor(args, onChange, opts) {
344
+ const safeArgs = Array.isArray(args) ? args : [];
345
+ const labels = ["start", "del", "item"];
346
+
347
+ return html`
348
+ <div class="array-object-field">
349
+ ${safeArgs.map(
350
+ (arg, idx) => html`
351
+ <div
352
+ class="array-object-row"
353
+ style="display:flex;gap:4px;align-items:center;margin-bottom:4px"
354
+ >
355
+ <span style="font-size:10px;color:var(--spectrum-global-color-gray-600);min-width:30px">
356
+ ${labels[idx] ?? "item"}
357
+ </span>
358
+ ${renderOperandEditor(
359
+ arg,
360
+ (newArg) => {
361
+ const updated = [...safeArgs];
362
+ updated[idx] = newArg;
363
+ onChange(updated);
364
+ },
365
+ { ...opts, mustBeRef: false },
366
+ )}
367
+ <sp-action-button
368
+ quiet
369
+ size="xs"
370
+ @click=${() => {
371
+ const updated = safeArgs.filter((_, i) => i !== idx);
372
+ onChange(updated.length > 0 ? updated : [null]);
373
+ }}
374
+ >
375
+ <sp-icon-delete slot="icon"></sp-icon-delete>
376
+ </sp-action-button>
377
+ </div>
378
+ `,
379
+ )}
380
+ <sp-action-button quiet size="s" @click=${() => onChange([...safeArgs, null])}>
381
+ + Add arg
382
+ </sp-action-button>
383
+ </div>
384
+ `;
385
+ }
386
+
387
+ // ─── Operator Picker Menu ───────────────────────────────────────────────────
388
+
389
+ const _operatorMenuCache = OPERATOR_GROUPS.map(
390
+ (group, i) => html`
391
+ ${i > 0 ? html`<sp-menu-divider></sp-menu-divider>` : nothing}
392
+ <sp-menu-group>
393
+ <span slot="header">${group.label}</span>
394
+ ${group.ops.map((op) => html`<sp-menu-item value=${op}>${op}</sp-menu-item>`)}
395
+ </sp-menu-group>
396
+ `,
397
+ );
398
+
399
+ // ─── Main Expression Editor ─────────────────────────────────────────────────
400
+
401
+ /**
402
+ * @param {any} node
403
+ * @param {(node: any) => void} onChange
404
+ * @param {{
405
+ * stateDefs: string[];
406
+ * allowEventRef: boolean;
407
+ * depth?: number;
408
+ * }} opts
409
+ * @returns {import("lit-html").TemplateResult}
410
+ */
411
+ export function renderExpressionEditor(node, onChange, opts) {
412
+ const depth = opts.depth ?? 0;
413
+ const safeNode = node && typeof node === "object" ? node : { operator: "=", target: null };
414
+ const op = safeNode.operator || "=";
415
+ const info = operatorInfo(op);
416
+
417
+ const nestStyle =
418
+ depth > 0
419
+ ? "border-left:2px solid var(--spectrum-global-color-gray-300);margin-left:8px;padding-left:8px;"
420
+ : "";
421
+
422
+ return html`
423
+ <div class="expression-editor" style=${nestStyle}>
424
+ ${renderFieldRow({
425
+ prop: "operator",
426
+ label: "Operator",
427
+ hasValue: false,
428
+ widget: html`
429
+ <sp-picker
430
+ size="s"
431
+ .value=${live(op)}
432
+ @change=${(/** @type {Event} */ e) => {
433
+ const newOp = /** @type {HTMLInputElement} */ (e.target).value;
434
+ const newInfo = operatorInfo(newOp);
435
+ /** @type {any} */
436
+ const updated = { operator: newOp, target: safeNode.target };
437
+ if (newInfo.targetMustBeRef && operandMode(safeNode.target) !== "ref") {
438
+ updated.target = { $ref: "" };
439
+ }
440
+ if (newInfo.needsValue) {
441
+ if (newInfo.valueIsNode) {
442
+ updated.value = safeNode.value?.operator
443
+ ? safeNode.value
444
+ : { operator: "!", target: null };
445
+ } else if (newInfo.spliceArray) {
446
+ updated.value = Array.isArray(safeNode.value) ? safeNode.value : [null];
447
+ } else {
448
+ updated.value = safeNode.value ?? null;
449
+ }
450
+ }
451
+ if (newInfo.needsInitial) updated.initial = safeNode.initial ?? 0;
452
+ onChange(updated);
453
+ }}
454
+ >
455
+ ${_operatorMenuCache}
456
+ </sp-picker>
457
+ `,
458
+ })}
459
+ ${renderFieldRow({
460
+ prop: "target",
461
+ label: "Target",
462
+ hasValue: false,
463
+ widget: renderOperandEditor(safeNode.target, (t) => onChange({ ...safeNode, target: t }), {
464
+ ...opts,
465
+ depth,
466
+ mustBeRef: info.targetMustBeRef,
467
+ }),
468
+ })}
469
+ ${info.needsValue && !info.valueIsNode && !info.spliceArray
470
+ ? renderFieldRow({
471
+ prop: "value",
472
+ label: "Value",
473
+ hasValue: false,
474
+ widget: renderOperandEditor(
475
+ safeNode.value,
476
+ (v) => onChange({ ...safeNode, value: v }),
477
+ { ...opts, depth, mustBeRef: false },
478
+ ),
479
+ })
480
+ : nothing}
481
+ ${info.needsValue && info.valueIsNode
482
+ ? html`
483
+ <div style="margin-top:4px">
484
+ ${renderFieldRow({
485
+ prop: "value",
486
+ label: "Per-item",
487
+ hasValue: false,
488
+ widget: nothing,
489
+ })}
490
+ ${renderExpressionEditor(
491
+ safeNode.value?.operator ? safeNode.value : { operator: "!", target: null },
492
+ (v) => onChange({ ...safeNode, value: v }),
493
+ { ...opts, depth: depth + 1 },
494
+ )}
495
+ </div>
496
+ `
497
+ : nothing}
498
+ ${info.spliceArray
499
+ ? html`
500
+ <div style="margin-top:4px">
501
+ ${renderFieldRow({
502
+ prop: "value",
503
+ label: "Args",
504
+ hasValue: false,
505
+ widget: nothing,
506
+ })}
507
+ ${renderSpliceArgsEditor(safeNode.value, (v) => onChange({ ...safeNode, value: v }), {
508
+ ...opts,
509
+ depth,
510
+ })}
511
+ </div>
512
+ `
513
+ : nothing}
514
+ ${info.needsInitial
515
+ ? renderFieldRow({
516
+ prop: "initial",
517
+ label: "Initial",
518
+ hasValue: false,
519
+ widget: renderOperandEditor(
520
+ safeNode.initial,
521
+ (v) => onChange({ ...safeNode, initial: v }),
522
+ { ...opts, depth, mustBeRef: false },
523
+ ),
524
+ })
525
+ : nothing}
526
+ </div>
527
+ `;
528
+ }