@wildwinter/expr-editor 0.3.0 → 0.5.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/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 {
@@ -258,6 +266,25 @@ interface ExpressionEditorOptions {
258
266
  * container inside a focus-trapping dialog (Radix, etc.) so opening a pill's
259
267
  * popover does not dismiss the surrounding dialog. */
260
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;
279
+ /** Flat compact editor for a `set_flags(@target, +a, -b)` value whose target is
280
+ * implied by context (an outcome row's target column). Renders only the
281
+ * flag-delta pills plus an "+ flag" chip, hiding the function name and target
282
+ * arg. `target` is the name-form ref of the flags property (e.g. "@quests");
283
+ * it seeds the call on the first flag add. Pairs with mode:"flat", and set
284
+ * requireNonEmpty so the last flag can't be removed. */
285
+ flagValue?: {
286
+ target: string;
287
+ };
261
288
  /** Emitted on every edit (name-form; "" when cleared). */
262
289
  onChange: (src: string) => void;
263
290
  /** Notified when the author starts (true) / stops (false) editing inside a popover
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 {
@@ -258,6 +266,25 @@ interface ExpressionEditorOptions {
258
266
  * container inside a focus-trapping dialog (Radix, etc.) so opening a pill's
259
267
  * popover does not dismiss the surrounding dialog. */
260
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;
279
+ /** Flat compact editor for a `set_flags(@target, +a, -b)` value whose target is
280
+ * implied by context (an outcome row's target column). Renders only the
281
+ * flag-delta pills plus an "+ flag" chip, hiding the function name and target
282
+ * arg. `target` is the name-form ref of the flags property (e.g. "@quests");
283
+ * it seeds the call on the first flag add. Pairs with mode:"flat", and set
284
+ * requireNonEmpty so the last flag can't be removed. */
285
+ flagValue?: {
286
+ target: string;
287
+ };
261
288
  /** Emitted on every edit (name-form; "" when cleared). */
262
289
  onChange: (src: string) => void;
263
290
  /** Notified when the author starts (true) / stops (false) editing inside a popover
package/dist/index.js CHANGED
@@ -488,11 +488,14 @@ function numberEditor(ctx, path, value, anchor) {
488
488
  function stringEditor(ctx, path, node, anchor) {
489
489
  const peer = findEnumPeer(ctx.getAst(), path);
490
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";
491
494
  ctx.openPopover(anchor, (close) => {
492
- if (enumEntry?.enumValues?.length) {
495
+ if (enumValues?.length) {
493
496
  const wrap = el("div", "exed-menu");
494
- wrap.append(el("div", "exed-menu-head", [`${displayName(enumEntry, ctx.defaultScope)} value`]));
495
- for (const v of enumEntry.enumValues) {
497
+ wrap.append(el("div", "exed-menu-head", [enumHeadLabel]));
498
+ for (const v of enumValues) {
496
499
  wrap.append(button(`exed-opt${v === node.value ? " sel" : ""}`, v, () => {
497
500
  replace(ctx, path, strLit(v));
498
501
  close();
@@ -536,7 +539,9 @@ function flagEditor(ctx, path, node, anchor) {
536
539
  const call = getNodeAt(ctx.getAst(), callPath);
537
540
  const flagsVar = call?.kind === "call" ? call.args[0] : void 0;
538
541
  const entry = flagsVar?.kind === "scopedvar" ? lookup(ctx.catalogue, flagsVar.scope, flagsVar.name) : null;
542
+ const flagCount = call?.kind === "call" ? call.args.filter((a) => a.kind === "flagdelta").length : 0;
539
543
  const used = call?.kind === "call" ? call.args.filter((a, i) => a.kind === "flagdelta" && i !== path[path.length - 1]).map((a) => a.name) : [];
544
+ const canRemove = !(ctx.requireNonEmpty && flagCount <= 1);
540
545
  ctx.openPopover(anchor, (close) => {
541
546
  const wrap = el("div", "exed-menu");
542
547
  wrap.append(el("div", "exed-menu-head", ["Flag set?"]));
@@ -561,9 +566,16 @@ function flagEditor(ctx, path, node, anchor) {
561
566
  close();
562
567
  } }));
563
568
  }
569
+ if (canRemove) {
570
+ wrap.append(button("exed-opt danger", "Remove flag", () => {
571
+ ctx.apply(deleteAt(ctx.getAst(), path));
572
+ close();
573
+ }));
574
+ }
564
575
  return wrap;
565
576
  });
566
577
  }
578
+ var canDelete = (ctx, path) => !(ctx.requireNonEmpty && path.length === 0);
567
579
  function callEditor(ctx, path, node, anchor) {
568
580
  ctx.openPopover(anchor, (close) => {
569
581
  const wrap = el("div", "exed-menu");
@@ -575,10 +587,12 @@ function callEditor(ctx, path, node, anchor) {
575
587
  close();
576
588
  }));
577
589
  }
578
- wrap.append(button("exed-opt danger", "Delete", () => {
579
- ctx.apply(deleteAt(ctx.getAst(), path));
580
- close();
581
- }));
590
+ if (canDelete(ctx, path)) {
591
+ wrap.append(button("exed-opt danger", "Delete", () => {
592
+ ctx.apply(deleteAt(ctx.getAst(), path));
593
+ close();
594
+ }));
595
+ }
582
596
  return wrap;
583
597
  });
584
598
  }
@@ -589,10 +603,10 @@ function variableEditor(ctx, path, node, anchor) {
589
603
  replace(ctx, path, scopedVar(entry.scope, entry.name));
590
604
  close();
591
605
  },
592
- footer: button("exed-opt danger", "Delete", () => {
606
+ ...canDelete(ctx, path) ? { footer: button("exed-opt danger", "Delete", () => {
593
607
  ctx.apply(deleteAt(ctx.getAst(), path));
594
608
  close();
595
- })
609
+ }) } : {}
596
610
  }));
597
611
  }
598
612
  function propertyPicker(ctx, opts) {
@@ -1057,6 +1071,8 @@ function mountExpressionEditor(host, opts) {
1057
1071
  requestFocus: (p) => {
1058
1072
  pendingFocus = p;
1059
1073
  },
1074
+ ...opts.requireNonEmpty ? { requireNonEmpty: true } : {},
1075
+ ...opts.valueEnumValues ? { valueEnumValues: opts.valueEnumValues } : {},
1060
1076
  ...opts.pickNode ? { pickNode: opts.pickNode } : {},
1061
1077
  ...opts.nodeLabel ? { nodeLabel: opts.nodeLabel } : {}
1062
1078
  };
@@ -1065,10 +1081,14 @@ function mountExpressionEditor(host, opts) {
1065
1081
  closePopover();
1066
1082
  host.replaceChildren();
1067
1083
  const v = validateSource(src, opts.schema, opts.dialect);
1068
- if (raw || v.unparseable) {
1084
+ const flagCompact = !!opts.flagValue && (opts.mode ?? "tree") === "flat";
1085
+ const astIsFlagCall = v.ast?.kind === "call" && (v.ast.name === "set_flags" || v.ast.name === "check_flags");
1086
+ if (raw || v.unparseable || flagCompact && !!v.ast && !astIsFlagCall) {
1069
1087
  host.append(rawArea());
1088
+ } else if (flagCompact) {
1089
+ host.append(flagValueBody(astIsFlagCall ? v.ast : null));
1070
1090
  } else if (!src.trim() || !v.ast) {
1071
- host.append(emptyState());
1091
+ host.append(opts.valueField ? valueEmptyState() : emptyState());
1072
1092
  } else {
1073
1093
  const ctx = buildCtx(v.ast);
1074
1094
  const body = el("div", "exed-body");
@@ -1112,6 +1132,40 @@ function mountExpressionEditor(host, opts) {
1112
1132
  }));
1113
1133
  return wrap;
1114
1134
  }
1135
+ function valueEmptyState() {
1136
+ const ctx = buildCtx(strLit(""));
1137
+ return el("div", "exed-flat", [renderNode(strLit(""), [], ctx)]);
1138
+ }
1139
+ function parseRef(ref) {
1140
+ const bare = ref.replace(/^@/, "");
1141
+ const dot = bare.indexOf(".");
1142
+ return dot >= 0 ? scopedVar(bare.slice(0, dot), bare.slice(dot + 1)) : scopedVar(defaultScope, bare);
1143
+ }
1144
+ function flagValueBody(call) {
1145
+ const wrap = el("div", "exed-flat exed-flagvalue");
1146
+ if (call) {
1147
+ const ctx = buildCtx(call);
1148
+ call.args.forEach((arg, i) => {
1149
+ if (arg.kind === "flagdelta") wrap.append(renderNode(arg, ["args", i], ctx));
1150
+ });
1151
+ wrap.append(addFlagChip(call));
1152
+ } else {
1153
+ wrap.append(addFlagChip(null));
1154
+ }
1155
+ return wrap;
1156
+ }
1157
+ function addFlagChip(call) {
1158
+ return button("exed-add exed-addflag", "+ flag", () => {
1159
+ if (call) {
1160
+ pendingFocus = ["args", call.args.length];
1161
+ emit(toSrc({ ...call, args: [...call.args, flagDelta("+", "")] }));
1162
+ } else {
1163
+ const seeded = callNode("set_flags", [parseRef(opts.flagValue.target), flagDelta("+", "")]);
1164
+ pendingFocus = ["args", 1];
1165
+ emit(toSrc(seeded));
1166
+ }
1167
+ }, "add a flag");
1168
+ }
1115
1169
  function addTermControl(ctx) {
1116
1170
  const boolean = opts.addTerm === "boolean";
1117
1171
  const ops = boolean ? ["and", "or"] : ARITHMETIC_OPS;