@wildwinter/expr-editor 0.7.0 → 0.9.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
@@ -214,6 +214,10 @@ interface EditCtx {
214
214
  apply(next: ExprNode | null): void;
215
215
  /** Open a popover anchored to `anchor`; `render(close)` builds the content. */
216
216
  openPopover(anchor: HTMLElement, render: (close: () => void) => Node): void;
217
+ /** Opener for the property right-click menu; falls back to openPopover. The
218
+ * read-only preview sets this (a real popover) while leaving openPopover a
219
+ * no-op, so property pills get their menu but left-click editing stays inert. */
220
+ openMenu?(anchor: HTMLElement, render: (close: () => void) => Node): void;
217
221
  /** Host-provided picker for a flow-node reference arg (e.g. `seen(...)` / `visits(...)`). When set,
218
222
  * the node-ref arg renders as a pill that opens this instead of a free-text field; `onPick` receives
219
223
  * the chosen node id. Absent in dialects / hosts that have no node catalogue. */
@@ -232,6 +236,16 @@ interface EditCtx {
232
236
  * (an enum-typed outcome target). Lets the string editor show an enum picker
233
237
  * even without a comparison peer. No effect on non-root literals. */
234
238
  valueEnumValues?: string[];
239
+ /** Host actions for a property pill (e.g. "Go to definition"). When set,
240
+ * right-clicking a property pill opens a menu of these actions. The host
241
+ * resolves what each does (navigation etc.); empty array = no menu. */
242
+ propertyActions?(ref: {
243
+ scope: string;
244
+ name: string;
245
+ }): Array<{
246
+ label: string;
247
+ run: () => void;
248
+ }>;
235
249
  }
236
250
 
237
251
  interface ExpressionEditorOptions {
@@ -286,6 +300,18 @@ interface ExpressionEditorOptions {
286
300
  target: string;
287
301
  flags: string[];
288
302
  };
303
+ /** Placeholder for the raw-text textarea (defaults to a generic example).
304
+ * Value cells pass a value-flavoured hint (e.g. "autumn or @count + 1"). */
305
+ rawPlaceholder?: string;
306
+ /** Host actions for a property pill, shown on right-click (e.g. go to
307
+ * definition). Receives the property ref; returns the menu items. */
308
+ propertyActions?(ref: {
309
+ scope: string;
310
+ name: string;
311
+ }): Array<{
312
+ label: string;
313
+ run: () => void;
314
+ }>;
289
315
  /** Emitted on every edit (name-form; "" when cleared). */
290
316
  onChange: (src: string) => void;
291
317
  /** Notified when the author starts (true) / stops (false) editing inside a popover
@@ -361,6 +387,18 @@ interface PreviewOptions {
361
387
  scopeOrder?: string[];
362
388
  /** Resolve a node id to a readable label for seen()/visits() node pills. */
363
389
  nodeLabel?: (id: string) => string;
390
+ /** Host actions for a property pill (e.g. "Go to definition"). When set, the
391
+ * preview becomes right-click interactive on property pills (left-click still
392
+ * falls through to the host); otherwise the preview is fully non-interactive. */
393
+ propertyActions?(ref: {
394
+ scope: string;
395
+ name: string;
396
+ }): Array<{
397
+ label: string;
398
+ run: () => void;
399
+ }>;
400
+ /** Where the property menu mounts (default document.body). */
401
+ popoverContainer?: HTMLElement;
364
402
  }
365
403
  /** Read-only pill strip for a condition (name-form). Empty/unparseable is the caller's concern; a
366
404
  * non-empty unparseable string falls back to its raw text. */
package/dist/index.d.ts CHANGED
@@ -214,6 +214,10 @@ interface EditCtx {
214
214
  apply(next: ExprNode | null): void;
215
215
  /** Open a popover anchored to `anchor`; `render(close)` builds the content. */
216
216
  openPopover(anchor: HTMLElement, render: (close: () => void) => Node): void;
217
+ /** Opener for the property right-click menu; falls back to openPopover. The
218
+ * read-only preview sets this (a real popover) while leaving openPopover a
219
+ * no-op, so property pills get their menu but left-click editing stays inert. */
220
+ openMenu?(anchor: HTMLElement, render: (close: () => void) => Node): void;
217
221
  /** Host-provided picker for a flow-node reference arg (e.g. `seen(...)` / `visits(...)`). When set,
218
222
  * the node-ref arg renders as a pill that opens this instead of a free-text field; `onPick` receives
219
223
  * the chosen node id. Absent in dialects / hosts that have no node catalogue. */
@@ -232,6 +236,16 @@ interface EditCtx {
232
236
  * (an enum-typed outcome target). Lets the string editor show an enum picker
233
237
  * even without a comparison peer. No effect on non-root literals. */
234
238
  valueEnumValues?: string[];
239
+ /** Host actions for a property pill (e.g. "Go to definition"). When set,
240
+ * right-clicking a property pill opens a menu of these actions. The host
241
+ * resolves what each does (navigation etc.); empty array = no menu. */
242
+ propertyActions?(ref: {
243
+ scope: string;
244
+ name: string;
245
+ }): Array<{
246
+ label: string;
247
+ run: () => void;
248
+ }>;
235
249
  }
236
250
 
237
251
  interface ExpressionEditorOptions {
@@ -286,6 +300,18 @@ interface ExpressionEditorOptions {
286
300
  target: string;
287
301
  flags: string[];
288
302
  };
303
+ /** Placeholder for the raw-text textarea (defaults to a generic example).
304
+ * Value cells pass a value-flavoured hint (e.g. "autumn or @count + 1"). */
305
+ rawPlaceholder?: string;
306
+ /** Host actions for a property pill, shown on right-click (e.g. go to
307
+ * definition). Receives the property ref; returns the menu items. */
308
+ propertyActions?(ref: {
309
+ scope: string;
310
+ name: string;
311
+ }): Array<{
312
+ label: string;
313
+ run: () => void;
314
+ }>;
289
315
  /** Emitted on every edit (name-form; "" when cleared). */
290
316
  onChange: (src: string) => void;
291
317
  /** Notified when the author starts (true) / stops (false) editing inside a popover
@@ -361,6 +387,18 @@ interface PreviewOptions {
361
387
  scopeOrder?: string[];
362
388
  /** Resolve a node id to a readable label for seen()/visits() node pills. */
363
389
  nodeLabel?: (id: string) => string;
390
+ /** Host actions for a property pill (e.g. "Go to definition"). When set, the
391
+ * preview becomes right-click interactive on property pills (left-click still
392
+ * falls through to the host); otherwise the preview is fully non-interactive. */
393
+ propertyActions?(ref: {
394
+ scope: string;
395
+ name: string;
396
+ }): Array<{
397
+ label: string;
398
+ run: () => void;
399
+ }>;
400
+ /** Where the property menu mounts (default document.body). */
401
+ popoverContainer?: HTMLElement;
364
402
  }
365
403
  /** Read-only pill strip for a condition (name-form). Empty/unparseable is the caller's concern; a
366
404
  * non-empty unparseable string falls back to its raw text. */
package/dist/index.js CHANGED
@@ -303,10 +303,11 @@ function el(tag, cls, children) {
303
303
  }
304
304
  return node;
305
305
  }
306
- function button(cls, label, onClick, title) {
306
+ function button(cls, label, onClick, title, aria) {
307
307
  const b = el("button", cls, [label]);
308
308
  b.type = "button";
309
309
  if (title) b.title = title;
310
+ if (aria) b.setAttribute("aria-label", aria);
310
311
  b.addEventListener("click", onClick);
311
312
  return b;
312
313
  }
@@ -387,10 +388,25 @@ function textField(opts) {
387
388
  var FLAG_FNS = /* @__PURE__ */ new Set(["check_flags", "set_flags"]);
388
389
  var TAG_ARG0_FNS = /* @__PURE__ */ new Set(["seen", "patter_seen", "visits", "patter_visits"]);
389
390
  function pill(kind, label, onClick, opts = {}) {
390
- const b = button(`exed-pill exed-pill-${kind}${opts.issue ? " exed-pill-err" : ""}`, label, () => onClick(b), opts.issue ?? opts.title);
391
+ const b = button(`exed-pill exed-pill-${kind}${opts.issue ? " exed-pill-err" : ""}`, label, () => onClick(b), opts.issue ?? opts.title, opts.aria);
392
+ b.setAttribute("aria-haspopup", "dialog");
391
393
  if (opts.path) b.dataset["exedPath"] = opts.path.join("/");
392
394
  return b;
393
395
  }
396
+ var OP_ARIA = {
397
+ "==": "equals",
398
+ "!=": "not equal to",
399
+ ">": "greater than",
400
+ ">=": "at least",
401
+ "<": "less than",
402
+ "<=": "at most",
403
+ "and": "and",
404
+ "or": "or",
405
+ "+": "plus",
406
+ "-": "minus",
407
+ "*": "times",
408
+ "/": "divided by"
409
+ };
394
410
  var issueText = (ctx, path) => {
395
411
  const list = issuesAt(ctx.byPath, path);
396
412
  return list.length ? list.map((i) => i.message).join("; ") : void 0;
@@ -418,7 +434,9 @@ function renderNode(node, path, ctx, parentOp, side) {
418
434
  case "scopedvar": {
419
435
  const entry = lookup(ctx.catalogue, node.scope, node.name);
420
436
  const label = displayName({ scope: node.scope, name: node.name }, ctx.defaultScope);
421
- return pill("var", label, (b) => variableEditor(ctx, path, node, b), { issue: issue ?? (entry ? void 0 : `Unknown property ${label}`) });
437
+ const varPill = pill("var", label, (b) => variableEditor(ctx, path, node, b), { issue: issue ?? (entry ? void 0 : `Unknown property ${label}`) });
438
+ attachPropertyMenu(ctx, node, varPill);
439
+ return varPill;
422
440
  }
423
441
  case "flagdelta":
424
442
  return pill(node.sign === "+" ? "flagpos" : "flagneg", `${node.sign}${node.name || "flag"}`, (b) => flagEditor(ctx, path, node, b), { issue, path });
@@ -435,7 +453,7 @@ function renderNode(node, path, ctx, parentOp, side) {
435
453
  }
436
454
  case "unary": {
437
455
  const row = el("span", "exed-unary");
438
- row.append(pill("op", UNARY_LABEL[node.op], () => ctx.apply(deleteOrUnwrapNot(ctx, path, node)), { title: "remove" }));
456
+ row.append(pill("op", UNARY_LABEL[node.op], () => ctx.apply(deleteOrUnwrapNot(ctx, path, node)), { title: "remove", aria: `${OP_ARIA[node.op] ?? node.op} (click to remove)` }));
439
457
  row.append(renderNode(node.operand, [...path, "operand"], ctx));
440
458
  return row;
441
459
  }
@@ -447,7 +465,7 @@ function renderNode(node, path, ctx, parentOp, side) {
447
465
  const swap = opSwapGroup(node.op);
448
466
  const opPill = pill("op", BINARY_LABEL[node.op], (b) => {
449
467
  if (swap) operatorEditor(ctx, path, node.op, swap, b);
450
- }, { title: swap ? "swap operator" : void 0 });
468
+ }, { title: swap ? "swap operator" : void 0, aria: OP_ARIA[node.op] ?? node.op });
451
469
  if (!swap) opPill.classList.add("exed-op-structural");
452
470
  row.append(opPill);
453
471
  row.append(renderNode(node.right, [...path, "right"], ctx, node.op, "right"));
@@ -468,6 +486,36 @@ var deleteOrUnwrapNot = (ctx, path, node) => (
468
486
  // clicking the NOT pill strips it (keeps the operand)
469
487
  setNodeAt(ctx.getAst(), path, node.operand)
470
488
  );
489
+ function attachPropertyMenu(ctx, node, pillEl) {
490
+ if (!ctx.propertyActions) return;
491
+ const actions = ctx.propertyActions({ scope: node.scope, name: node.name });
492
+ if (!actions.length) return;
493
+ const open = ctx.openMenu ?? ctx.openPopover;
494
+ pillEl.addEventListener("contextmenu", (e) => {
495
+ e.preventDefault();
496
+ open(pillEl, (close) => {
497
+ const wrap = el("div", "exed-menu");
498
+ for (const a of actions) wrap.append(button("exed-opt", a.label, () => {
499
+ a.run();
500
+ close();
501
+ }));
502
+ return wrap;
503
+ });
504
+ });
505
+ }
506
+ function isCompoundComparison(node, ctx) {
507
+ if (node.kind !== "binary" || !isComparisonOp(node.op)) return false;
508
+ const numCall = (n) => n.kind === "call" && ctx.dialect.functions[n.name]?.returnType === "number";
509
+ const isNum = (n) => n.kind === "number";
510
+ return numCall(node.left) && isNum(node.right) || numCall(node.right) && isNum(node.left);
511
+ }
512
+ function effectiveDeletePath(ctx, path) {
513
+ const last = path[path.length - 1];
514
+ if (last !== "left" && last !== "right") return path;
515
+ const parentPath = path.slice(0, -1);
516
+ const parent = getNodeAt(ctx.getAst(), parentPath);
517
+ return parent && isCompoundComparison(parent, ctx) ? parentPath : path;
518
+ }
471
519
  function boolEditor(ctx, path, value, anchor) {
472
520
  ctx.openPopover(anchor, (close) => {
473
521
  const wrap = el("div", "exed-menu");
@@ -579,7 +627,7 @@ function flagEditor(ctx, path, node, anchor) {
579
627
  return wrap;
580
628
  });
581
629
  }
582
- var canDelete = (ctx, path) => !(ctx.requireNonEmpty && path.length === 0);
630
+ var canDelete = (ctx, path) => !(ctx.requireNonEmpty && effectiveDeletePath(ctx, path).length === 0);
583
631
  function callEditor(ctx, path, node, anchor) {
584
632
  ctx.openPopover(anchor, (close) => {
585
633
  const wrap = el("div", "exed-menu");
@@ -593,7 +641,7 @@ function callEditor(ctx, path, node, anchor) {
593
641
  }
594
642
  if (canDelete(ctx, path)) {
595
643
  wrap.append(button("exed-opt danger", "Delete", () => {
596
- ctx.apply(deleteAt(ctx.getAst(), path));
644
+ ctx.apply(deleteAt(ctx.getAst(), effectiveDeletePath(ctx, path)));
597
645
  close();
598
646
  }));
599
647
  }
@@ -608,7 +656,7 @@ function variableEditor(ctx, path, node, anchor) {
608
656
  close();
609
657
  },
610
658
  ...canDelete(ctx, path) ? { footer: button("exed-opt danger", "Delete", () => {
611
- ctx.apply(deleteAt(ctx.getAst(), path));
659
+ ctx.apply(deleteAt(ctx.getAst(), effectiveDeletePath(ctx, path)));
612
660
  close();
613
661
  }) } : {}
614
662
  }));
@@ -878,15 +926,17 @@ function renderRow(ctx, row, env) {
878
926
  if (row.kind === "container") return renderContainer(ctx, row, env);
879
927
  return renderLeaf(ctx, row, env);
880
928
  }
881
- function notToggle(ctx, path) {
882
- return button("exed-rowbtn", "NOT", () => ctx.apply(toggleContainerNot(ctx.getAst(), path)), "toggle NOT");
929
+ function notToggle(ctx, path, pressed) {
930
+ const b = button("exed-rowbtn", "NOT", () => ctx.apply(toggleContainerNot(ctx.getAst(), path)), "toggle NOT", pressed ? "remove NOT" : "apply NOT");
931
+ b.setAttribute("aria-pressed", String(pressed));
932
+ return b;
883
933
  }
884
934
  function rowActions(ctx, row, env) {
885
935
  const acts = el("div", "exed-rowacts");
886
- acts.append(notToggle(ctx, row.path));
936
+ acts.append(notToggle(ctx, row.path, row.negated));
887
937
  if (env.chainPath && env.count != null && env.index != null) {
888
- if (env.index > 0) acts.append(button("exed-rowbtn", "\u2191", () => ctx.apply(moveChildInContainer(ctx.getAst(), env.chainPath, env.index, env.index - 1)), "move up"));
889
- if (env.index < env.count - 1) acts.append(button("exed-rowbtn", "\u2193", () => ctx.apply(moveChildInContainer(ctx.getAst(), env.chainPath, env.index, env.index + 1)), "move down"));
938
+ if (env.index > 0) acts.append(button("exed-rowbtn", "\u2191", () => ctx.apply(moveChildInContainer(ctx.getAst(), env.chainPath, env.index, env.index - 1)), "move up", "move up"));
939
+ if (env.index < env.count - 1) acts.append(button("exed-rowbtn", "\u2193", () => ctx.apply(moveChildInContainer(ctx.getAst(), env.chainPath, env.index, env.index + 1)), "move down", "move down"));
890
940
  }
891
941
  acts.append(button("exed-rowbtn danger", "\u2715", () => {
892
942
  if (env.root) {
@@ -895,7 +945,7 @@ function rowActions(ctx, row, env) {
895
945
  }
896
946
  const target = redirectDeleteForPlaceholderSibling(ctx.getAst(), row.path);
897
947
  ctx.apply(deleteAt(ctx.getAst(), target));
898
- }, "delete"));
948
+ }, "delete", "delete this condition"));
899
949
  return acts;
900
950
  }
901
951
  function renderLeaf(ctx, row, env) {
@@ -923,8 +973,8 @@ function renderContainer(ctx, row, env) {
923
973
  head.append(el("span", "exed-grouplabel", [label]));
924
974
  }
925
975
  const headActs = el("div", "exed-rowacts");
926
- headActs.append(notToggle(ctx, row.path));
927
- if (!env.root) headActs.append(button("exed-rowbtn danger", "\u2715", () => ctx.apply(deleteAt(ctx.getAst(), row.path)), "delete group"));
976
+ headActs.append(notToggle(ctx, row.path, row.negated));
977
+ if (!env.root) headActs.append(button("exed-rowbtn danger", "\u2715", () => ctx.apply(deleteAt(ctx.getAst(), row.path)), "delete group", "delete this group"));
928
978
  head.append(headActs);
929
979
  box.append(head);
930
980
  const body = el("div", "exed-groupbody");
@@ -1077,6 +1127,7 @@ function mountExpressionEditor(host, opts) {
1077
1127
  },
1078
1128
  ...opts.requireNonEmpty ? { requireNonEmpty: true } : {},
1079
1129
  ...opts.valueEnumValues ? { valueEnumValues: opts.valueEnumValues } : {},
1130
+ ...opts.propertyActions ? { propertyActions: opts.propertyActions } : {},
1080
1131
  ...opts.pickNode ? { pickNode: opts.pickNode } : {},
1081
1132
  ...opts.nodeLabel ? { nodeLabel: opts.nodeLabel } : {}
1082
1133
  };
@@ -1113,7 +1164,7 @@ function mountExpressionEditor(host, opts) {
1113
1164
  function rawArea() {
1114
1165
  const ta = el("textarea", "exed-raw");
1115
1166
  ta.value = src;
1116
- ta.placeholder = "@gold > 0 and @met_anna";
1167
+ ta.placeholder = opts.rawPlaceholder ?? "@gold > 0 and @met_anna";
1117
1168
  ta.rows = 2;
1118
1169
  ta.addEventListener("input", () => {
1119
1170
  src = ta.value;
@@ -1612,7 +1663,13 @@ function frozenCtx(src, o) {
1612
1663
  pickNode: () => {
1613
1664
  },
1614
1665
  // enables labelled node pills; never fires (preview is read-only)
1615
- ...o.nodeLabel ? { nodeLabel: o.nodeLabel } : {}
1666
+ ...o.nodeLabel ? { nodeLabel: o.nodeLabel } : {},
1667
+ ...o.propertyActions ? {
1668
+ propertyActions: o.propertyActions,
1669
+ openMenu: (anchor, render) => {
1670
+ openPopover(anchor, render, { container: o.popoverContainer });
1671
+ }
1672
+ } : {}
1616
1673
  };
1617
1674
  return { ctx, ast: v.ast };
1618
1675
  }
@@ -1621,7 +1678,8 @@ function exprPills(src, o) {
1621
1678
  return ast ? renderNode(ast, [], ctx) : el("span", "exed-preview-raw", [src]);
1622
1679
  }
1623
1680
  function renderConditionPreview(src, o) {
1624
- return el("div", "exed-preview", [exprPills(src, o)]);
1681
+ const cls = o.propertyActions ? "exed-preview exed-preview-interactive" : "exed-preview";
1682
+ return el("div", cls, [exprPills(src, o)]);
1625
1683
  }
1626
1684
  function renderEffectsPreview(effects, o) {
1627
1685
  const wrap = el("div", "exed-preview exed-preview-effects");