@wildwinter/expr-editor 0.2.0 → 0.4.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 +11 -0
- package/dist/index.cjs +44 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +44 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -224,6 +224,14 @@ interface EditCtx {
|
|
|
224
224
|
* next render — used by insert-then-refine templates so the author lands
|
|
225
225
|
* straight in the first unfilled slot instead of chasing the error ring. */
|
|
226
226
|
requestFocus?(path: AstPath): void;
|
|
227
|
+
/** When true, a delete that would empty the whole expression is blocked (the
|
|
228
|
+
* Delete affordance is withheld). Used by single-value fields that must
|
|
229
|
+
* always hold at least one term; conditions leave it false (empty = always). */
|
|
230
|
+
requireNonEmpty?: boolean;
|
|
231
|
+
/** Enum values offered when editing the single root literal of a value field
|
|
232
|
+
* (an enum-typed outcome target). Lets the string editor show an enum picker
|
|
233
|
+
* even without a comparison peer. No effect on non-root literals. */
|
|
234
|
+
valueEnumValues?: string[];
|
|
227
235
|
}
|
|
228
236
|
|
|
229
237
|
interface ExpressionEditorOptions {
|
|
@@ -254,6 +262,20 @@ interface ExpressionEditorOptions {
|
|
|
254
262
|
/** Render the editor's own validation message list under the pills (default true).
|
|
255
263
|
* Hosts that display their own validation messages pass false to avoid doubling up. */
|
|
256
264
|
messages?: boolean;
|
|
265
|
+
/** Where to append the popover micro-editors (default document.body). Pass a
|
|
266
|
+
* container inside a focus-trapping dialog (Radix, etc.) so opening a pill's
|
|
267
|
+
* popover does not dismiss the surrounding dialog. */
|
|
268
|
+
popoverContainer?: HTMLElement;
|
|
269
|
+
/** Block a delete that would empty the whole expression (single-value fields
|
|
270
|
+
* that must always hold a term). Conditions leave this off (empty = always). */
|
|
271
|
+
requireNonEmpty?: boolean;
|
|
272
|
+
/** Enum values for the single root literal of a value field (an enum-typed
|
|
273
|
+
* target): the literal editor then offers them as a picker. Flat mode only. */
|
|
274
|
+
valueEnumValues?: string[];
|
|
275
|
+
/** Flat single-value field: an empty value renders one editable "set a value…"
|
|
276
|
+
* placeholder pill (a root string literal) rather than the condition
|
|
277
|
+
* clause-menu empty state. Pairs with mode:"flat". */
|
|
278
|
+
valueField?: boolean;
|
|
257
279
|
/** Emitted on every edit (name-form; "" when cleared). */
|
|
258
280
|
onChange: (src: string) => void;
|
|
259
281
|
/** Notified when the author starts (true) / stops (false) editing inside a popover
|
|
@@ -296,6 +318,10 @@ interface EffectsEditorOptions {
|
|
|
296
318
|
allowEmit?: boolean;
|
|
297
319
|
/** Start each inline value editor in raw-text mode (host's global "show as text" toggle drives this). */
|
|
298
320
|
text?: boolean;
|
|
321
|
+
/** Where to append popover micro-editors (default document.body). Pass a container
|
|
322
|
+
* inside a focus-trapping dialog so popovers do not dismiss it. Threaded to the
|
|
323
|
+
* target/event pickers here and to every inline value editor. */
|
|
324
|
+
popoverContainer?: HTMLElement;
|
|
299
325
|
/** Emitted on every structural / value edit with the whole new list. */
|
|
300
326
|
onChange: (effects: EditorEffect[]) => void;
|
|
301
327
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -224,6 +224,14 @@ interface EditCtx {
|
|
|
224
224
|
* next render — used by insert-then-refine templates so the author lands
|
|
225
225
|
* straight in the first unfilled slot instead of chasing the error ring. */
|
|
226
226
|
requestFocus?(path: AstPath): void;
|
|
227
|
+
/** When true, a delete that would empty the whole expression is blocked (the
|
|
228
|
+
* Delete affordance is withheld). Used by single-value fields that must
|
|
229
|
+
* always hold at least one term; conditions leave it false (empty = always). */
|
|
230
|
+
requireNonEmpty?: boolean;
|
|
231
|
+
/** Enum values offered when editing the single root literal of a value field
|
|
232
|
+
* (an enum-typed outcome target). Lets the string editor show an enum picker
|
|
233
|
+
* even without a comparison peer. No effect on non-root literals. */
|
|
234
|
+
valueEnumValues?: string[];
|
|
227
235
|
}
|
|
228
236
|
|
|
229
237
|
interface ExpressionEditorOptions {
|
|
@@ -254,6 +262,20 @@ interface ExpressionEditorOptions {
|
|
|
254
262
|
/** Render the editor's own validation message list under the pills (default true).
|
|
255
263
|
* Hosts that display their own validation messages pass false to avoid doubling up. */
|
|
256
264
|
messages?: boolean;
|
|
265
|
+
/** Where to append the popover micro-editors (default document.body). Pass a
|
|
266
|
+
* container inside a focus-trapping dialog (Radix, etc.) so opening a pill's
|
|
267
|
+
* popover does not dismiss the surrounding dialog. */
|
|
268
|
+
popoverContainer?: HTMLElement;
|
|
269
|
+
/** Block a delete that would empty the whole expression (single-value fields
|
|
270
|
+
* that must always hold a term). Conditions leave this off (empty = always). */
|
|
271
|
+
requireNonEmpty?: boolean;
|
|
272
|
+
/** Enum values for the single root literal of a value field (an enum-typed
|
|
273
|
+
* target): the literal editor then offers them as a picker. Flat mode only. */
|
|
274
|
+
valueEnumValues?: string[];
|
|
275
|
+
/** Flat single-value field: an empty value renders one editable "set a value…"
|
|
276
|
+
* placeholder pill (a root string literal) rather than the condition
|
|
277
|
+
* clause-menu empty state. Pairs with mode:"flat". */
|
|
278
|
+
valueField?: boolean;
|
|
257
279
|
/** Emitted on every edit (name-form; "" when cleared). */
|
|
258
280
|
onChange: (src: string) => void;
|
|
259
281
|
/** Notified when the author starts (true) / stops (false) editing inside a popover
|
|
@@ -296,6 +318,10 @@ interface EffectsEditorOptions {
|
|
|
296
318
|
allowEmit?: boolean;
|
|
297
319
|
/** Start each inline value editor in raw-text mode (host's global "show as text" toggle drives this). */
|
|
298
320
|
text?: boolean;
|
|
321
|
+
/** Where to append popover micro-editors (default document.body). Pass a container
|
|
322
|
+
* inside a focus-trapping dialog so popovers do not dismiss it. Threaded to the
|
|
323
|
+
* target/event pickers here and to every inline value editor. */
|
|
324
|
+
popoverContainer?: HTMLElement;
|
|
299
325
|
/** Emitted on every structural / value edit with the whole new list. */
|
|
300
326
|
onChange: (effects: EditorEffect[]) => void;
|
|
301
327
|
}
|
package/dist/index.js
CHANGED
|
@@ -310,7 +310,8 @@ function button(cls, label, onClick, title) {
|
|
|
310
310
|
b.addEventListener("click", onClick);
|
|
311
311
|
return b;
|
|
312
312
|
}
|
|
313
|
-
function openPopover(anchor, render,
|
|
313
|
+
function openPopover(anchor, render, opts = {}) {
|
|
314
|
+
const container = opts.container ?? document.body;
|
|
314
315
|
const pop = el("div", "exed-pop");
|
|
315
316
|
let closed = false;
|
|
316
317
|
const close = () => {
|
|
@@ -319,7 +320,7 @@ function openPopover(anchor, render, onClose) {
|
|
|
319
320
|
document.removeEventListener("pointerdown", onDown, true);
|
|
320
321
|
document.removeEventListener("keydown", onKey, true);
|
|
321
322
|
pop.remove();
|
|
322
|
-
onClose?.();
|
|
323
|
+
opts.onClose?.();
|
|
323
324
|
};
|
|
324
325
|
const onDown = (e) => {
|
|
325
326
|
const t = e.target;
|
|
@@ -332,15 +333,21 @@ function openPopover(anchor, render, onClose) {
|
|
|
332
333
|
}
|
|
333
334
|
};
|
|
334
335
|
pop.append(render(close));
|
|
335
|
-
|
|
336
|
+
container.append(pop);
|
|
336
337
|
const a = anchor.getBoundingClientRect();
|
|
337
338
|
const ph = pop.getBoundingClientRect();
|
|
338
339
|
let top = a.bottom + 4;
|
|
339
340
|
if (top + ph.height > window.innerHeight - 8 && a.top - ph.height - 4 > 8) top = a.top - ph.height - 4;
|
|
340
341
|
let left = a.left;
|
|
341
342
|
if (left + ph.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - 8 - ph.width);
|
|
342
|
-
|
|
343
|
-
|
|
343
|
+
if (container === document.body) {
|
|
344
|
+
pop.style.top = `${Math.round(top + window.scrollY)}px`;
|
|
345
|
+
pop.style.left = `${Math.round(left + window.scrollX)}px`;
|
|
346
|
+
} else {
|
|
347
|
+
const cr = container.getBoundingClientRect();
|
|
348
|
+
pop.style.top = `${Math.round(top - cr.top + container.scrollTop)}px`;
|
|
349
|
+
pop.style.left = `${Math.round(left - cr.left + container.scrollLeft)}px`;
|
|
350
|
+
}
|
|
344
351
|
setTimeout(() => {
|
|
345
352
|
document.addEventListener("pointerdown", onDown, true);
|
|
346
353
|
document.addEventListener("keydown", onKey, true);
|
|
@@ -481,11 +488,14 @@ function numberEditor(ctx, path, value, anchor) {
|
|
|
481
488
|
function stringEditor(ctx, path, node, anchor) {
|
|
482
489
|
const peer = findEnumPeer(ctx.getAst(), path);
|
|
483
490
|
const enumEntry = peer ? lookup(ctx.catalogue, peer.scope, peer.name) : null;
|
|
491
|
+
const rootValueEnums = path.length === 0 && ctx.valueEnumValues?.length ? ctx.valueEnumValues : null;
|
|
492
|
+
const enumValues = enumEntry?.enumValues?.length ? enumEntry.enumValues : rootValueEnums;
|
|
493
|
+
const enumHeadLabel = enumEntry ? `${displayName(enumEntry, ctx.defaultScope)} value` : "Value";
|
|
484
494
|
ctx.openPopover(anchor, (close) => {
|
|
485
|
-
if (
|
|
495
|
+
if (enumValues?.length) {
|
|
486
496
|
const wrap = el("div", "exed-menu");
|
|
487
|
-
wrap.append(el("div", "exed-menu-head", [
|
|
488
|
-
for (const v of
|
|
497
|
+
wrap.append(el("div", "exed-menu-head", [enumHeadLabel]));
|
|
498
|
+
for (const v of enumValues) {
|
|
489
499
|
wrap.append(button(`exed-opt${v === node.value ? " sel" : ""}`, v, () => {
|
|
490
500
|
replace(ctx, path, strLit(v));
|
|
491
501
|
close();
|
|
@@ -557,6 +567,7 @@ function flagEditor(ctx, path, node, anchor) {
|
|
|
557
567
|
return wrap;
|
|
558
568
|
});
|
|
559
569
|
}
|
|
570
|
+
var canDelete = (ctx, path) => !(ctx.requireNonEmpty && path.length === 0);
|
|
560
571
|
function callEditor(ctx, path, node, anchor) {
|
|
561
572
|
ctx.openPopover(anchor, (close) => {
|
|
562
573
|
const wrap = el("div", "exed-menu");
|
|
@@ -568,10 +579,12 @@ function callEditor(ctx, path, node, anchor) {
|
|
|
568
579
|
close();
|
|
569
580
|
}));
|
|
570
581
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
582
|
+
if (canDelete(ctx, path)) {
|
|
583
|
+
wrap.append(button("exed-opt danger", "Delete", () => {
|
|
584
|
+
ctx.apply(deleteAt(ctx.getAst(), path));
|
|
585
|
+
close();
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
575
588
|
return wrap;
|
|
576
589
|
});
|
|
577
590
|
}
|
|
@@ -582,10 +595,10 @@ function variableEditor(ctx, path, node, anchor) {
|
|
|
582
595
|
replace(ctx, path, scopedVar(entry.scope, entry.name));
|
|
583
596
|
close();
|
|
584
597
|
},
|
|
585
|
-
footer: button("exed-opt danger", "Delete", () => {
|
|
598
|
+
...canDelete(ctx, path) ? { footer: button("exed-opt danger", "Delete", () => {
|
|
586
599
|
ctx.apply(deleteAt(ctx.getAst(), path));
|
|
587
600
|
close();
|
|
588
|
-
})
|
|
601
|
+
}) } : {}
|
|
589
602
|
}));
|
|
590
603
|
}
|
|
591
604
|
function propertyPicker(ctx, opts) {
|
|
@@ -1037,9 +1050,12 @@ function mountExpressionEditor(host, opts) {
|
|
|
1037
1050
|
apply: (next) => emit(toSrc(next)),
|
|
1038
1051
|
openPopover: (anchor, r) => {
|
|
1039
1052
|
closePopover();
|
|
1040
|
-
const pop = openPopover(anchor, r,
|
|
1041
|
-
|
|
1042
|
-
|
|
1053
|
+
const pop = openPopover(anchor, r, {
|
|
1054
|
+
container: opts.popoverContainer,
|
|
1055
|
+
onClose: () => {
|
|
1056
|
+
if (activePopover === pop) activePopover = null;
|
|
1057
|
+
setEditing(false);
|
|
1058
|
+
}
|
|
1043
1059
|
});
|
|
1044
1060
|
activePopover = pop;
|
|
1045
1061
|
setEditing(true);
|
|
@@ -1047,6 +1063,8 @@ function mountExpressionEditor(host, opts) {
|
|
|
1047
1063
|
requestFocus: (p) => {
|
|
1048
1064
|
pendingFocus = p;
|
|
1049
1065
|
},
|
|
1066
|
+
...opts.requireNonEmpty ? { requireNonEmpty: true } : {},
|
|
1067
|
+
...opts.valueEnumValues ? { valueEnumValues: opts.valueEnumValues } : {},
|
|
1050
1068
|
...opts.pickNode ? { pickNode: opts.pickNode } : {},
|
|
1051
1069
|
...opts.nodeLabel ? { nodeLabel: opts.nodeLabel } : {}
|
|
1052
1070
|
};
|
|
@@ -1058,7 +1076,7 @@ function mountExpressionEditor(host, opts) {
|
|
|
1058
1076
|
if (raw || v.unparseable) {
|
|
1059
1077
|
host.append(rawArea());
|
|
1060
1078
|
} else if (!src.trim() || !v.ast) {
|
|
1061
|
-
host.append(emptyState());
|
|
1079
|
+
host.append(opts.valueField ? valueEmptyState() : emptyState());
|
|
1062
1080
|
} else {
|
|
1063
1081
|
const ctx = buildCtx(v.ast);
|
|
1064
1082
|
const body = el("div", "exed-body");
|
|
@@ -1102,6 +1120,10 @@ function mountExpressionEditor(host, opts) {
|
|
|
1102
1120
|
}));
|
|
1103
1121
|
return wrap;
|
|
1104
1122
|
}
|
|
1123
|
+
function valueEmptyState() {
|
|
1124
|
+
const ctx = buildCtx(strLit(""));
|
|
1125
|
+
return el("div", "exed-flat", [renderNode(strLit(""), [], ctx)]);
|
|
1126
|
+
}
|
|
1105
1127
|
function addTermControl(ctx) {
|
|
1106
1128
|
const boolean = opts.addTerm === "boolean";
|
|
1107
1129
|
const ops = boolean ? ["and", "or"] : ARITHMETIC_OPS;
|
|
@@ -1331,7 +1353,7 @@ function mountEffectsEditor(host, opts) {
|
|
|
1331
1353
|
close();
|
|
1332
1354
|
},
|
|
1333
1355
|
onCancel: close
|
|
1334
|
-
}));
|
|
1356
|
+
}), { container: opts.popoverContainer });
|
|
1335
1357
|
};
|
|
1336
1358
|
const targetType = (target) => opts.catalogue.find((e) => refOf(e, defaultScope) === target)?.type;
|
|
1337
1359
|
const valueDisplay = (value, onChange, type) => {
|
|
@@ -1346,6 +1368,7 @@ function mountEffectsEditor(host, opts) {
|
|
|
1346
1368
|
functions: opts.functions,
|
|
1347
1369
|
mode: "flat",
|
|
1348
1370
|
...addTerm ? { addTerm } : {},
|
|
1371
|
+
...opts.popoverContainer ? { popoverContainer: opts.popoverContainer } : {},
|
|
1349
1372
|
text: textMode,
|
|
1350
1373
|
onChange
|
|
1351
1374
|
}));
|
|
@@ -1385,7 +1408,7 @@ function mountEffectsEditor(host, opts) {
|
|
|
1385
1408
|
wrap.append(search, list);
|
|
1386
1409
|
setTimeout(() => search.focus(), 0);
|
|
1387
1410
|
return wrap;
|
|
1388
|
-
});
|
|
1411
|
+
}, { container: opts.popoverContainer });
|
|
1389
1412
|
};
|
|
1390
1413
|
const iconBtn = (glyph, title, onClick, danger = false) => button(`exed-eff-icon${danger ? " danger" : ""}`, glyph, onClick, title);
|
|
1391
1414
|
function setRow(eff, i) {
|
|
@@ -1458,7 +1481,7 @@ function mountEffectsEditor(host, opts) {
|
|
|
1458
1481
|
wrap.append(button("exed-btn primary", "Apply", apply));
|
|
1459
1482
|
setTimeout(() => input.focus(), 0);
|
|
1460
1483
|
return wrap;
|
|
1461
|
-
});
|
|
1484
|
+
}, { container: opts.popoverContainer });
|
|
1462
1485
|
};
|
|
1463
1486
|
const rowActions2 = (i) => {
|
|
1464
1487
|
const acts = el("div", "exed-effect-acts");
|