@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/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, onClose) {
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
- document.body.append(pop);
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
- pop.style.top = `${Math.round(top + window.scrollY)}px`;
343
- pop.style.left = `${Math.round(left + window.scrollX)}px`;
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 (enumEntry?.enumValues?.length) {
495
+ if (enumValues?.length) {
486
496
  const wrap = el("div", "exed-menu");
487
- wrap.append(el("div", "exed-menu-head", [`${displayName(enumEntry, ctx.defaultScope)} value`]));
488
- for (const v of enumEntry.enumValues) {
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
- wrap.append(button("exed-opt danger", "Delete", () => {
572
- ctx.apply(deleteAt(ctx.getAst(), path));
573
- close();
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
- if (activePopover === pop) activePopover = null;
1042
- setEditing(false);
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");