form-builder-pro 1.4.1 → 1.4.2

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.mjs CHANGED
@@ -4747,8 +4747,31 @@ function transformField(field) {
4747
4747
  transformed.repeatIncrementEnabled = field.repeatIncrementEnabled;
4748
4748
  if (field.dateConstraints !== void 0)
4749
4749
  transformed.dateConstraints = field.dateConstraints;
4750
- if (field.formulaConfig !== void 0)
4750
+ if (field.formulaConfig !== void 0) {
4751
4751
  transformed.formulaConfig = field.formulaConfig;
4752
+ } else if (field.type === "formula" && field.formulaType) {
4753
+ const dp = field.floatingPrecision ?? 2;
4754
+ if (field.formulaType === "SIMPLE") {
4755
+ transformed.formulaConfig = {
4756
+ mode: "single",
4757
+ single: { expression: field.formulaExpression ?? "" },
4758
+ multiple: { compareField: "", conditions: [], fallbackExpression: "" },
4759
+ decimalPlaces: dp
4760
+ };
4761
+ } else if (field.formulaType === "SWITCH") {
4762
+ const cases = Array.isArray(field.switchCases) ? field.switchCases.map((c) => ({ value: c.matchValue ?? c.value ?? "", expression: c.formulaExpression ?? c.expression ?? "" })) : [];
4763
+ transformed.formulaConfig = {
4764
+ mode: "multiple",
4765
+ single: { expression: "" },
4766
+ multiple: {
4767
+ compareField: field.switchField ?? "",
4768
+ conditions: cases,
4769
+ fallbackExpression: field.switchDefaultFormula ?? ""
4770
+ },
4771
+ decimalPlaces: dp
4772
+ };
4773
+ }
4774
+ }
4752
4775
  if (field.nameGeneratorFormat !== void 0)
4753
4776
  transformed.nameGeneratorFormat = field.nameGeneratorFormat;
4754
4777
  if (field.nameGeneratorText !== void 0)
@@ -5066,8 +5089,32 @@ function fieldToPayload(field, opts) {
5066
5089
  if (field.type === "formula") {
5067
5090
  payload.fieldType = "FORMULA";
5068
5091
  payload.type = "formula";
5069
- if (field.formulaConfig !== void 0)
5070
- payload.formulaConfig = field.formulaConfig;
5092
+ payload.readOnly = true;
5093
+ payload.floatingPrecision = field.formulaConfig?.decimalPlaces ?? 2;
5094
+ if (field.formulaConfig?.mode === "single") {
5095
+ payload.formulaType = "SIMPLE";
5096
+ payload.formulaExpression = field.formulaConfig.single?.expression ?? "";
5097
+ payload.switchField = null;
5098
+ payload.switchCases = null;
5099
+ payload.switchDefaultFormula = null;
5100
+ } else if (field.formulaConfig?.mode === "multiple") {
5101
+ const multi = field.formulaConfig.multiple;
5102
+ payload.formulaType = "SWITCH";
5103
+ payload.formulaExpression = "";
5104
+ payload.switchField = multi?.compareField ?? null;
5105
+ payload.switchCases = (multi?.conditions ?? []).map((c) => ({
5106
+ matchValue: c.value,
5107
+ formulaExpression: c.expression ?? ""
5108
+ }));
5109
+ payload.switchDefaultFormula = multi?.fallbackExpression ?? "";
5110
+ payload.switchCases = payload.switchCases;
5111
+ } else {
5112
+ payload.formulaType = "SIMPLE";
5113
+ payload.formulaExpression = "";
5114
+ payload.switchField = null;
5115
+ payload.switchCases = null;
5116
+ payload.switchDefaultFormula = null;
5117
+ }
5071
5118
  }
5072
5119
  if (field.type === "repeater") {
5073
5120
  payload.fieldType = "REPEATER";
@@ -10505,9 +10552,647 @@ var SectionList = class {
10505
10552
  }
10506
10553
  };
10507
10554
 
10555
+ // src/utils/formulaTokenParser.ts
10556
+ var FUNCTION_NAMES = /* @__PURE__ */ new Set(["ROUND", "ABS", "MIN", "MAX", "FLOOR", "CEIL", "SQRT", "POW"]);
10557
+ var OPERATOR_CHARS = /* @__PURE__ */ new Set(["+", "-", "*", "/"]);
10558
+ function parseExpressionToTokens(expression, fieldMap, mode) {
10559
+ if (!expression)
10560
+ return [];
10561
+ const tokens = [];
10562
+ let i = 0;
10563
+ const len = expression.length;
10564
+ while (i < len) {
10565
+ const ch = expression[i];
10566
+ if (/\s/.test(ch)) {
10567
+ let ws = "";
10568
+ while (i < len && /\s/.test(expression[i]))
10569
+ ws += expression[i++];
10570
+ tokens.push({ type: "space", value: ws, rawValue: ws });
10571
+ continue;
10572
+ }
10573
+ if (mode === "bracket" && ch === "{") {
10574
+ i++;
10575
+ let ref = "";
10576
+ while (i < len && expression[i] !== "}")
10577
+ ref += expression[i++];
10578
+ if (i < len && expression[i] === "}")
10579
+ i++;
10580
+ const trimmedRef = ref.trim();
10581
+ const field = fieldMap.get(trimmedRef);
10582
+ tokens.push({
10583
+ type: "field",
10584
+ value: field ? field.label || field.fieldName || trimmedRef : trimmedRef,
10585
+ rawValue: `{${trimmedRef}}`,
10586
+ fieldRef: trimmedRef
10587
+ });
10588
+ continue;
10589
+ }
10590
+ if (OPERATOR_CHARS.has(ch)) {
10591
+ tokens.push({ type: "operator", value: ch, rawValue: ch });
10592
+ i++;
10593
+ continue;
10594
+ }
10595
+ if (ch === "%") {
10596
+ tokens.push({ type: "percent", value: "%", rawValue: "%" });
10597
+ i++;
10598
+ continue;
10599
+ }
10600
+ if (ch === "(" || ch === ")") {
10601
+ tokens.push({ type: "paren", value: ch, rawValue: ch });
10602
+ i++;
10603
+ continue;
10604
+ }
10605
+ if (ch === ",") {
10606
+ tokens.push({ type: "comma", value: ",", rawValue: "," });
10607
+ i++;
10608
+ continue;
10609
+ }
10610
+ if (/[0-9]/.test(ch) || ch === "." && i + 1 < len && /[0-9]/.test(expression[i + 1])) {
10611
+ let num = "";
10612
+ while (i < len && /[0-9.]/.test(expression[i]))
10613
+ num += expression[i++];
10614
+ tokens.push({ type: "number", value: num, rawValue: num });
10615
+ continue;
10616
+ }
10617
+ if (/[a-zA-Z_]/.test(ch)) {
10618
+ let ident = "";
10619
+ while (i < len && /[a-zA-Z0-9_]/.test(expression[i]))
10620
+ ident += expression[i++];
10621
+ if (FUNCTION_NAMES.has(ident.toUpperCase())) {
10622
+ tokens.push({ type: "function", value: ident, rawValue: ident });
10623
+ continue;
10624
+ }
10625
+ if (mode === "plain") {
10626
+ const field = fieldMap.get(ident);
10627
+ tokens.push({
10628
+ type: "field",
10629
+ value: field ? field.label || field.fieldName || ident : ident,
10630
+ rawValue: ident,
10631
+ fieldRef: ident
10632
+ });
10633
+ continue;
10634
+ }
10635
+ tokens.push({ type: "unknown", value: ident, rawValue: ident });
10636
+ continue;
10637
+ }
10638
+ tokens.push({ type: "unknown", value: ch, rawValue: ch });
10639
+ i++;
10640
+ }
10641
+ return tokens;
10642
+ }
10643
+ function buildFieldMap(fields) {
10644
+ const map = /* @__PURE__ */ new Map();
10645
+ for (const f of fields) {
10646
+ if (f.fieldName)
10647
+ map.set(f.fieldName, f);
10648
+ if (f.id && f.id !== f.fieldName)
10649
+ map.set(f.id, f);
10650
+ }
10651
+ return map;
10652
+ }
10653
+ function getFieldRef(field) {
10654
+ return field.fieldName && field.fieldName !== field.id ? field.fieldName : field.id;
10655
+ }
10656
+
10657
+ // src/builder/FormulaEditorWidget.ts
10658
+ var _stylesInjected = false;
10659
+ var STYLE_ID = "formula-editor-widget-styles-v2";
10660
+ function injectStyles() {
10661
+ if (_stylesInjected && document.getElementById(STYLE_ID))
10662
+ return;
10663
+ document.getElementById(STYLE_ID)?.remove();
10664
+ document.getElementById("formula-editor-widget-styles")?.remove();
10665
+ _stylesInjected = true;
10666
+ const style = document.createElement("style");
10667
+ style.id = STYLE_ID;
10668
+ style.textContent = `
10669
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
10670
+ FormulaEditorWidget v2
10671
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
10672
+
10673
+ /* \u2500\u2500 Container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10674
+ .few-container {
10675
+ position: relative;
10676
+ width: 100%;
10677
+ }
10678
+
10679
+ /* \u2500\u2500 Editor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10680
+ /*
10681
+ * NOTE: color uses !important so it is never muted by
10682
+ * Angular ViewEncapsulation, host-element rules, or global resets.
10683
+ * This ensures operators/numbers always render at full text contrast.
10684
+ */
10685
+ .few-editor {
10686
+ display: block;
10687
+ width: 100%;
10688
+ min-height: 38px;
10689
+ padding: 6px 32px 6px 12px; /* right padding reserves space for clear btn */
10690
+ border: 1px solid #e2e8f0;
10691
+ border-radius: 6px;
10692
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
10693
+ font-size: 13px;
10694
+ font-weight: 700 !important; /* \u2190 operators/numbers always bold */
10695
+ line-height: 1.7;
10696
+ color: rgb(213, 12, 65) !important; /* operator/text color \u2014 chips override with their own !important */
10697
+ caret-color: #635bff;
10698
+ background: transparent;
10699
+ outline: none;
10700
+ word-break: break-word;
10701
+ white-space: pre-wrap;
10702
+ cursor: text;
10703
+ transition: border-color 0.15s, box-shadow 0.15s;
10704
+ box-sizing: border-box;
10705
+ }
10706
+
10707
+ .few-editor:focus {
10708
+ border-color: #635bff;
10709
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.15);
10710
+ }
10711
+
10712
+ .few-editor.few-has-error {
10713
+ border-color: #ef4444;
10714
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
10715
+ }
10716
+
10717
+ /* \u2500\u2500 Placeholder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10718
+ .few-placeholder {
10719
+ position: absolute;
10720
+ top: 0;
10721
+ left: 0;
10722
+ right: 32px; /* don't overlap clear button */
10723
+ bottom: 0;
10724
+ padding: 6px 0 6px 12px;
10725
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
10726
+ font-size: 13px;
10727
+ line-height: 1.7;
10728
+ color: #94a3b8;
10729
+ pointer-events: none;
10730
+ user-select: none;
10731
+ white-space: nowrap;
10732
+ overflow: hidden;
10733
+ text-overflow: ellipsis;
10734
+ }
10735
+
10736
+ /* \u2500\u2500 Field chip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10737
+ .few-chip {
10738
+ display: inline-flex;
10739
+ align-items: center;
10740
+ padding: 0px 7px 1px 6px;
10741
+ margin: 0 2px;
10742
+ font-size: 11.5px;
10743
+ font-weight: 600 !important; /* explicit !important so the editor's 700 !important doesn't cascade in */
10744
+ font-family: ui-sans-serif, system-ui, sans-serif;
10745
+ letter-spacing: 0.01em;
10746
+ border-radius: 4px;
10747
+ background: rgba(99, 91, 255, 0.10);
10748
+ color: #635bff !important; /* chip label always purple \u2014 not inherited */
10749
+ border: 1px solid rgba(99, 91, 255, 0.28);
10750
+ cursor: default;
10751
+ user-select: none;
10752
+ vertical-align: middle;
10753
+ line-height: 1.6;
10754
+ white-space: nowrap;
10755
+ transition: background 0.1s;
10756
+ }
10757
+
10758
+ .few-chip:hover {
10759
+ background: rgba(99, 91, 255, 0.17);
10760
+ }
10761
+
10762
+ .few-chip-unknown {
10763
+ background: rgba(239, 68, 68, 0.08);
10764
+ color: #dc2626 !important;
10765
+ border-color: rgba(239, 68, 68, 0.25);
10766
+ }
10767
+
10768
+ /* \u2500\u2500 Clear (\xD7) button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10769
+ .few-clear-btn {
10770
+ position: absolute;
10771
+ top: 50%;
10772
+ right: 7px;
10773
+ transform: translateY(-50%);
10774
+ display: flex;
10775
+ align-items: center;
10776
+ justify-content: center;
10777
+ width: 18px;
10778
+ height: 18px;
10779
+ padding: 0;
10780
+ border: none;
10781
+ border-radius: 50%;
10782
+ background: #94a3b8;
10783
+ color: #ffffff;
10784
+ font-size: 12px;
10785
+ line-height: 1;
10786
+ font-weight: 700;
10787
+ cursor: pointer;
10788
+ opacity: 0;
10789
+ pointer-events: none;
10790
+ transition: opacity 0.15s, background 0.15s;
10791
+ z-index: 2;
10792
+ }
10793
+
10794
+ .few-clear-btn:hover {
10795
+ background: #475569;
10796
+ }
10797
+
10798
+ /* Shown only when the editor has content */
10799
+ .few-container.few-has-content .few-clear-btn {
10800
+ opacity: 1;
10801
+ pointer-events: auto;
10802
+ }
10803
+
10804
+ /* \u2500\u2500 Dark mode (media query) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10805
+ @media (prefers-color-scheme: dark) {
10806
+ .few-editor {
10807
+ border-color: #334155;
10808
+ /* operator color is set via inline !important \u2014 not overridden here */
10809
+ }
10810
+ .few-editor:focus {
10811
+ border-color: #635bff;
10812
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.25);
10813
+ }
10814
+ .few-placeholder { color: #64748b; }
10815
+ .few-chip {
10816
+ background: rgba(99, 91, 255, 0.18);
10817
+ color: #a5b4fc !important;
10818
+ border-color: rgba(99, 91, 255, 0.38);
10819
+ }
10820
+ .few-chip-unknown {
10821
+ background: rgba(239, 68, 68, 0.15);
10822
+ color: #f87171 !important;
10823
+ border-color: rgba(239, 68, 68, 0.35);
10824
+ }
10825
+ .few-clear-btn {
10826
+ background: #64748b;
10827
+ color: #f1f5f9;
10828
+ }
10829
+ .few-clear-btn:hover { background: #475569; }
10830
+ }
10831
+
10832
+ /* \u2500\u2500 Dark mode (Tailwind class strategy) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10833
+ .dark .few-editor {
10834
+ border-color: #334155;
10835
+ /* operator color is set via inline !important \u2014 not overridden here */
10836
+ }
10837
+ .dark .few-editor:focus {
10838
+ border-color: #635bff;
10839
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.25);
10840
+ }
10841
+ .dark .few-placeholder { color: #64748b; }
10842
+ .dark .few-chip {
10843
+ background: rgba(99, 91, 255, 0.18);
10844
+ color: #a5b4fc !important;
10845
+ border-color: rgba(99, 91, 255, 0.38);
10846
+ }
10847
+ .dark .few-chip-unknown {
10848
+ background: rgba(239, 68, 68, 0.15);
10849
+ color: #f87171 !important;
10850
+ border-color: rgba(239, 68, 68, 0.35);
10851
+ }
10852
+ .dark .few-clear-btn {
10853
+ background: #64748b;
10854
+ color: #f1f5f9;
10855
+ }
10856
+ .dark .few-clear-btn:hover { background: #475569; }
10857
+ `;
10858
+ document.head.appendChild(style);
10859
+ }
10860
+ var FormulaEditorWidget = class {
10861
+ constructor(options) {
10862
+ __publicField(this, "_container");
10863
+ __publicField(this, "_editor");
10864
+ __publicField(this, "_placeholderEl");
10865
+ __publicField(this, "_clearBtn");
10866
+ __publicField(this, "_options");
10867
+ __publicField(this, "_fieldMap");
10868
+ /**
10869
+ * The canonical internal expression — SINGLE SOURCE OF TRUTH.
10870
+ * The DOM is always derived from this; never the reverse.
10871
+ * Updated by _onEditorInput() (user edits) and setValue() (programmatic).
10872
+ */
10873
+ __publicField(this, "_internalValue", "");
10874
+ injectStyles();
10875
+ this._options = { ...options };
10876
+ this._fieldMap = buildFieldMap(options.availableFields);
10877
+ this._container = document.createElement("div");
10878
+ this._container.className = "few-container";
10879
+ this._placeholderEl = document.createElement("div");
10880
+ this._placeholderEl.className = "few-placeholder";
10881
+ this._placeholderEl.textContent = options.placeholder ?? "Enter formula\u2026";
10882
+ this._editor = document.createElement("div");
10883
+ this._editor.className = "few-editor";
10884
+ this._editor.contentEditable = "true";
10885
+ this._editor.spellcheck = false;
10886
+ this._editor.setAttribute("autocomplete", "off");
10887
+ this._editor.setAttribute("autocorrect", "off");
10888
+ this._editor.setAttribute("autocapitalize", "off");
10889
+ this._editor.setAttribute("data-formula-editor", "true");
10890
+ this._editor.style.setProperty("color", "rgb(213, 12, 65)", "important");
10891
+ this._editor.style.setProperty("font-weight", "700", "important");
10892
+ this._editor.style.setProperty("caret-color", "#635bff", "important");
10893
+ this._clearBtn = document.createElement("button");
10894
+ this._clearBtn.type = "button";
10895
+ this._clearBtn.className = "few-clear-btn";
10896
+ this._clearBtn.title = "Clear formula";
10897
+ this._clearBtn.textContent = "\xD7";
10898
+ this._clearBtn.addEventListener("mousedown", (e) => {
10899
+ e.preventDefault();
10900
+ this.clear();
10901
+ });
10902
+ this._container.appendChild(this._placeholderEl);
10903
+ this._container.appendChild(this._editor);
10904
+ this._container.appendChild(this._clearBtn);
10905
+ this._attachEvents();
10906
+ if (options.initialValue) {
10907
+ this.setValue(options.initialValue);
10908
+ } else {
10909
+ this._syncUI();
10910
+ }
10911
+ }
10912
+ // ─── Internal event wiring ───────────────────────────────────────────────
10913
+ _attachEvents() {
10914
+ this._editor.addEventListener("input", () => this._onEditorInput());
10915
+ this._editor.addEventListener("keydown", (e) => {
10916
+ if (e.key === "Enter") {
10917
+ e.preventDefault();
10918
+ return;
10919
+ }
10920
+ if (e.key === "Backspace")
10921
+ this._handleBackspaceOnChip(e);
10922
+ });
10923
+ this._editor.addEventListener("paste", (e) => {
10924
+ e.preventDefault();
10925
+ const text = e.clipboardData?.getData("text/plain") ?? "";
10926
+ if (text) {
10927
+ this._insertTextAtCaret(text);
10928
+ this._onEditorInput();
10929
+ }
10930
+ });
10931
+ this._editor.addEventListener("dragover", (e) => e.preventDefault());
10932
+ this._editor.addEventListener("drop", (e) => e.preventDefault());
10933
+ }
10934
+ // ─── Core edit cycle ─────────────────────────────────────────────────────
10935
+ _onEditorInput() {
10936
+ this._normaliseDom();
10937
+ const newValue = this._readInternalExpression();
10938
+ const changed = newValue !== this._internalValue;
10939
+ this._internalValue = newValue;
10940
+ this._syncUI();
10941
+ if (changed) {
10942
+ this._options.onChange?.(newValue);
10943
+ }
10944
+ }
10945
+ /**
10946
+ * Remove browser-inserted artefacts (<br>, stray <div>/<span>) that some
10947
+ * browsers inject into a contenteditable. Chips (data-field-ref) are kept.
10948
+ */
10949
+ _normaliseDom() {
10950
+ for (const node of Array.from(this._editor.childNodes)) {
10951
+ if (node.nodeType !== Node.ELEMENT_NODE)
10952
+ continue;
10953
+ const el = node;
10954
+ if (el.hasAttribute("data-field-ref"))
10955
+ continue;
10956
+ if (el.tagName === "BR") {
10957
+ el.remove();
10958
+ continue;
10959
+ }
10960
+ const textNode = document.createTextNode(el.textContent ?? "");
10961
+ this._editor.replaceChild(textNode, el);
10962
+ }
10963
+ }
10964
+ /**
10965
+ * Backspace immediately before a chip should delete the whole chip, not
10966
+ * step into its non-editable content.
10967
+ */
10968
+ _handleBackspaceOnChip(e) {
10969
+ const sel = window.getSelection();
10970
+ if (!sel || sel.rangeCount === 0)
10971
+ return;
10972
+ const range = sel.getRangeAt(0);
10973
+ if (!range.collapsed)
10974
+ return;
10975
+ let prev = null;
10976
+ if (range.startContainer === this._editor) {
10977
+ if (range.startOffset > 0)
10978
+ prev = this._editor.childNodes[range.startOffset - 1];
10979
+ } else if (range.startContainer.nodeType === Node.TEXT_NODE) {
10980
+ if (range.startOffset === 0)
10981
+ prev = range.startContainer.previousSibling;
10982
+ }
10983
+ if (prev && prev.hasAttribute?.("data-field-ref")) {
10984
+ e.preventDefault();
10985
+ prev.remove();
10986
+ this._onEditorInput();
10987
+ }
10988
+ }
10989
+ // ─── Serialisation: DOM → internal expression ────────────────────────────
10990
+ _readInternalExpression() {
10991
+ let result = "";
10992
+ for (const node of Array.from(this._editor.childNodes)) {
10993
+ if (node.nodeType === Node.TEXT_NODE) {
10994
+ result += node.textContent ?? "";
10995
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
10996
+ const el = node;
10997
+ const ref = el.getAttribute("data-field-ref");
10998
+ if (ref !== null) {
10999
+ result += this._options.mode === "bracket" ? `{${ref}}` : ref;
11000
+ } else {
11001
+ result += el.textContent ?? "";
11002
+ }
11003
+ }
11004
+ }
11005
+ return result;
11006
+ }
11007
+ // ─── Deserialization: internal expression → DOM ──────────────────────────
11008
+ /**
11009
+ * Load an internal expression string.
11010
+ *
11011
+ * This is the ONLY path that populates the editor with chips.
11012
+ * It does NOT fire onChange (programmatic set ≠ user edit).
11013
+ *
11014
+ * Design invariant: the DOM is ALWAYS fully rebuilt from _internalValue,
11015
+ * never from user-visible text. This guarantees correct state on re-render
11016
+ * after field switches, undo/redo, or hot-reload.
11017
+ */
11018
+ setValue(internalExpression) {
11019
+ this._internalValue = internalExpression ?? "";
11020
+ this._editor.innerHTML = "";
11021
+ if (this._internalValue.trim()) {
11022
+ const tokens = parseExpressionToTokens(
11023
+ this._internalValue,
11024
+ this._fieldMap,
11025
+ this._options.mode
11026
+ );
11027
+ for (const token of tokens) {
11028
+ if (token.type === "field" && token.fieldRef !== void 0) {
11029
+ const field = this._fieldMap.get(token.fieldRef) ?? {
11030
+ id: token.fieldRef,
11031
+ fieldName: token.fieldRef,
11032
+ label: token.fieldRef
11033
+ // shows raw ref — still readable
11034
+ };
11035
+ const isUnknown = !this._fieldMap.has(token.fieldRef);
11036
+ this._editor.appendChild(this._createChipEl(field, isUnknown));
11037
+ } else {
11038
+ this._editor.appendChild(document.createTextNode(token.value));
11039
+ }
11040
+ }
11041
+ }
11042
+ this._syncUI();
11043
+ }
11044
+ /** Return the current stored expression. Always reflects live DOM state. */
11045
+ getValue() {
11046
+ return this._readInternalExpression();
11047
+ }
11048
+ // ─── Public mutation ─────────────────────────────────────────────────────
11049
+ /**
11050
+ * Insert a field chip at the caret (or append if editor lacks focus).
11051
+ * Handles spacing automatically so chips never run into adjacent text.
11052
+ */
11053
+ insertField(field) {
11054
+ this._editor.focus();
11055
+ const chip = this._createChipEl(field, false);
11056
+ const sel = window.getSelection();
11057
+ const inEditor = sel && sel.rangeCount > 0 && this._editor.contains(sel.getRangeAt(0).commonAncestorContainer);
11058
+ if (inEditor) {
11059
+ const range = sel.getRangeAt(0);
11060
+ range.deleteContents();
11061
+ if (this._needsSpaceBefore(range)) {
11062
+ range.insertNode(document.createTextNode(" "));
11063
+ range.collapse(false);
11064
+ }
11065
+ range.insertNode(chip);
11066
+ const spaceAfter = document.createTextNode(" ");
11067
+ range.setStartAfter(chip);
11068
+ range.setEndAfter(chip);
11069
+ range.insertNode(spaceAfter);
11070
+ range.setStartAfter(spaceAfter);
11071
+ range.setEndAfter(spaceAfter);
11072
+ sel.removeAllRanges();
11073
+ sel.addRange(range);
11074
+ } else {
11075
+ this._appendAtEnd(chip);
11076
+ }
11077
+ this._onEditorInput();
11078
+ }
11079
+ /**
11080
+ * Insert plain text at the caret (operators, function names, parens).
11081
+ * Operators rendered by this path are text nodes and always inherit
11082
+ * .few-editor's high-contrast color.
11083
+ */
11084
+ insertText(text) {
11085
+ this._editor.focus();
11086
+ this._insertTextAtCaret(text);
11087
+ this._onEditorInput();
11088
+ }
11089
+ /**
11090
+ * Clear the entire formula expression.
11091
+ * Fires onChange('') so the store is updated immediately.
11092
+ */
11093
+ clear() {
11094
+ this._editor.innerHTML = "";
11095
+ this._internalValue = "";
11096
+ this._syncUI();
11097
+ this._options.onChange?.("");
11098
+ }
11099
+ // ─── Visual state ────────────────────────────────────────────────────────
11100
+ /** Toggle error (red border) state. */
11101
+ setError(hasError) {
11102
+ this._editor.classList.toggle("few-has-error", hasError);
11103
+ }
11104
+ /**
11105
+ * Refresh the field map with a new field list (e.g. schema changed).
11106
+ * Re-renders from _internalValue so stale labels / unknowns are resolved.
11107
+ */
11108
+ updateFields(fields) {
11109
+ this._options.availableFields = fields;
11110
+ this._fieldMap = buildFieldMap(fields);
11111
+ this.setValue(this._internalValue);
11112
+ }
11113
+ /** Return the root element to mount. */
11114
+ getElement() {
11115
+ return this._container;
11116
+ }
11117
+ /** Focus the editable area. */
11118
+ focus() {
11119
+ this._editor.focus();
11120
+ }
11121
+ /** Remove from DOM. */
11122
+ destroy() {
11123
+ this._container.remove();
11124
+ }
11125
+ // ─── Private helpers ─────────────────────────────────────────────────────
11126
+ _insertTextAtCaret(text) {
11127
+ const sel = window.getSelection();
11128
+ const inEditor = sel && sel.rangeCount > 0 && this._editor.contains(sel.getRangeAt(0).commonAncestorContainer);
11129
+ if (inEditor) {
11130
+ const range = sel.getRangeAt(0);
11131
+ range.deleteContents();
11132
+ const tn = document.createTextNode(text);
11133
+ range.insertNode(tn);
11134
+ range.setStartAfter(tn);
11135
+ range.setEndAfter(tn);
11136
+ sel.removeAllRanges();
11137
+ sel.addRange(range);
11138
+ } else {
11139
+ this._editor.appendChild(document.createTextNode(text));
11140
+ }
11141
+ }
11142
+ _appendAtEnd(chip) {
11143
+ const last = this._editor.lastChild;
11144
+ if (last) {
11145
+ if (last.nodeType === Node.TEXT_NODE) {
11146
+ const txt = last.textContent ?? "";
11147
+ if (txt.length > 0 && !/\s$/.test(txt)) {
11148
+ this._editor.appendChild(document.createTextNode(" "));
11149
+ }
11150
+ } else if (last.hasAttribute?.("data-field-ref")) {
11151
+ this._editor.appendChild(document.createTextNode(" "));
11152
+ }
11153
+ }
11154
+ this._editor.appendChild(chip);
11155
+ this._editor.appendChild(document.createTextNode(" "));
11156
+ }
11157
+ _needsSpaceBefore(range) {
11158
+ const { startContainer, startOffset } = range;
11159
+ if (startContainer.nodeType === Node.TEXT_NODE) {
11160
+ const text = startContainer.textContent ?? "";
11161
+ const ch = text[startOffset - 1];
11162
+ return startOffset > 0 && ch !== void 0 && !/[\s(]/.test(ch);
11163
+ }
11164
+ if (startContainer === this._editor && startOffset > 0) {
11165
+ const prev = this._editor.childNodes[startOffset - 1];
11166
+ return prev?.hasAttribute?.("data-field-ref") ?? false;
11167
+ }
11168
+ return false;
11169
+ }
11170
+ _createChipEl(field, isUnknown) {
11171
+ const chip = document.createElement("span");
11172
+ chip.contentEditable = "false";
11173
+ chip.setAttribute("data-field-ref", getFieldRef(field));
11174
+ chip.textContent = field.label || field.fieldName || field.id;
11175
+ chip.className = isUnknown ? "few-chip few-chip-unknown" : "few-chip";
11176
+ chip.title = this._options.mode === "bracket" ? `{${getFieldRef(field)}}` : getFieldRef(field);
11177
+ return chip;
11178
+ }
11179
+ /**
11180
+ * Synchronise all purely-visual state:
11181
+ * - placeholder visibility
11182
+ * - clear-button visibility (few-has-content on container)
11183
+ */
11184
+ _syncUI() {
11185
+ const hasContent = this._internalValue.trim().length > 0 || !!this._editor.querySelector("[data-field-ref]");
11186
+ this._placeholderEl.style.display = hasContent ? "none" : "block";
11187
+ this._container.classList.toggle("few-has-content", hasContent);
11188
+ this._editor.style.setProperty("color", "rgb(213, 12, 65)", "important");
11189
+ this._editor.style.setProperty("font-weight", "700", "important");
11190
+ }
11191
+ };
11192
+
10508
11193
  // src/builder/FormBuilder.ts
10509
11194
  var advancedCssPanelState = /* @__PURE__ */ new Map();
10510
- var lastFocusedExprTextarea = null;
11195
+ var lastFocusedFormulaWidget = null;
10511
11196
  var LABEL_DEBOUNCE_MS = 300;
10512
11197
  var labelUpdateTimeouts = /* @__PURE__ */ new Map();
10513
11198
  var FormBuilder = class {
@@ -10986,8 +11671,15 @@ var FormBuilder = class {
10986
11671
  if (field.type !== "formula" || !field.formulaConfig)
10987
11672
  continue;
10988
11673
  const fcfg = field.formulaConfig;
10989
- const fAvailable = getFieldsForFormula(schema, field.id);
10990
- const fNames = fAvailable.map((f) => f.fieldName);
11674
+ const allOtherFields = schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== field.id);
11675
+ const validRefSet = /* @__PURE__ */ new Set();
11676
+ allOtherFields.forEach((f) => {
11677
+ if (f.fieldName)
11678
+ validRefSet.add(f.fieldName);
11679
+ if (f.id)
11680
+ validRefSet.add(f.id);
11681
+ });
11682
+ const validRefs = Array.from(validRefSet);
10991
11683
  const exprs = [];
10992
11684
  if (fcfg.mode === "single") {
10993
11685
  if (fcfg.single?.expression)
@@ -11001,7 +11693,7 @@ var FormBuilder = class {
11001
11693
  exprs.push(fcfg.multiple.fallbackExpression);
11002
11694
  }
11003
11695
  for (const expr of exprs) {
11004
- const result = validateFormulaExpression(expr, fNames);
11696
+ const result = validateFormulaExpression(expr, validRefs);
11005
11697
  if (!result.valid) {
11006
11698
  alert(`Formula error in "${field.label}": ${result.error}`);
11007
11699
  return;
@@ -11759,38 +12451,64 @@ var FormBuilder = class {
11759
12451
  const numericFields = getNumericFieldsForFormula(schema, selectedField.id);
11760
12452
  const availableIds = numericFields.map((f) => f.id);
11761
12453
  const availableNames = numericFields.map((f) => f.fieldName);
12454
+ const allFieldInfosForWidget = schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== selectedField.id).map((f) => ({
12455
+ id: f.id,
12456
+ fieldName: f.fieldName ?? f.id,
12457
+ label: f.label || f.fieldName || f.id
12458
+ }));
12459
+ const numFieldInfos = numericFields.map((f) => ({
12460
+ id: f.id,
12461
+ fieldName: f.fieldName,
12462
+ label: f.label || f.fieldName || f.id
12463
+ }));
11762
12464
  const formulaGroup = createElement("div", { className: "mb-3" });
11763
- formulaGroup.appendChild(createElement("label", { className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1", text: "Formula" }));
11764
- const formulaInput = createElement("input", {
11765
- type: "text",
11766
- className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent font-mono text-sm",
11767
- value: selectedField.formula || "",
12465
+ formulaGroup.appendChild(createElement("label", {
12466
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12467
+ text: "Formula"
12468
+ }));
12469
+ const numFormulaWidget = new FormulaEditorWidget({
12470
+ mode: "plain",
12471
+ availableFields: allFieldInfosForWidget,
12472
+ // Always read from the store snapshot — guarantees correct reload
12473
+ // after field switch and re-render.
12474
+ initialValue: selectedField.formula || "",
11768
12475
  placeholder: "e.g. quantity * price",
11769
- "data-focus-id": `field-formula-${selectedField.id}`,
11770
- oninput: (e) => {
11771
- const formula = e.target.value.trim();
12476
+ onChange: (formula) => {
11772
12477
  const deps = parseFormulaDependencies(formula);
11773
- const validation = validateFormula(formula, availableIds, availableNames, selectedField.id);
11774
- const hasCircular = deps.length > 0 && detectCircularDependency(schema, selectedField.id, formula, deps);
11775
- const errEl = formulaGroup.querySelector(".formula-error");
11776
- if (errEl) {
11777
- if (validation.valid && !hasCircular) {
11778
- errEl.textContent = "";
11779
- errEl.classList.add("hidden");
11780
- } else {
11781
- errEl.textContent = !validation.valid ? validation.error : "Circular dependency detected";
11782
- errEl.classList.remove("hidden");
11783
- }
12478
+ const isValid2 = (() => {
12479
+ if (!formula.trim())
12480
+ return true;
12481
+ const v = validateFormula(formula, availableIds, availableNames, selectedField.id);
12482
+ if (!v.valid)
12483
+ return false;
12484
+ return !(deps.length > 0 && detectCircularDependency(schema, selectedField.id, formula, deps));
12485
+ })();
12486
+ numFormulaWidget.setError(!isValid2 && formula.trim().length > 0);
12487
+ if (!isValid2 && formula.trim()) {
12488
+ const v = validateFormula(formula, availableIds, availableNames, selectedField.id);
12489
+ formulaError.textContent = !v.valid ? v.error : "Circular dependency detected";
12490
+ formulaError.classList.remove("hidden");
12491
+ } else {
12492
+ formulaError.textContent = "";
12493
+ formulaError.classList.add("hidden");
11784
12494
  }
11785
12495
  formStore.getState().updateField(selectedField.id, { formula, dependencies: deps });
11786
12496
  }
11787
12497
  });
11788
- formulaGroup.appendChild(formulaInput);
11789
- const formulaError = createElement("div", { className: "text-xs text-red-600 dark:text-red-400 mt-1 formula-error hidden" });
12498
+ numFormulaWidget.getElement().querySelector("[data-formula-editor]")?.addEventListener("focus", () => {
12499
+ lastFocusedFormulaWidget = numFormulaWidget;
12500
+ });
12501
+ formulaGroup.appendChild(numFormulaWidget.getElement());
12502
+ const formulaError = createElement("div", {
12503
+ className: "text-xs text-red-600 dark:text-red-400 mt-1 formula-error hidden"
12504
+ });
11790
12505
  formulaGroup.appendChild(formulaError);
11791
12506
  body.appendChild(formulaGroup);
11792
12507
  const insertGroup = createElement("div", { className: "mb-3" });
11793
- insertGroup.appendChild(createElement("label", { className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1", text: "Insert Field" }));
12508
+ insertGroup.appendChild(createElement("label", {
12509
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12510
+ text: "Insert Field"
12511
+ }));
11794
12512
  const insertSelect = createElement("select", {
11795
12513
  className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
11796
12514
  onchange: (e) => {
@@ -11798,34 +12516,58 @@ var FormBuilder = class {
11798
12516
  const ref = sel.value;
11799
12517
  if (!ref)
11800
12518
  return;
11801
- const current = selectedField.formula || "";
11802
- const insert = current ? ` ${ref} ` : ref;
11803
- const newFormula = current + insert;
11804
- formStore.getState().updateField(selectedField.id, {
11805
- formula: newFormula,
11806
- dependencies: parseFormulaDependencies(newFormula)
11807
- });
11808
- formulaInput.value = newFormula;
12519
+ const field = numFieldInfos.find((f) => f.fieldName === ref || f.id === ref);
12520
+ if (field) {
12521
+ numFormulaWidget.insertField(field);
12522
+ lastFocusedFormulaWidget = numFormulaWidget;
12523
+ }
11809
12524
  sel.value = "";
11810
- this.render();
11811
12525
  }
11812
12526
  });
11813
- insertSelect.appendChild(createElement("option", { value: "", text: "Select field to insert...", selected: true }));
11814
- numericFields.forEach((f) => {
12527
+ insertSelect.appendChild(createElement("option", { value: "", text: "Select field to insert\u2026", selected: true }));
12528
+ numFieldInfos.forEach((f) => {
11815
12529
  const ref = f.fieldName !== f.id ? f.fieldName : f.id;
11816
- insertSelect.appendChild(createElement("option", { value: ref, text: `${f.label} (${ref})` }));
12530
+ insertSelect.appendChild(createElement("option", { value: ref, text: f.label }));
11817
12531
  });
11818
12532
  insertGroup.appendChild(insertSelect);
11819
12533
  body.appendChild(insertGroup);
11820
- const hintEl = createElement("p", {
11821
- className: "text-xs text-gray-500 dark:text-gray-400 mb-2",
11822
- text: "Use +, -, *, / and parentheses. Reference fields by their name or ID."
12534
+ body.appendChild(createElement("label", {
12535
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12536
+ text: "Operators"
12537
+ }));
12538
+ const numMathWrap = createElement("div", { className: "flex flex-wrap gap-1 mb-3" });
12539
+ [
12540
+ { text: "+", insert: " + " },
12541
+ { text: "-", insert: " - " },
12542
+ { text: "*", insert: " * " },
12543
+ { text: "/", insert: " / " },
12544
+ { text: "%", insert: " % " },
12545
+ { text: "(", insert: "(" },
12546
+ { text: ")", insert: ")" }
12547
+ ].forEach((op) => {
12548
+ numMathWrap.appendChild(createElement("button", {
12549
+ type: "button",
12550
+ className: "px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded font-mono hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors",
12551
+ text: op.text,
12552
+ onclick: () => {
12553
+ (lastFocusedFormulaWidget ?? numFormulaWidget).insertText(op.insert);
12554
+ }
12555
+ }));
11823
12556
  });
11824
- body.appendChild(hintEl);
12557
+ body.appendChild(numMathWrap);
12558
+ body.appendChild(createElement("p", {
12559
+ className: "text-xs text-gray-500 dark:text-gray-400 mb-2",
12560
+ text: "Fields shown as labels \u2014 stored as field references internally."
12561
+ }));
11825
12562
  }
11826
12563
  }
11827
12564
  if (selectedField.type === "formula") {
11828
12565
  const schema = formStore.getState().schema;
12566
+ const allSchemaFieldInfos = schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== selectedField.id).map((f) => ({
12567
+ id: f.id,
12568
+ fieldName: f.fieldName ?? f.id,
12569
+ label: f.label || f.fieldName || f.id
12570
+ }));
11829
12571
  const availableFields = getFieldsForFormula(schema, selectedField.id);
11830
12572
  const cfg = selectedField.formulaConfig ?? {
11831
12573
  mode: "single",
@@ -11878,35 +12620,43 @@ var FormBuilder = class {
11878
12620
  modeRow.appendChild(multipleBtn);
11879
12621
  modeGroup.appendChild(modeRow);
11880
12622
  body.appendChild(modeGroup);
12623
+ const fwFieldInfos = allSchemaFieldInfos;
12624
+ lastFocusedFormulaWidget = null;
12625
+ const registerFormulaWidget = (widget) => {
12626
+ widget.getElement().querySelector("[data-formula-editor]")?.addEventListener("focus", () => {
12627
+ lastFocusedFormulaWidget = widget;
12628
+ });
12629
+ };
11881
12630
  if (cfg.mode === "single") {
11882
12631
  const exprGroup = createElement("div", { className: "mb-3" });
11883
12632
  exprGroup.appendChild(createElement("label", {
11884
12633
  className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
11885
12634
  text: "Expression"
11886
12635
  }));
11887
- const exprTextarea = createElement("textarea", {
11888
- className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent font-mono text-sm resize-none",
11889
- placeholder: "e.g. {fieldA} + {fieldB} * 1.18",
11890
- rows: "3"
11891
- });
11892
- exprTextarea.value = cfg.single?.expression ?? "";
11893
- exprTextarea.addEventListener("focus", () => {
11894
- lastFocusedExprTextarea = exprTextarea;
11895
- });
11896
12636
  const exprError = createElement("div", { className: "text-xs text-red-500 mt-1 hidden" });
11897
- exprTextarea.addEventListener("input", () => {
11898
- const expr = exprTextarea.value;
11899
- const result = validateFormulaExpression(expr, availableFields.map((f) => f.fieldName));
11900
- if (result.valid || !expr.trim()) {
11901
- exprError.classList.add("hidden");
11902
- } else {
11903
- exprError.textContent = result.error;
11904
- exprError.classList.remove("hidden");
12637
+ const singleWidget = new FormulaEditorWidget({
12638
+ mode: "bracket",
12639
+ // Use ALL schema fields for the fieldMap so any ref resolves to its label
12640
+ availableFields: fwFieldInfos,
12641
+ // Source of truth: always read from persisted cfg, never from a
12642
+ // closure variable. This guarantees correct reload after field switch.
12643
+ initialValue: cfg.single?.expression ?? "",
12644
+ placeholder: "e.g. {fieldA} + {fieldB} * 1.18",
12645
+ onChange: (expr) => {
12646
+ const result = validateFormulaExpression(expr, fwFieldInfos.map((f) => f.fieldName));
12647
+ const isValid2 = result.valid || !expr.trim();
12648
+ singleWidget.setError(!isValid2);
12649
+ exprError.textContent = isValid2 ? "" : result.error;
12650
+ exprError.classList.toggle("hidden", isValid2);
12651
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12652
+ formStore.getState().updateField(selectedField.id, {
12653
+ formulaConfig: { ...freshCfg, single: { expression: expr } }
12654
+ });
11905
12655
  }
11906
- const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
11907
- formStore.getState().updateField(selectedField.id, { formulaConfig: { ...freshCfg, single: { expression: expr } } });
11908
12656
  });
11909
- exprGroup.appendChild(exprTextarea);
12657
+ registerFormulaWidget(singleWidget);
12658
+ lastFocusedFormulaWidget = singleWidget;
12659
+ exprGroup.appendChild(singleWidget.getElement());
11910
12660
  exprGroup.appendChild(exprError);
11911
12661
  body.appendChild(exprGroup);
11912
12662
  } else {
@@ -11933,16 +12683,15 @@ var FormBuilder = class {
11933
12683
  });
11934
12684
  compareGroup.appendChild(compareSelect);
11935
12685
  body.appendChild(compareGroup);
11936
- const conditionsLabel = createElement("label", {
12686
+ body.appendChild(createElement("label", {
11937
12687
  className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-2",
11938
12688
  text: "Conditions"
11939
- });
11940
- body.appendChild(conditionsLabel);
12689
+ }));
11941
12690
  const conditions = cfg.multiple?.conditions ?? [];
11942
12691
  conditions.forEach((cond, idx) => {
11943
12692
  const row = createElement("div", { className: "mb-2 p-2 rounded-md border border-gray-100 dark:border-gray-800 space-y-1" });
11944
- const whenLabel = createElement("div", { className: "text-xs text-gray-500 dark:text-gray-400", text: "When equals" });
11945
- const valueInput = createElement("input", {
12693
+ row.appendChild(createElement("div", { className: "text-xs text-gray-500 dark:text-gray-400", text: "When equals" }));
12694
+ row.appendChild(createElement("input", {
11946
12695
  type: "text",
11947
12696
  className: "w-full px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
11948
12697
  value: cond.value,
@@ -11954,25 +12703,26 @@ var FormBuilder = class {
11954
12703
  newConds[idx] = { ...newConds[idx], value: v };
11955
12704
  patchMultiple({ conditions: newConds });
11956
12705
  }
11957
- });
11958
- const exprLabel = createElement("div", { className: "text-xs text-gray-500 dark:text-gray-400", text: "\u2192 Expression" });
11959
- const condTextarea = createElement("textarea", {
11960
- className: "w-full px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded-md bg-transparent font-mono resize-none",
12706
+ }));
12707
+ row.appendChild(createElement("div", { className: "text-xs text-gray-500 dark:text-gray-400", text: "\u2192 Expression" }));
12708
+ const condWidget = new FormulaEditorWidget({
12709
+ mode: "bracket",
12710
+ availableFields: fwFieldInfos,
12711
+ // all-schema fieldMap
12712
+ initialValue: cond.expression ?? "",
11961
12713
  placeholder: "e.g. {fieldA} + {fieldB}",
11962
- rows: "2"
11963
- });
11964
- condTextarea.value = cond.expression ?? "";
11965
- condTextarea.addEventListener("focus", () => {
11966
- lastFocusedExprTextarea = condTextarea;
11967
- });
11968
- condTextarea.addEventListener("input", () => {
11969
- const expr = condTextarea.value;
11970
- const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
11971
- const newConds = [...freshCfg.multiple?.conditions ?? []];
11972
- newConds[idx] = { ...newConds[idx], expression: expr };
11973
- patchMultiple({ conditions: newConds });
12714
+ onChange: (expr) => {
12715
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12716
+ const newConds = [...freshCfg.multiple?.conditions ?? []];
12717
+ newConds[idx] = { ...newConds[idx], expression: expr };
12718
+ patchMultiple({ conditions: newConds });
12719
+ }
11974
12720
  });
11975
- const removeBtn = createElement("button", {
12721
+ registerFormulaWidget(condWidget);
12722
+ if (!lastFocusedFormulaWidget)
12723
+ lastFocusedFormulaWidget = condWidget;
12724
+ row.appendChild(condWidget.getElement());
12725
+ row.appendChild(createElement("button", {
11976
12726
  type: "button",
11977
12727
  className: "text-xs text-red-500 hover:text-red-700 dark:text-red-400",
11978
12728
  text: "\u2212 Remove",
@@ -11983,12 +12733,7 @@ var FormBuilder = class {
11983
12733
  patchMultiple({ conditions: newConds });
11984
12734
  this.render();
11985
12735
  }
11986
- });
11987
- row.appendChild(whenLabel);
11988
- row.appendChild(valueInput);
11989
- row.appendChild(exprLabel);
11990
- row.appendChild(condTextarea);
11991
- row.appendChild(removeBtn);
12736
+ }));
11992
12737
  body.appendChild(row);
11993
12738
  });
11994
12739
  body.appendChild(createElement("button", {
@@ -12007,22 +12752,22 @@ var FormBuilder = class {
12007
12752
  className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12008
12753
  text: "Fallback Expression"
12009
12754
  }));
12010
- const fallbackTextarea = createElement("textarea", {
12011
- className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent font-mono text-sm resize-none",
12755
+ const fallbackWidget = new FormulaEditorWidget({
12756
+ mode: "bracket",
12757
+ availableFields: fwFieldInfos,
12758
+ initialValue: cfg.multiple?.fallbackExpression ?? "",
12012
12759
  placeholder: "e.g. 0",
12013
- rows: "2"
12014
- });
12015
- fallbackTextarea.value = cfg.multiple?.fallbackExpression ?? "";
12016
- fallbackTextarea.addEventListener("focus", () => {
12017
- lastFocusedExprTextarea = fallbackTextarea;
12018
- });
12019
- fallbackTextarea.addEventListener("input", () => {
12020
- patchMultiple({ fallbackExpression: fallbackTextarea.value });
12760
+ onChange: (expr) => {
12761
+ patchMultiple({ fallbackExpression: expr });
12762
+ }
12021
12763
  });
12022
- fallbackGroup.appendChild(fallbackTextarea);
12764
+ registerFormulaWidget(fallbackWidget);
12765
+ if (!lastFocusedFormulaWidget)
12766
+ lastFocusedFormulaWidget = fallbackWidget;
12767
+ fallbackGroup.appendChild(fallbackWidget.getElement());
12023
12768
  body.appendChild(fallbackGroup);
12024
12769
  }
12025
- if (availableFields.length > 0) {
12770
+ if (fwFieldInfos.length > 0) {
12026
12771
  const insertGroup = createElement("div", { className: "mb-3" });
12027
12772
  insertGroup.appendChild(createElement("label", {
12028
12773
  className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
@@ -12035,27 +12780,20 @@ var FormBuilder = class {
12035
12780
  const ref = sel.value;
12036
12781
  if (!ref)
12037
12782
  return;
12038
- const ta = lastFocusedExprTextarea;
12039
- if (ta) {
12040
- const start = ta.selectionStart ?? ta.value.length;
12041
- const end = ta.selectionEnd ?? ta.value.length;
12042
- const before = ta.value.slice(0, start);
12043
- const after = ta.value.slice(end);
12044
- const pad = before.length > 0 && !/\s$/.test(before) ? " " : "";
12045
- ta.value = before + pad + ref + after;
12046
- const newPos = before.length + pad.length + ref.length;
12047
- ta.selectionStart = ta.selectionEnd = newPos;
12048
- ta.dispatchEvent(new Event("input", { bubbles: true }));
12049
- ta.focus();
12783
+ const field = fwFieldInfos.find((f) => f.fieldName === ref || f.id === ref);
12784
+ const target = lastFocusedFormulaWidget;
12785
+ if (field && target) {
12786
+ target.insertField(field);
12050
12787
  }
12051
12788
  sel.value = "";
12052
12789
  }
12053
12790
  });
12054
12791
  insertSelect.appendChild(createElement("option", { value: "", text: "Select field to insert\u2026", selected: true }));
12055
- availableFields.forEach((f) => {
12792
+ fwFieldInfos.forEach((f) => {
12056
12793
  insertSelect.appendChild(createElement("option", {
12057
- value: `{${f.fieldName}}`,
12058
- text: `${f.label} ({${f.fieldName}})`
12794
+ value: f.fieldName !== f.id ? f.fieldName : f.id,
12795
+ text: f.label
12796
+ // show only human-readable label
12059
12797
  }));
12060
12798
  });
12061
12799
  insertGroup.appendChild(insertSelect);
@@ -12086,16 +12824,7 @@ var FormBuilder = class {
12086
12824
  className: "px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded font-mono hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors",
12087
12825
  text: op.text,
12088
12826
  onclick: () => {
12089
- const ta = lastFocusedExprTextarea;
12090
- if (!ta)
12091
- return;
12092
- const start = ta.selectionStart ?? ta.value.length;
12093
- const end = ta.selectionEnd ?? ta.value.length;
12094
- ta.value = ta.value.slice(0, start) + op.insert + ta.value.slice(end);
12095
- const newPos = start + op.insert.length;
12096
- ta.selectionStart = ta.selectionEnd = newPos;
12097
- ta.dispatchEvent(new Event("input", { bubbles: true }));
12098
- ta.focus();
12827
+ lastFocusedFormulaWidget?.insertText(op.insert);
12099
12828
  }
12100
12829
  }));
12101
12830
  });