@dmitryvim/form-builder 0.5.3 → 0.6.4

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/esm/index.js CHANGED
@@ -3,12 +3,12 @@ function t(key, state, params) {
3
3
  const locale = state.config.locale || "en";
4
4
  const localeTranslations = state.config.translations[locale];
5
5
  const fallbackTranslations = state.config.translations.en;
6
- let text = localeTranslations?.[key] || fallbackTranslations?.[key] || key;
6
+ let text = localeTranslations?.[key] ?? fallbackTranslations?.[key] ?? key;
7
7
  if (params) {
8
8
  for (const [paramKey, paramValue] of Object.entries(params)) {
9
9
  text = text.replace(
10
10
  new RegExp(`\\{${paramKey}\\}`, "g"),
11
- String(paramValue)
11
+ () => String(paramValue)
12
12
  );
13
13
  }
14
14
  }
@@ -23,9 +23,7 @@ function isPlainObject(obj) {
23
23
  return obj && typeof obj === "object" && obj.constructor === Object;
24
24
  }
25
25
  function escapeHtml(text) {
26
- const div = document.createElement("div");
27
- div.textContent = text;
28
- return div.innerHTML;
26
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
29
27
  }
30
28
  function getElementLookupKey(element, state) {
31
29
  if (element.key) {
@@ -60,8 +58,8 @@ function formatFileSize(bytes) {
60
58
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
61
59
  }
62
60
  function serializeHiddenValue(value) {
63
- if (value === null || value === void 0) return "";
64
- return typeof value === "object" ? JSON.stringify(value) : String(value);
61
+ if (value === void 0) return "";
62
+ return JSON.stringify(value);
65
63
  }
66
64
  function deserializeHiddenValue(raw) {
67
65
  if (raw === "") return null;
@@ -71,6 +69,23 @@ function deserializeHiddenValue(raw) {
71
69
  return raw;
72
70
  }
73
71
  }
72
+ function readTypedInputValue(input) {
73
+ if (input instanceof HTMLInputElement) {
74
+ if (input.type === "checkbox") return input.checked;
75
+ if (input.dataset.hiddenField) return deserializeHiddenValue(input.value);
76
+ if (input.dataset.booleanField) return input.value === "true";
77
+ if (input.type === "number" || input.type === "range") {
78
+ if (input.value === "") return null;
79
+ const parsed = parseFloat(input.value);
80
+ const decimals = input.dataset.decimals;
81
+ return decimals !== void 0 ? Number(parsed.toFixed(parseInt(decimals, 10))) : parsed;
82
+ }
83
+ if (input.dataset.colourField) {
84
+ return input.value.toUpperCase();
85
+ }
86
+ }
87
+ return input.value === "" ? null : input.value;
88
+ }
74
89
  function createHiddenInput(name, value) {
75
90
  const input = document.createElement("input");
76
91
  input.type = "hidden";
@@ -308,6 +323,14 @@ function validateSchema(schema) {
308
323
  }
309
324
  }
310
325
  function validateElements(elements, path) {
326
+ const seenKeys = /* @__PURE__ */ new Set();
327
+ elements.forEach((element, index) => {
328
+ if (!element.key) return;
329
+ if (seenKeys.has(element.key)) {
330
+ errors.push(`${path}[${index}]: duplicate key "${element.key}"`);
331
+ }
332
+ seenKeys.add(element.key);
333
+ });
311
334
  elements.forEach((element, index) => {
312
335
  const elementPath = `${path}[${index}]`;
313
336
  if (!element.type) {
@@ -317,6 +340,14 @@ function validateSchema(schema) {
317
340
  errors.push(`${elementPath}: missing key`);
318
341
  }
319
342
  validateCountBounds(element, elementPath, errors);
343
+ if (element.type === "number" && "decimals" in element) {
344
+ const decimals = element.decimals;
345
+ if (decimals !== void 0 && (!Number.isInteger(decimals) || decimals < 0)) {
346
+ errors.push(
347
+ `${elementPath}: decimals must be a non-negative integer (got ${JSON.stringify(decimals)})`
348
+ );
349
+ }
350
+ }
320
351
  if (element.type === "markdown") {
321
352
  const content = element.content;
322
353
  if (typeof content !== "string") {
@@ -467,12 +498,55 @@ function deepEqual(a, b) {
467
498
  }
468
499
 
469
500
  // src/utils/styles.ts
470
- function clearFieldError(input) {
501
+ function findErrorAnchor(input) {
502
+ return input.closest?.(".fb-chip") ?? input.closest?.(".slider-container") ?? input;
503
+ }
504
+ function findErrorNode(input) {
505
+ const anchor = findErrorAnchor(input);
471
506
  const name = input.getAttribute("name");
472
- if (!name) return;
473
- const doc = input.ownerDocument || document;
474
- const errorNode = doc.getElementById(`error-${name}`);
475
- if (errorNode) errorNode.remove();
507
+ const parent = anchor.parentElement;
508
+ if (name && parent) {
509
+ for (const child of Array.from(parent.children)) {
510
+ if (child.classList.contains("error-message") && child.getAttribute("data-error-for") === name) {
511
+ return child;
512
+ }
513
+ }
514
+ }
515
+ const sibling = anchor.nextElementSibling;
516
+ return sibling && sibling.classList.contains("error-message") ? sibling : null;
517
+ }
518
+ function markFieldValidity(input, errorMessage) {
519
+ if (!input) return;
520
+ if (errorMessage == null) {
521
+ input.classList.remove("invalid");
522
+ input.title = "";
523
+ findErrorNode(input)?.remove();
524
+ return;
525
+ }
526
+ input.classList.add("invalid");
527
+ input.title = errorMessage;
528
+ if (errorMessage === "") {
529
+ findErrorNode(input)?.remove();
530
+ return;
531
+ }
532
+ let errorElement = findErrorNode(input);
533
+ if (!errorElement) {
534
+ const anchor = findErrorAnchor(input);
535
+ errorElement = document.createElement("div");
536
+ errorElement.className = "error-message";
537
+ errorElement.style.cssText = `
538
+ color: var(--fb-error-color);
539
+ font-size: var(--fb-font-size-small);
540
+ margin-top: 0.25rem;
541
+ `;
542
+ anchor.parentNode?.insertBefore(errorElement, anchor.nextSibling);
543
+ }
544
+ errorElement.setAttribute("data-error-for", input.getAttribute("name") ?? "");
545
+ errorElement.textContent = errorMessage;
546
+ errorElement.style.display = "block";
547
+ }
548
+ function clearFieldError(input) {
549
+ findErrorNode(input)?.remove();
476
550
  }
477
551
  var BIN_ICON_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
478
552
  function ensureThemingHooks(doc) {
@@ -594,7 +668,7 @@ function ensureThemingHooks(doc) {
594
668
  `;
595
669
  doc.head.appendChild(style);
596
670
  }
597
- function applyAutoExpand(textarea, options = {}) {
671
+ function applyAutoExpand(textarea, options) {
598
672
  textarea.style.overflow = "hidden";
599
673
  textarea.style.resize = "none";
600
674
  const minRows = Math.max(1, options.minRows ?? 1);
@@ -624,6 +698,7 @@ function applyAutoExpand(textarea, options = {}) {
624
698
  const ro = new ResizeObserver((entries) => {
625
699
  if (!textarea.isConnected) {
626
700
  ro.disconnect();
701
+ options.observers?.delete(ro);
627
702
  return;
628
703
  }
629
704
  const entry = entries[0];
@@ -633,6 +708,7 @@ function applyAutoExpand(textarea, options = {}) {
633
708
  resize();
634
709
  });
635
710
  ro.observe(textarea);
711
+ options.observers?.add(ro);
636
712
  }
637
713
  function applySingleLineMode(textarea) {
638
714
  textarea.addEventListener("keydown", (e) => {
@@ -943,11 +1019,11 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
943
1019
  overflow-wrap: anywhere;
944
1020
  `;
945
1021
  textInput.name = pathKey;
946
- textInput.placeholder = element.placeholder ?? "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
947
- textInput.value = ctx.prefill[element.key] || element.default || "";
1022
+ textInput.placeholder = element.placeholder ?? t("placeholderText", state);
1023
+ textInput.value = ctx.prefill[element.key] ?? element.default ?? "";
948
1024
  textInput.readOnly = readonly;
949
1025
  applySingleLineMode(textInput);
950
- applyAutoExpand(textInput);
1026
+ applyAutoExpand(textInput, { observers: state.autoExpandObservers });
951
1027
  if (!readonly) {
952
1028
  textInput.addEventListener("focus", () => {
953
1029
  textInput.style.borderColor = "var(--fb-border-focus-color)";
@@ -1005,7 +1081,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1005
1081
  const chip = input.closest(".fb-chip");
1006
1082
  const sib = chip?.nextElementSibling;
1007
1083
  if (sib && sib.classList.contains("error-message")) {
1008
- sib.id = `error-${input.name}`;
1084
+ sib.setAttribute("data-error-for", input.name);
1009
1085
  }
1010
1086
  });
1011
1087
  }
@@ -1020,7 +1096,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1020
1096
  input.rows = 1;
1021
1097
  input.className = "fb-chip-input";
1022
1098
  input.value = value;
1023
- input.placeholder = element.placeholder || t("placeholderText", state);
1099
+ input.placeholder = element.placeholder ?? t("placeholderText", state);
1024
1100
  input.readOnly = readonly;
1025
1101
  chip.appendChild(input);
1026
1102
  if (!readonly && ctx.instance) {
@@ -1034,7 +1110,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1034
1110
  input.addEventListener("input", handleChange);
1035
1111
  }
1036
1112
  applySingleLineMode(input);
1037
- applyAutoExpand(input);
1113
+ applyAutoExpand(input, { observers: state.autoExpandObservers });
1038
1114
  if (!readonly) {
1039
1115
  const rem = document.createElement("button");
1040
1116
  rem.type = "button";
@@ -1055,6 +1131,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1055
1131
  updateIndices();
1056
1132
  updateAddButton();
1057
1133
  updateRemoveButtons();
1134
+ ctx.instance?.triggerOnChange(pathKey);
1058
1135
  };
1059
1136
  chip.appendChild(rem);
1060
1137
  }
@@ -1079,6 +1156,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1079
1156
  addChip(element.default || "");
1080
1157
  updateAddButton();
1081
1158
  updateRemoveButtons();
1159
+ ctx.instance?.triggerOnChange(pathKey);
1082
1160
  },
1083
1161
  { label: element.addLabel }
1084
1162
  );
@@ -1096,40 +1174,6 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1096
1174
  function validateTextElement(element, key, context) {
1097
1175
  const errors = [];
1098
1176
  const { scopeRoot, skipValidation } = context;
1099
- const markValidity = (input, errorMessage) => {
1100
- if (!input) return;
1101
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
1102
- let errorElement = document.getElementById(errorId);
1103
- if (errorMessage) {
1104
- input.classList.add("invalid");
1105
- input.title = errorMessage;
1106
- if (!errorElement) {
1107
- errorElement = document.createElement("div");
1108
- errorElement.id = errorId;
1109
- errorElement.className = "error-message";
1110
- errorElement.style.cssText = `
1111
- color: var(--fb-error-color);
1112
- font-size: var(--fb-font-size-small);
1113
- margin-top: 0.25rem;
1114
- `;
1115
- const chipAncestor = input.closest?.(".fb-chip");
1116
- const anchor = chipAncestor || input;
1117
- if (anchor.nextSibling) {
1118
- anchor.parentNode?.insertBefore(errorElement, anchor.nextSibling);
1119
- } else {
1120
- anchor.parentNode?.appendChild(errorElement);
1121
- }
1122
- }
1123
- errorElement.textContent = errorMessage;
1124
- errorElement.style.display = "block";
1125
- } else {
1126
- input.classList.remove("invalid");
1127
- input.title = "";
1128
- if (errorElement) {
1129
- errorElement.remove();
1130
- }
1131
- }
1132
- };
1133
1177
  const validateTextInput = (input, val, fieldKey) => {
1134
1178
  let hasError = false;
1135
1179
  const { state } = context;
@@ -1137,12 +1181,12 @@ function validateTextElement(element, key, context) {
1137
1181
  if (element.minLength !== void 0 && element.minLength !== null && val.length < element.minLength) {
1138
1182
  const msg = t("minLength", state, { min: element.minLength });
1139
1183
  errors.push(`${fieldKey}: ${msg}`);
1140
- markValidity(input, msg);
1184
+ markFieldValidity(input, msg);
1141
1185
  hasError = true;
1142
1186
  } else if (element.maxLength !== void 0 && element.maxLength !== null && val.length > element.maxLength) {
1143
1187
  const msg = t("maxLength", state, { max: element.maxLength });
1144
1188
  errors.push(`${fieldKey}: ${msg}`);
1145
- markValidity(input, msg);
1189
+ markFieldValidity(input, msg);
1146
1190
  hasError = true;
1147
1191
  } else if (element.pattern) {
1148
1192
  try {
@@ -1150,19 +1194,19 @@ function validateTextElement(element, key, context) {
1150
1194
  if (!re.test(val)) {
1151
1195
  const msg = t("patternMismatch", state);
1152
1196
  errors.push(`${fieldKey}: ${msg}`);
1153
- markValidity(input, msg);
1197
+ markFieldValidity(input, msg);
1154
1198
  hasError = true;
1155
1199
  }
1156
1200
  } catch {
1157
1201
  const msg = t("invalidPattern", state);
1158
1202
  errors.push(`${fieldKey}: ${msg}`);
1159
- markValidity(input, msg);
1203
+ markFieldValidity(input, msg);
1160
1204
  hasError = true;
1161
1205
  }
1162
1206
  }
1163
1207
  }
1164
1208
  if (!hasError) {
1165
- markValidity(input, null);
1209
+ markFieldValidity(input, null);
1166
1210
  }
1167
1211
  };
1168
1212
  if (element.multiple) {
@@ -1192,12 +1236,12 @@ function validateTextElement(element, key, context) {
1192
1236
  }
1193
1237
  return { value: values, errors };
1194
1238
  } else {
1195
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1239
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
1196
1240
  const val = input?.value ?? "";
1197
1241
  if (!skipValidation && element.required && val === "") {
1198
1242
  const msg = t("required", context.state);
1199
1243
  errors.push(`${key}: ${msg}`);
1200
- markValidity(input, msg);
1244
+ markFieldValidity(input, msg);
1201
1245
  return { value: null, errors };
1202
1246
  }
1203
1247
  if (input) {
@@ -1259,8 +1303,8 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1259
1303
  line-height: var(--fb-line-height, 1.5);
1260
1304
  `;
1261
1305
  textareaInput.name = pathKey;
1262
- textareaInput.placeholder = element.placeholder ?? "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
1263
- textareaInput.value = ctx.prefill[element.key] || element.default || "";
1306
+ textareaInput.placeholder = element.placeholder ?? t("placeholderText", state);
1307
+ textareaInput.value = ctx.prefill[element.key] ?? element.default ?? "";
1264
1308
  textareaInput.readOnly = readonly;
1265
1309
  if (!readonly && ctx.instance) {
1266
1310
  const handleChange = () => {
@@ -1270,7 +1314,10 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1270
1314
  textareaInput.addEventListener("blur", handleChange);
1271
1315
  textareaInput.addEventListener("input", handleChange);
1272
1316
  }
1273
- applyAutoExpand(textareaInput, { minRows: element.rows ?? 1 });
1317
+ applyAutoExpand(textareaInput, {
1318
+ minRows: element.rows ?? 1,
1319
+ observers: state.autoExpandObservers
1320
+ });
1274
1321
  textareaWrapper.appendChild(textareaInput);
1275
1322
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1276
1323
  const counter = createCharCounter(element, textareaInput);
@@ -1313,7 +1360,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1313
1360
  font-family: var(--fb-font-family);
1314
1361
  line-height: var(--fb-line-height, 1.5);
1315
1362
  `;
1316
- textareaInput.placeholder = element.placeholder || t("placeholderText", state);
1363
+ textareaInput.placeholder = element.placeholder ?? t("placeholderText", state);
1317
1364
  textareaInput.value = value;
1318
1365
  textareaInput.readOnly = readonly;
1319
1366
  if (!readonly && ctx.instance) {
@@ -1324,7 +1371,10 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1324
1371
  textareaInput.addEventListener("blur", handleChange);
1325
1372
  textareaInput.addEventListener("input", handleChange);
1326
1373
  }
1327
- applyAutoExpand(textareaInput, { minRows: element.rows ?? 1 });
1374
+ applyAutoExpand(textareaInput, {
1375
+ minRows: element.rows ?? 1,
1376
+ observers: state.autoExpandObservers
1377
+ });
1328
1378
  textareaContainer.appendChild(textareaInput);
1329
1379
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1330
1380
  const counter = createCharCounter(element, textareaInput);
@@ -1362,6 +1412,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1362
1412
  updateIndices();
1363
1413
  updateAddButton();
1364
1414
  updateRemoveButtons();
1415
+ ctx.instance?.triggerOnChange(pathKey);
1365
1416
  }
1366
1417
  };
1367
1418
  item.appendChild(removeBtn);
@@ -1381,6 +1432,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1381
1432
  addTextareaItem(element.default || "");
1382
1433
  updateAddButton();
1383
1434
  updateRemoveButtons();
1435
+ ctx.instance?.triggerOnChange(pathKey);
1384
1436
  },
1385
1437
  { label: element.addLabel }
1386
1438
  );
@@ -1541,6 +1593,17 @@ function createNumberRangeHint(element, input) {
1541
1593
  updateColor();
1542
1594
  return hint;
1543
1595
  }
1596
+ function numberStepAttr(element) {
1597
+ if (element.step !== void 0) return element.step.toString();
1598
+ if (element.decimals !== void 0)
1599
+ return (10 ** -element.decimals).toString();
1600
+ return "any";
1601
+ }
1602
+ function applyDecimalsMarker(input, element) {
1603
+ if (element.decimals !== void 0) {
1604
+ input.setAttribute("data-decimals", String(element.decimals));
1605
+ }
1606
+ }
1544
1607
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1545
1608
  const state = ctx.state;
1546
1609
  const readonly = isElementReadonly(element, state, ctx);
@@ -1549,11 +1612,12 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1549
1612
  const numberInput = document.createElement("input");
1550
1613
  numberInput.type = "number";
1551
1614
  numberInput.name = pathKey;
1552
- numberInput.placeholder = element.placeholder || "0";
1615
+ numberInput.placeholder = element.placeholder ?? "0";
1553
1616
  if (element.min !== void 0) numberInput.min = element.min.toString();
1554
1617
  if (element.max !== void 0) numberInput.max = element.max.toString();
1555
- if (element.step !== void 0) numberInput.step = element.step.toString();
1556
- numberInput.value = ctx.prefill[element.key] || element.default || "";
1618
+ numberInput.step = numberStepAttr(element);
1619
+ applyDecimalsMarker(numberInput, element);
1620
+ numberInput.value = ctx.prefill[element.key] ?? element.default ?? "";
1557
1621
  numberInput.readOnly = readonly;
1558
1622
  if (!element.stepper) {
1559
1623
  numberInput.className = "w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
@@ -1592,7 +1656,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1592
1656
  const minCount = element.minCount ?? (element.required ? 1 : 0);
1593
1657
  const maxCount = element.maxCount ?? Infinity;
1594
1658
  while (values.length < minCount) {
1595
- values.push(element.default || "");
1659
+ values.push(element.default ?? "");
1596
1660
  }
1597
1661
  const container = document.createElement("div");
1598
1662
  container.className = "fb-row";
@@ -1621,10 +1685,11 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1621
1685
  width: 100%;
1622
1686
  box-sizing: border-box;
1623
1687
  `;
1624
- numberInput.placeholder = element.placeholder || "0";
1688
+ numberInput.placeholder = element.placeholder ?? "0";
1625
1689
  if (element.min !== void 0) numberInput.min = element.min.toString();
1626
1690
  if (element.max !== void 0) numberInput.max = element.max.toString();
1627
- if (element.step !== void 0) numberInput.step = element.step.toString();
1691
+ numberInput.step = numberStepAttr(element);
1692
+ applyDecimalsMarker(numberInput, element);
1628
1693
  numberInput.value = value.toString();
1629
1694
  numberInput.readOnly = readonly;
1630
1695
  if (!readonly && ctx.instance) {
@@ -1672,6 +1737,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1672
1737
  updateIndices();
1673
1738
  updateAddButton();
1674
1739
  updateRemoveButtons();
1740
+ ctx.instance?.triggerOnChange(pathKey);
1675
1741
  }
1676
1742
  };
1677
1743
  item.appendChild(removeBtn);
@@ -1687,10 +1753,11 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1687
1753
  const handle = createAddItemRow(
1688
1754
  "number",
1689
1755
  () => {
1690
- values.push(element.default || "");
1691
- addNumberItem(element.default || "");
1756
+ values.push(element.default ?? "");
1757
+ addNumberItem(element.default ?? "");
1692
1758
  updateAddButton();
1693
1759
  updateRemoveButtons();
1760
+ ctx.instance?.triggerOnChange(pathKey);
1694
1761
  },
1695
1762
  { label: element.addLabel }
1696
1763
  );
@@ -1708,54 +1775,22 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1708
1775
  function validateNumberElement(element, key, context) {
1709
1776
  const errors = [];
1710
1777
  const { scopeRoot, skipValidation } = context;
1711
- const markValidity = (input, errorMessage) => {
1712
- if (!input) return;
1713
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
1714
- let errorElement = document.getElementById(errorId);
1715
- if (errorMessage) {
1716
- input.classList.add("invalid");
1717
- input.title = errorMessage;
1718
- if (!errorElement) {
1719
- errorElement = document.createElement("div");
1720
- errorElement.id = errorId;
1721
- errorElement.className = "error-message";
1722
- errorElement.style.cssText = `
1723
- color: var(--fb-error-color);
1724
- font-size: var(--fb-font-size-small);
1725
- margin-top: 0.25rem;
1726
- `;
1727
- if (input.nextSibling) {
1728
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
1729
- } else {
1730
- input.parentNode?.appendChild(errorElement);
1731
- }
1732
- }
1733
- errorElement.textContent = errorMessage;
1734
- errorElement.style.display = "block";
1735
- } else {
1736
- input.classList.remove("invalid");
1737
- input.title = "";
1738
- if (errorElement) {
1739
- errorElement.remove();
1740
- }
1741
- }
1742
- };
1743
1778
  const validateNumberInput = (input, v, fieldKey) => {
1744
1779
  let hasError = false;
1745
1780
  const { state } = context;
1746
1781
  if (!skipValidation && element.min !== void 0 && element.min !== null && v < element.min) {
1747
1782
  const msg = t("minValue", state, { min: element.min });
1748
1783
  errors.push(`${fieldKey}: ${msg}`);
1749
- markValidity(input, msg);
1784
+ markFieldValidity(input, msg);
1750
1785
  hasError = true;
1751
1786
  } else if (!skipValidation && element.max !== void 0 && element.max !== null && v > element.max) {
1752
1787
  const msg = t("maxValue", state, { max: element.max });
1753
1788
  errors.push(`${fieldKey}: ${msg}`);
1754
- markValidity(input, msg);
1789
+ markFieldValidity(input, msg);
1755
1790
  hasError = true;
1756
1791
  }
1757
1792
  if (!hasError) {
1758
- markValidity(input, null);
1793
+ markFieldValidity(input, null);
1759
1794
  }
1760
1795
  };
1761
1796
  if (element.multiple) {
@@ -1767,20 +1802,19 @@ function validateNumberElement(element, key, context) {
1767
1802
  const raw = input?.value ?? "";
1768
1803
  if (raw === "") {
1769
1804
  values.push(null);
1770
- markValidity(input, null);
1805
+ markFieldValidity(input, null);
1771
1806
  return;
1772
1807
  }
1773
1808
  const v = parseFloat(raw);
1774
1809
  if (!skipValidation && !Number.isFinite(v)) {
1775
1810
  const msg = t("notANumber", context.state);
1776
1811
  errors.push(`${key}[${index}]: ${msg}`);
1777
- markValidity(input, msg);
1812
+ markFieldValidity(input, msg);
1778
1813
  values.push(null);
1779
1814
  return;
1780
1815
  }
1781
1816
  validateNumberInput(input, v, `${key}[${index}]`);
1782
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals ?? 0 : 0;
1783
- values.push(Number(v.toFixed(d)));
1817
+ values.push(applyDecimals(v, element.decimals));
1784
1818
  });
1785
1819
  if (!skipValidation) {
1786
1820
  const { state } = context;
@@ -1799,31 +1833,34 @@ function validateNumberElement(element, key, context) {
1799
1833
  }
1800
1834
  return { value: values, errors };
1801
1835
  } else {
1802
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1836
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
1803
1837
  const raw = input?.value ?? "";
1804
1838
  const { state } = context;
1805
1839
  if (!skipValidation && element.required && raw === "") {
1806
1840
  const msg = t("required", state);
1807
1841
  errors.push(`${key}: ${msg}`);
1808
- markValidity(input, msg);
1842
+ markFieldValidity(input, msg);
1809
1843
  return { value: null, errors };
1810
1844
  }
1811
1845
  if (raw === "") {
1812
- markValidity(input, null);
1846
+ markFieldValidity(input, null);
1813
1847
  return { value: null, errors };
1814
1848
  }
1815
1849
  const v = parseFloat(raw);
1816
1850
  if (!skipValidation && !Number.isFinite(v)) {
1817
1851
  const msg = t("notANumber", state);
1818
1852
  errors.push(`${key}: ${msg}`);
1819
- markValidity(input, msg);
1853
+ markFieldValidity(input, msg);
1820
1854
  return { value: null, errors };
1821
1855
  }
1822
1856
  validateNumberInput(input, v, key);
1823
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals ?? 0 : 0;
1824
- return { value: Number(v.toFixed(d)), errors };
1857
+ return { value: applyDecimals(v, element.decimals), errors };
1825
1858
  }
1826
1859
  }
1860
+ function applyDecimals(v, decimals) {
1861
+ if (!Number.isInteger(decimals) || decimals < 0) return v;
1862
+ return Number(v.toFixed(decimals));
1863
+ }
1827
1864
  function updateNumberField(element, fieldPath, value, context) {
1828
1865
  const { scopeRoot } = context;
1829
1866
  if (element.multiple) {
@@ -1863,6 +1900,38 @@ function updateNumberField(element, fieldPath, value, context) {
1863
1900
  }
1864
1901
 
1865
1902
  // src/components/select.ts
1903
+ function appendSelectOptions(select, element, selectedValue, state) {
1904
+ const options = element.options || [];
1905
+ if (!options.some((option) => option.value === "")) {
1906
+ const emptyOption = document.createElement("option");
1907
+ emptyOption.value = "";
1908
+ emptyOption.textContent = element.placeholder ?? t("selectPlaceholder", state);
1909
+ select.appendChild(emptyOption);
1910
+ }
1911
+ const strSelected = selectedValue == null ? null : String(selectedValue);
1912
+ let anySelected = false;
1913
+ options.forEach((option) => {
1914
+ const optionEl = document.createElement("option");
1915
+ optionEl.value = option.value;
1916
+ optionEl.textContent = option.label;
1917
+ if (strSelected === option.value) {
1918
+ optionEl.selected = true;
1919
+ anySelected = true;
1920
+ }
1921
+ select.appendChild(optionEl);
1922
+ });
1923
+ if (!anySelected && strSelected !== null && strSelected !== "") {
1924
+ console.warn(
1925
+ `select "${element.key}": prefill value "${strSelected}" is not among the options; leaving the field unselected`
1926
+ );
1927
+ }
1928
+ if (!anySelected) {
1929
+ const empty = Array.from(select.options).find(
1930
+ (option) => option.value === ""
1931
+ );
1932
+ if (empty) empty.selected = true;
1933
+ }
1934
+ }
1866
1935
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1867
1936
  const state = ctx.state;
1868
1937
  const readonly = isElementReadonly(element, state, ctx);
@@ -1875,18 +1944,18 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1875
1944
  `;
1876
1945
  selectInput.name = pathKey;
1877
1946
  selectInput.disabled = readonly;
1878
- (element.options || []).forEach((option) => {
1879
- const optionEl = document.createElement("option");
1880
- optionEl.value = option.value;
1881
- optionEl.textContent = option.label;
1882
- if ((ctx.prefill[element.key] || element.default) === option.value) {
1883
- optionEl.selected = true;
1884
- }
1885
- selectInput.appendChild(optionEl);
1886
- });
1947
+ appendSelectOptions(
1948
+ selectInput,
1949
+ element,
1950
+ ctx.prefill[element.key] ?? element.default,
1951
+ state
1952
+ );
1887
1953
  if (!readonly && ctx.instance) {
1888
1954
  const handleChange = () => {
1889
- ctx.instance.triggerOnChange(pathKey, selectInput.value);
1955
+ ctx.instance.triggerOnChange(
1956
+ pathKey,
1957
+ selectInput.value === "" ? null : selectInput.value
1958
+ );
1890
1959
  };
1891
1960
  selectInput.addEventListener("change", handleChange);
1892
1961
  }
@@ -1906,7 +1975,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1906
1975
  const minCount = element.minCount ?? (element.required ? 1 : 0);
1907
1976
  const maxCount = element.maxCount ?? Infinity;
1908
1977
  while (values.length < minCount) {
1909
- values.push(element.default || element.options?.[0]?.value || "");
1978
+ values.push(element.default ?? "");
1910
1979
  }
1911
1980
  const container = document.createElement("div");
1912
1981
  container.className = "fb-row";
@@ -1931,15 +2000,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1931
2000
  font-family: var(--fb-font-family);
1932
2001
  `;
1933
2002
  selectInput.disabled = readonly;
1934
- (element.options || []).forEach((option) => {
1935
- const optionElement = document.createElement("option");
1936
- optionElement.value = option.value;
1937
- optionElement.textContent = option.label;
1938
- if (value === option.value) {
1939
- optionElement.selected = true;
1940
- }
1941
- selectInput.appendChild(optionElement);
1942
- });
2003
+ appendSelectOptions(selectInput, element, value, state);
1943
2004
  if (!readonly && ctx.instance) {
1944
2005
  const handleChange = () => {
1945
2006
  ctx.instance.triggerOnChange(selectInput.name, selectInput.value);
@@ -1976,6 +2037,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1976
2037
  updateIndices();
1977
2038
  updateAddButton();
1978
2039
  updateRemoveButtons();
2040
+ ctx.instance?.triggerOnChange(pathKey);
1979
2041
  }
1980
2042
  };
1981
2043
  item.appendChild(removeBtn);
@@ -1991,11 +2053,12 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1991
2053
  const handle = createAddItemRow(
1992
2054
  "select",
1993
2055
  () => {
1994
- const defaultValue = element.default || element.options?.[0]?.value || "";
2056
+ const defaultValue = element.default ?? "";
1995
2057
  values.push(defaultValue);
1996
2058
  addSelectItem(defaultValue);
1997
2059
  updateAddButton();
1998
2060
  updateRemoveButtons();
2061
+ ctx.instance?.triggerOnChange(pathKey);
1999
2062
  },
2000
2063
  { label: element.addLabel }
2001
2064
  );
@@ -2019,38 +2082,6 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2019
2082
  function validateSelectElement(element, key, context) {
2020
2083
  const errors = [];
2021
2084
  const { scopeRoot, skipValidation } = context;
2022
- const markValidity = (input, errorMessage) => {
2023
- if (!input) return;
2024
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
2025
- let errorElement = document.getElementById(errorId);
2026
- if (errorMessage) {
2027
- input.classList.add("invalid");
2028
- input.title = errorMessage;
2029
- if (!errorElement) {
2030
- errorElement = document.createElement("div");
2031
- errorElement.id = errorId;
2032
- errorElement.className = "error-message";
2033
- errorElement.style.cssText = `
2034
- color: var(--fb-error-color);
2035
- font-size: var(--fb-font-size-small);
2036
- margin-top: 0.25rem;
2037
- `;
2038
- if (input.nextSibling) {
2039
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
2040
- } else {
2041
- input.parentNode?.appendChild(errorElement);
2042
- }
2043
- }
2044
- errorElement.textContent = errorMessage;
2045
- errorElement.style.display = "block";
2046
- } else {
2047
- input.classList.remove("invalid");
2048
- input.title = "";
2049
- if (errorElement) {
2050
- errorElement.remove();
2051
- }
2052
- }
2053
- };
2054
2085
  const validateMultipleCount = (key2, values, element2, filterFn) => {
2055
2086
  if (skipValidation) return;
2056
2087
  const { state } = context;
@@ -2074,27 +2105,36 @@ function validateSelectElement(element, key, context) {
2074
2105
  const values = [];
2075
2106
  inputs.forEach((input) => {
2076
2107
  const val = input?.value ?? "";
2077
- values.push(val);
2078
- markValidity(input, null);
2108
+ values.push(val === "" ? null : val);
2109
+ markFieldValidity(input, null);
2079
2110
  });
2080
- validateMultipleCount(key, values, element, (v) => v !== "");
2111
+ validateMultipleCount(key, values, element, (v) => v != null);
2081
2112
  return { value: values, errors };
2082
2113
  } else {
2083
- const input = scopeRoot.querySelector(
2084
- `[name$="${key}"]`
2085
- );
2114
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
2086
2115
  const val = input?.value ?? "";
2087
2116
  if (!skipValidation && element.required && val === "") {
2088
2117
  const msg = t("required", context.state);
2089
2118
  errors.push(`${key}: ${msg}`);
2090
- markValidity(input, msg);
2119
+ markFieldValidity(input, msg);
2091
2120
  return { value: null, errors };
2092
2121
  } else {
2093
- markValidity(input, null);
2122
+ markFieldValidity(input, null);
2094
2123
  }
2095
2124
  return { value: val === "" ? null : val, errors };
2096
2125
  }
2097
2126
  }
2127
+ function assertValueInOptions(select, strValue, fieldPath) {
2128
+ if (strValue === "") return;
2129
+ const match = Array.from(select.options).some(
2130
+ (option) => option.value === strValue
2131
+ );
2132
+ if (!match) {
2133
+ throw new Error(
2134
+ `updateSelectField: value "${strValue}" is not among the options of "${fieldPath}"`
2135
+ );
2136
+ }
2137
+ }
2098
2138
  function updateSelectField(element, fieldPath, value, context) {
2099
2139
  const { scopeRoot } = context;
2100
2140
  if ("multiple" in element && element.multiple) {
@@ -2109,10 +2149,17 @@ function updateSelectField(element, fieldPath, value, context) {
2109
2149
  );
2110
2150
  selects.forEach((select, index) => {
2111
2151
  if (index < value.length) {
2112
- select.value = value[index] != null ? String(value[index]) : "";
2152
+ const strValue = value[index] != null ? String(value[index]) : "";
2153
+ assertValueInOptions(select, strValue, `${fieldPath}[${index}]`);
2154
+ }
2155
+ });
2156
+ selects.forEach((select, index) => {
2157
+ if (index < value.length) {
2158
+ const strValue = value[index] != null ? String(value[index]) : "";
2159
+ select.value = strValue;
2113
2160
  const options = select.querySelectorAll("option");
2114
2161
  options.forEach((option) => {
2115
- option.selected = option.value === String(value[index]);
2162
+ option.selected = option.value === strValue;
2116
2163
  });
2117
2164
  select.classList.remove("invalid");
2118
2165
  select.title = "";
@@ -2129,10 +2176,12 @@ function updateSelectField(element, fieldPath, value, context) {
2129
2176
  `[name="${fieldPath}"]`
2130
2177
  );
2131
2178
  if (select) {
2132
- select.value = value != null ? String(value) : "";
2179
+ const strValue = value != null ? String(value) : "";
2180
+ assertValueInOptions(select, strValue, fieldPath);
2181
+ select.value = strValue;
2133
2182
  const options = select.querySelectorAll("option");
2134
2183
  options.forEach((option) => {
2135
- option.selected = option.value === String(value);
2184
+ option.selected = option.value === strValue;
2136
2185
  });
2137
2186
  select.classList.remove("invalid");
2138
2187
  select.title = "";
@@ -2335,7 +2384,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2335
2384
  const minCount = element.minCount ?? (element.required ? 1 : 0);
2336
2385
  const maxCount = element.maxCount ?? Infinity;
2337
2386
  while (values.length < minCount) {
2338
- values.push(element.default || element.options?.[0]?.value || "");
2387
+ values.push(element.default ?? "");
2339
2388
  }
2340
2389
  const readonly = isElementReadonly(element, state, ctx);
2341
2390
  const container = document.createElement("div");
@@ -2413,6 +2462,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2413
2462
  updateIndices();
2414
2463
  updateAddButton();
2415
2464
  updateRemoveButtons();
2465
+ ctx.instance?.triggerOnChange(pathKey);
2416
2466
  }
2417
2467
  };
2418
2468
  item.appendChild(removeBtn);
@@ -2428,11 +2478,12 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2428
2478
  const handle = createAddItemRow(
2429
2479
  "switcher",
2430
2480
  () => {
2431
- const defaultValue = element.default || element.options?.[0]?.value || "";
2481
+ const defaultValue = element.default ?? "";
2432
2482
  values.push(defaultValue);
2433
2483
  addSwitcherItem(defaultValue);
2434
2484
  updateAddButton();
2435
2485
  updateRemoveButtons();
2486
+ ctx.instance?.triggerOnChange(pathKey);
2436
2487
  },
2437
2488
  { label: element.addLabel }
2438
2489
  );
@@ -2456,38 +2507,6 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2456
2507
  function validateSwitcherElement(element, key, context) {
2457
2508
  const errors = [];
2458
2509
  const { scopeRoot, skipValidation } = context;
2459
- const markValidity = (input, errorMessage) => {
2460
- if (!input) return;
2461
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
2462
- let errorElement = document.getElementById(errorId);
2463
- if (errorMessage) {
2464
- input.classList.add("invalid");
2465
- input.title = errorMessage;
2466
- if (!errorElement) {
2467
- errorElement = document.createElement("div");
2468
- errorElement.id = errorId;
2469
- errorElement.className = "error-message";
2470
- errorElement.style.cssText = `
2471
- color: var(--fb-error-color);
2472
- font-size: var(--fb-font-size-small);
2473
- margin-top: 0.25rem;
2474
- `;
2475
- if (input.nextSibling) {
2476
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
2477
- } else {
2478
- input.parentNode?.appendChild(errorElement);
2479
- }
2480
- }
2481
- errorElement.textContent = errorMessage;
2482
- errorElement.style.display = "block";
2483
- } else {
2484
- input.classList.remove("invalid");
2485
- input.title = "";
2486
- if (errorElement) {
2487
- errorElement.remove();
2488
- }
2489
- }
2490
- };
2491
2510
  const validateMultipleCount = (fieldKey, values, el, filterFn) => {
2492
2511
  if (skipValidation) return;
2493
2512
  const { state } = context;
@@ -2514,35 +2533,35 @@ function validateSwitcherElement(element, key, context) {
2514
2533
  const values = [];
2515
2534
  inputs.forEach((input) => {
2516
2535
  const val = input?.value ?? "";
2517
- values.push(val);
2536
+ values.push(val === "" ? null : val);
2518
2537
  if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2519
2538
  const msg = t("invalidOption", context.state);
2520
- markValidity(input, msg);
2539
+ markFieldValidity(input, msg);
2521
2540
  errors.push(`${key}: ${msg}`);
2522
2541
  } else {
2523
- markValidity(input, null);
2542
+ markFieldValidity(input, null);
2524
2543
  }
2525
2544
  });
2526
- validateMultipleCount(key, values, element, (v) => v !== "");
2545
+ validateMultipleCount(key, values, element, (v) => v != null);
2527
2546
  return { value: values, errors };
2528
2547
  } else {
2529
2548
  const input = scopeRoot.querySelector(
2530
- `input[type="hidden"][name$="${key}"]`
2549
+ `input[type="hidden"][name="${key}"]`
2531
2550
  );
2532
2551
  const val = input?.value ?? "";
2533
2552
  if (!skipValidation && element.required && val === "") {
2534
2553
  const msg = t("required", context.state);
2535
2554
  errors.push(`${key}: ${msg}`);
2536
- markValidity(input, msg);
2555
+ markFieldValidity(input, msg);
2537
2556
  return { value: null, errors };
2538
2557
  }
2539
2558
  if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2540
2559
  const msg = t("invalidOption", context.state);
2541
2560
  errors.push(`${key}: ${msg}`);
2542
- markValidity(input, msg);
2561
+ markFieldValidity(input, msg);
2543
2562
  return { value: null, errors };
2544
2563
  }
2545
- markValidity(input, null);
2564
+ markFieldValidity(input, null);
2546
2565
  return { value: val === "" ? null : val, errors };
2547
2566
  }
2548
2567
  }
@@ -2714,6 +2733,7 @@ function renderBooleanElement(element, ctx, wrapper, pathKey) {
2714
2733
  const hiddenInput = document.createElement("input");
2715
2734
  hiddenInput.type = "hidden";
2716
2735
  hiddenInput.name = pathKey;
2736
+ hiddenInput.setAttribute("data-boolean-field", "true");
2717
2737
  hiddenInput.value = initial ? "true" : "false";
2718
2738
  const row = document.createElement("div");
2719
2739
  row.className = "fb-toggle-row";
@@ -5569,7 +5589,7 @@ function validateSingleFile(element, key, context) {
5569
5589
  const { scopeRoot, skipValidation, state } = context;
5570
5590
  const errors = [];
5571
5591
  const input = scopeRoot.querySelector(
5572
- `input[name$="${key}"][type="hidden"]`
5592
+ `input[name="${key}"][type="hidden"]`
5573
5593
  );
5574
5594
  const rid = input?.value ?? "";
5575
5595
  if (!skipValidation && element.required && rid === "") {
@@ -5789,7 +5809,7 @@ function createReadonlyColourUI(value) {
5789
5809
  container.appendChild(hexText);
5790
5810
  return container;
5791
5811
  }
5792
- function createEditColourUI(value, pathKey, ctx) {
5812
+ function createEditColourUI(value, pathKey, ctx, placeholder) {
5793
5813
  const normalizedValue = normalizeColourValue(value);
5794
5814
  const pickerWrapper = document.createElement("div");
5795
5815
  pickerWrapper.className = "colour-picker-wrapper";
@@ -5813,9 +5833,10 @@ function createEditColourUI(value, pathKey, ctx) {
5813
5833
  const hexInput = document.createElement("input");
5814
5834
  hexInput.type = "text";
5815
5835
  hexInput.className = "colour-hex-input";
5836
+ hexInput.setAttribute("data-colour-field", "true");
5816
5837
  hexInput.name = pathKey;
5817
5838
  hexInput.value = normalizedValue;
5818
- hexInput.placeholder = "#000000";
5839
+ hexInput.placeholder = placeholder ?? "#000000";
5819
5840
  hexInput.style.cssText = `
5820
5841
  width: 100px;
5821
5842
  padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
@@ -5904,12 +5925,17 @@ function createEditColourUI(value, pathKey, ctx) {
5904
5925
  function renderColourElement(element, ctx, wrapper, pathKey) {
5905
5926
  const state = ctx.state;
5906
5927
  const readonly = isElementReadonly(element, state, ctx);
5907
- const initialValue = ctx.prefill[element.key] || element.default || "#000000";
5928
+ const initialValue = ctx.prefill[element.key] ?? element.default ?? "#000000";
5908
5929
  if (readonly) {
5909
5930
  const readonlyUI = createReadonlyColourUI(initialValue);
5910
5931
  wrapper.appendChild(readonlyUI);
5911
5932
  } else {
5912
- const editUI = createEditColourUI(initialValue, pathKey, ctx);
5933
+ const editUI = createEditColourUI(
5934
+ initialValue,
5935
+ pathKey,
5936
+ ctx,
5937
+ element.placeholder
5938
+ );
5913
5939
  wrapper.appendChild(editUI);
5914
5940
  }
5915
5941
  if (!readonly) {
@@ -5931,7 +5957,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5931
5957
  const minCount = element.minCount ?? (element.required ? 1 : 0);
5932
5958
  const maxCount = element.maxCount ?? Infinity;
5933
5959
  while (values.length < minCount) {
5934
- values.push(element.default || "#000000");
5960
+ values.push(element.default ?? "#000000");
5935
5961
  }
5936
5962
  const container = document.createElement("div");
5937
5963
  container.className = "fb-row";
@@ -5955,7 +5981,12 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5955
5981
  }
5956
5982
  } else {
5957
5983
  const tempPathKey = `${pathKey}[${container.children.length}]`;
5958
- const editUI = createEditColourUI(value, tempPathKey, ctx);
5984
+ const editUI = createEditColourUI(
5985
+ value,
5986
+ tempPathKey,
5987
+ ctx,
5988
+ element.placeholder
5989
+ );
5959
5990
  editUI.style.flex = "1";
5960
5991
  itemWrapper.appendChild(editUI);
5961
5992
  }
@@ -6001,6 +6032,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6001
6032
  updateIndices();
6002
6033
  updateAddButton();
6003
6034
  updateRemoveButtons();
6035
+ ctx.instance?.triggerOnChange(pathKey);
6004
6036
  }
6005
6037
  };
6006
6038
  item.appendChild(removeBtn);
@@ -6016,11 +6048,12 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6016
6048
  const handle = createAddItemRow(
6017
6049
  "colour",
6018
6050
  () => {
6019
- const defaultColour = element.default || "#000000";
6051
+ const defaultColour = element.default ?? "#000000";
6020
6052
  values.push(defaultColour);
6021
6053
  addColourItem(defaultColour);
6022
6054
  updateAddButton();
6023
6055
  updateRemoveButtons();
6056
+ ctx.instance?.triggerOnChange(pathKey);
6024
6057
  },
6025
6058
  { label: element.addLabel }
6026
6059
  );
@@ -6048,58 +6081,26 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6048
6081
  function validateColourElement(element, key, context) {
6049
6082
  const errors = [];
6050
6083
  const { scopeRoot, skipValidation } = context;
6051
- const markValidity = (input, errorMessage) => {
6052
- if (!input) return;
6053
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
6054
- let errorElement = document.getElementById(errorId);
6055
- if (errorMessage) {
6056
- input.classList.add("invalid");
6057
- input.title = errorMessage;
6058
- if (!errorElement) {
6059
- errorElement = document.createElement("div");
6060
- errorElement.id = errorId;
6061
- errorElement.className = "error-message";
6062
- errorElement.style.cssText = `
6063
- color: var(--fb-error-color);
6064
- font-size: var(--fb-font-size-small);
6065
- margin-top: 0.25rem;
6066
- `;
6067
- if (input.nextSibling) {
6068
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
6069
- } else {
6070
- input.parentNode?.appendChild(errorElement);
6071
- }
6072
- }
6073
- errorElement.textContent = errorMessage;
6074
- errorElement.style.display = "block";
6075
- } else {
6076
- input.classList.remove("invalid");
6077
- input.title = "";
6078
- if (errorElement) {
6079
- errorElement.remove();
6080
- }
6081
- }
6082
- };
6083
6084
  const validateColourValue = (input, val, fieldKey) => {
6084
6085
  const { state } = context;
6085
6086
  if (!val) {
6086
6087
  if (!skipValidation && element.required) {
6087
6088
  const msg = t("required", state);
6088
6089
  errors.push(`${fieldKey}: ${msg}`);
6089
- markValidity(input, msg);
6090
+ markFieldValidity(input, msg);
6090
6091
  return "";
6091
6092
  }
6092
- markValidity(input, null);
6093
+ markFieldValidity(input, null);
6093
6094
  return "";
6094
6095
  }
6095
6096
  const normalized = normalizeColourValue(val);
6096
6097
  if (!skipValidation && !isValidHexColour(normalized)) {
6097
6098
  const msg = t("invalidHexColour", state);
6098
6099
  errors.push(`${fieldKey}: ${msg}`);
6099
- markValidity(input, msg);
6100
+ markFieldValidity(input, msg);
6100
6101
  return val;
6101
6102
  }
6102
- markValidity(input, null);
6103
+ markFieldValidity(input, null);
6103
6104
  return normalized;
6104
6105
  };
6105
6106
  if (element.multiple) {
@@ -6136,7 +6137,7 @@ function validateColourElement(element, key, context) {
6136
6137
  if (!skipValidation && element.required && val === "") {
6137
6138
  const msg = t("required", context.state);
6138
6139
  errors.push(`${key}: ${msg}`);
6139
- markValidity(hexInput, msg);
6140
+ markFieldValidity(hexInput, msg);
6140
6141
  return { value: "", errors };
6141
6142
  }
6142
6143
  const validated = validateColourValue(hexInput, val, key);
@@ -6457,6 +6458,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
6457
6458
  updateIndices();
6458
6459
  updateAddButton();
6459
6460
  updateRemoveButtons();
6461
+ ctx.instance?.triggerOnChange(pathKey);
6460
6462
  }
6461
6463
  };
6462
6464
  item.appendChild(removeBtn);
@@ -6476,6 +6478,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
6476
6478
  addSliderItem(defaultValue);
6477
6479
  updateAddButton();
6478
6480
  updateRemoveButtons();
6481
+ ctx.instance?.triggerOnChange(pathKey);
6479
6482
  },
6480
6483
  { label: element.addLabel }
6481
6484
  );
@@ -6517,42 +6520,6 @@ function validateSliderElement(element, key, context) {
6517
6520
  const max = element.max;
6518
6521
  const step = element.step ?? 1;
6519
6522
  const scale = element.scale || "linear";
6520
- const markValidity = (input, errorMessage) => {
6521
- if (!input) return;
6522
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
6523
- let errorElement = document.getElementById(errorId);
6524
- if (errorMessage) {
6525
- input.classList.add("invalid");
6526
- input.title = errorMessage;
6527
- if (!errorElement) {
6528
- errorElement = document.createElement("div");
6529
- errorElement.id = errorId;
6530
- errorElement.className = "error-message";
6531
- errorElement.style.cssText = `
6532
- color: var(--fb-error-color);
6533
- font-size: var(--fb-font-size-small);
6534
- margin-top: 0.25rem;
6535
- `;
6536
- const sliderContainer = input.closest(".slider-container");
6537
- if (sliderContainer && sliderContainer.nextSibling) {
6538
- sliderContainer.parentNode?.insertBefore(
6539
- errorElement,
6540
- sliderContainer.nextSibling
6541
- );
6542
- } else if (sliderContainer) {
6543
- sliderContainer.parentNode?.appendChild(errorElement);
6544
- }
6545
- }
6546
- errorElement.textContent = errorMessage;
6547
- errorElement.style.display = "block";
6548
- } else {
6549
- input.classList.remove("invalid");
6550
- input.title = "";
6551
- if (errorElement) {
6552
- errorElement.remove();
6553
- }
6554
- }
6555
- };
6556
6523
  const validateSliderValue = (slider, fieldKey) => {
6557
6524
  const { state } = context;
6558
6525
  const rawValue = slider.value;
@@ -6560,10 +6527,10 @@ function validateSliderElement(element, key, context) {
6560
6527
  if (!skipValidation && element.required) {
6561
6528
  const msg = t("required", state);
6562
6529
  errors.push(`${fieldKey}: ${msg}`);
6563
- markValidity(slider, msg);
6530
+ markFieldValidity(slider, msg);
6564
6531
  return null;
6565
6532
  }
6566
- markValidity(slider, null);
6533
+ markFieldValidity(slider, null);
6567
6534
  return null;
6568
6535
  }
6569
6536
  let value;
@@ -6579,17 +6546,17 @@ function validateSliderElement(element, key, context) {
6579
6546
  if (value < min) {
6580
6547
  const msg = t("minValue", state, { min });
6581
6548
  errors.push(`${fieldKey}: ${msg}`);
6582
- markValidity(slider, msg);
6549
+ markFieldValidity(slider, msg);
6583
6550
  return value;
6584
6551
  }
6585
6552
  if (value > max) {
6586
6553
  const msg = t("maxValue", state, { max });
6587
6554
  errors.push(`${fieldKey}: ${msg}`);
6588
- markValidity(slider, msg);
6555
+ markFieldValidity(slider, msg);
6589
6556
  return value;
6590
6557
  }
6591
6558
  }
6592
- markValidity(slider, null);
6559
+ markFieldValidity(slider, null);
6593
6560
  return value;
6594
6561
  };
6595
6562
  if (element.multiple) {
@@ -6734,20 +6701,12 @@ function extractRootFormData(formRoot) {
6734
6701
  inputs.forEach((input) => {
6735
6702
  const fieldName = input.getAttribute("name");
6736
6703
  if (fieldName && !fieldName.includes("[") && !fieldName.includes(".")) {
6737
- if (input instanceof HTMLSelectElement) {
6738
- data[fieldName] = input.value;
6739
- } else if (input instanceof HTMLInputElement) {
6740
- if (input.type === "checkbox") {
6741
- data[fieldName] = input.checked;
6742
- } else if (input.type === "radio") {
6743
- if (input.checked) {
6744
- data[fieldName] = input.value;
6745
- }
6746
- } else {
6704
+ if (input instanceof HTMLInputElement && input.type === "radio") {
6705
+ if (input.checked) {
6747
6706
  data[fieldName] = input.value;
6748
6707
  }
6749
- } else if (input instanceof HTMLTextAreaElement) {
6750
- data[fieldName] = input.value;
6708
+ } else {
6709
+ data[fieldName] = readTypedInputValue(input);
6751
6710
  }
6752
6711
  }
6753
6712
  });
@@ -6816,7 +6775,7 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
6816
6775
  };
6817
6776
  element.elements.forEach((child) => {
6818
6777
  if (child.type !== "markdown" && (child.hidden || child.type === "hidden")) {
6819
- const prefillVal = containerPrefill[child.key] ?? ("default" in child ? child.default : null) ?? null;
6778
+ const prefillVal = child.key in containerPrefill ? containerPrefill[child.key] : ("default" in child ? child.default : null) ?? null;
6820
6779
  itemsWrap.appendChild(
6821
6780
  createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal)
6822
6781
  );
@@ -6861,7 +6820,7 @@ function mountRemoveButton(item, onRemove, state, containerLabel) {
6861
6820
  item.classList.add("fb-row-removable");
6862
6821
  item.insertBefore(rem, item.firstChild);
6863
6822
  }
6864
- function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6823
+ function renderMultipleContainerElement(element, ctx, wrapper, pathKey) {
6865
6824
  const state = ctx.state;
6866
6825
  const containerIsReadonly = isElementReadonly(element, state, ctx);
6867
6826
  const childInheritedReadonly = containerIsReadonly || ctx.inheritedReadonly;
@@ -6894,6 +6853,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6894
6853
  if (countItems() <= min) return;
6895
6854
  item.remove();
6896
6855
  updateAddButton();
6856
+ ctx.instance?.triggerOnChange(pathKey);
6897
6857
  };
6898
6858
  const createContainerItem = (idx, rowPrefill, formData) => {
6899
6859
  const subCtx = {
@@ -6916,7 +6876,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6916
6876
  );
6917
6877
  element.elements.forEach((child) => {
6918
6878
  if (child.type !== "markdown" && (child.hidden || child.type === "hidden")) {
6919
- const hiddenValue = rowPrefill?.[child.key] ?? ("default" in child ? child.default : null) ?? null;
6879
+ const hiddenValue = rowPrefill && child.key in rowPrefill ? rowPrefill[child.key] : ("default" in child ? child.default : null) ?? null;
6920
6880
  childWrapper.appendChild(
6921
6881
  createHiddenInput(pathJoin(subCtx.path, child.key), hiddenValue)
6922
6882
  );
@@ -6950,6 +6910,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6950
6910
  itemsWrap.appendChild(item);
6951
6911
  }
6952
6912
  updateAddButton();
6913
+ ctx.instance?.triggerOnChange(pathKey);
6953
6914
  };
6954
6915
  let slideAddTile = null;
6955
6916
  let slideAddUpdate = null;
@@ -7024,19 +6985,16 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
7024
6985
  }
7025
6986
  }
7026
6987
  }
7027
- var validateElementFunc = null;
7028
- function setValidateElement(fn) {
7029
- validateElementFunc = fn;
7030
- }
7031
- function validateElement(element, ctx, customScopeRoot) {
7032
- if (!validateElementFunc) {
6988
+ function requireValidateElement(context) {
6989
+ if (!context.validateElement) {
7033
6990
  throw new Error(
7034
- "validateElement not initialized. Should be set from FormBuilderInstance"
6991
+ "validateContainerElement: context.validateElement missing \u2014 container validation requires the instance validator"
7035
6992
  );
7036
6993
  }
7037
- return validateElementFunc(element, ctx, customScopeRoot);
6994
+ return context.validateElement;
7038
6995
  }
7039
6996
  function validateContainerElement(element, key, context) {
6997
+ const validateChild = requireValidateElement(context);
7040
6998
  const errors = [];
7041
6999
  const { scopeRoot, skipValidation, path } = context;
7042
7000
  if (!("elements" in element)) {
@@ -7087,7 +7045,7 @@ function validateContainerElement(element, key, context) {
7087
7045
  }
7088
7046
  }
7089
7047
  const childKey = `${key}[${domIndex}].${child.key}`;
7090
- const childResult = validateElement(
7048
+ const childResult = validateChild(
7091
7049
  { ...child, key: childKey },
7092
7050
  { path },
7093
7051
  itemContainer
@@ -7128,7 +7086,7 @@ function validateContainerElement(element, key, context) {
7128
7086
  }
7129
7087
  {
7130
7088
  const childKey = `${key}.${child.key}`;
7131
- const childResult = validateElement(
7089
+ const childResult = validateChild(
7132
7090
  { ...child, key: childKey },
7133
7091
  { path },
7134
7092
  containerContainer
@@ -7243,7 +7201,7 @@ function renderGroupElement(element, ctx, wrapper, pathKey) {
7243
7201
  maxCount: element.repeat?.max
7244
7202
  };
7245
7203
  if (containerElement.multiple) {
7246
- renderMultipleContainerElement(containerElement, ctx, wrapper);
7204
+ renderMultipleContainerElement(containerElement, ctx, wrapper, pathKey);
7247
7205
  } else {
7248
7206
  renderSingleContainerElement(containerElement, ctx, wrapper, pathKey);
7249
7207
  }
@@ -7619,7 +7577,7 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
7619
7577
  rebuild();
7620
7578
  } catch (e) {
7621
7579
  const errMsg = e instanceof Error ? e.message : String(e);
7622
- console.error(t("tableImportError", state).replace("{error}", errMsg));
7580
+ console.error(t("tableImportError", state, { error: errMsg }));
7623
7581
  } finally {
7624
7582
  overlay.remove();
7625
7583
  }
@@ -8561,7 +8519,7 @@ function updateTableField(element, fieldPath, value, context) {
8561
8519
  }
8562
8520
 
8563
8521
  // src/components/richinput.ts
8564
- function applyAutoExpand2(textarea, backdrop) {
8522
+ function applyAutoExpand2(textarea, backdrop, observers) {
8565
8523
  textarea.style.overflow = "hidden";
8566
8524
  textarea.style.resize = "none";
8567
8525
  const lineCount = (textarea.value.match(/\n/g) || []).length + 1;
@@ -8583,6 +8541,7 @@ function applyAutoExpand2(textarea, backdrop) {
8583
8541
  const ro = new ResizeObserver((entries) => {
8584
8542
  if (!textarea.isConnected) {
8585
8543
  ro.disconnect();
8544
+ observers.delete(ro);
8586
8545
  return;
8587
8546
  }
8588
8547
  const entry = entries[0];
@@ -8592,6 +8551,7 @@ function applyAutoExpand2(textarea, backdrop) {
8592
8551
  resize();
8593
8552
  });
8594
8553
  ro.observe(textarea);
8554
+ observers.add(ro);
8595
8555
  }
8596
8556
  function buildFileLabels(files, state) {
8597
8557
  const labels = /* @__PURE__ */ new Map();
@@ -9161,7 +9121,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
9161
9121
  `;
9162
9122
  const textarea = document.createElement("textarea");
9163
9123
  textarea.name = `${pathKey}__text`;
9164
- textarea.placeholder = element.placeholder || t("richinputPlaceholder", state);
9124
+ textarea.placeholder = element.placeholder ?? t("richinputPlaceholder", state);
9165
9125
  const rawInitialText = initialValue.text ?? "";
9166
9126
  textarea.value = rawInitialText ? replaceRidsWithFilenames(rawInitialText, files, state) : "";
9167
9127
  textarea.style.cssText = `
@@ -9178,7 +9138,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
9178
9138
  z-index: 1;
9179
9139
  caret-color: var(--fb-text-color, #111827);
9180
9140
  `;
9181
- applyAutoExpand2(textarea, backdrop);
9141
+ applyAutoExpand2(textarea, backdrop, ctx.state.autoExpandObservers);
9182
9142
  textarea.addEventListener("scroll", () => {
9183
9143
  backdrop.scrollTop = textarea.scrollTop;
9184
9144
  });
@@ -10151,12 +10111,7 @@ function validateHiddenElement(element, key, context) {
10151
10111
  const input = scopeRoot.querySelector(
10152
10112
  `input[type="hidden"][data-hidden-field="true"][name="${key}"]`
10153
10113
  );
10154
- const raw = input?.value ?? "";
10155
- if (raw === "") {
10156
- const defaultVal = "default" in element ? element.default : null;
10157
- return { value: defaultVal !== void 0 ? defaultVal : null, errors: [] };
10158
- }
10159
- return { value: deserializeHiddenValue(raw), errors: [] };
10114
+ return { value: deserializeHiddenValue(input?.value ?? ""), errors: [] };
10160
10115
  }
10161
10116
  function updateHiddenField(_element, fieldPath, value, context) {
10162
10117
  const { scopeRoot } = context;
@@ -10240,15 +10195,19 @@ var componentRegistry = {
10240
10195
  function getComponentOperations(elementType) {
10241
10196
  return componentRegistry[elementType] || null;
10242
10197
  }
10198
+ function resolveOperations(element) {
10199
+ const isHiddenField = element.type !== "markdown" && (element.type === "hidden" || Boolean(element.hidden));
10200
+ return isHiddenField ? componentRegistry.hidden : getComponentOperations(element.type);
10201
+ }
10243
10202
  function validateElementWithComponent(element, key, context) {
10244
- const ops = getComponentOperations(element.type);
10203
+ const ops = resolveOperations(element);
10245
10204
  if (ops && ops.validate) {
10246
10205
  return ops.validate(element, key, context);
10247
10206
  }
10248
10207
  return null;
10249
10208
  }
10250
10209
  function updateElementWithComponent(element, fieldPath, value, context) {
10251
- const ops = getComponentOperations(element.type);
10210
+ const ops = resolveOperations(element);
10252
10211
  if (ops && ops.update) {
10253
10212
  ops.update(element, fieldPath, value, context);
10254
10213
  return true;
@@ -10260,6 +10219,10 @@ function updateElementWithComponent(element, fieldPath, value, context) {
10260
10219
  function showTooltip(tooltipId, button) {
10261
10220
  const tooltip = document.getElementById(tooltipId);
10262
10221
  if (!tooltip) return;
10222
+ if (!button.isConnected) {
10223
+ tooltip.remove();
10224
+ return;
10225
+ }
10263
10226
  const isCurrentlyVisible = !tooltip.classList.contains("hidden");
10264
10227
  document.querySelectorAll('[id^="tooltip-"]').forEach((t2) => {
10265
10228
  t2.classList.add("hidden");
@@ -10339,23 +10302,13 @@ function extractDOMValue(fieldPath, formRoot) {
10339
10302
  if (!input) {
10340
10303
  return void 0;
10341
10304
  }
10342
- if (input instanceof HTMLSelectElement) {
10343
- return input.value;
10344
- } else if (input instanceof HTMLInputElement) {
10345
- if (input.type === "checkbox") {
10346
- return input.checked;
10347
- } else if (input.type === "radio") {
10348
- const checked = formRoot.querySelector(
10349
- `[name="${fieldPath}"]:checked`
10350
- );
10351
- return checked ? checked.value : void 0;
10352
- } else {
10353
- return input.value;
10354
- }
10355
- } else if (input instanceof HTMLTextAreaElement) {
10356
- return input.value;
10305
+ if (input instanceof HTMLInputElement && input.type === "radio") {
10306
+ const checked = formRoot.querySelector(
10307
+ `[name="${fieldPath}"]:checked`
10308
+ );
10309
+ return checked ? checked.value : void 0;
10357
10310
  }
10358
- return void 0;
10311
+ return readTypedInputValue(input);
10359
10312
  }
10360
10313
  function buildScopedDataAtPath(path, value) {
10361
10314
  const segments = path.match(/[^.[\]]+|\[\d+\]/g);
@@ -10484,11 +10437,19 @@ function createFieldLabel(element) {
10484
10437
  }
10485
10438
  return title;
10486
10439
  }
10487
- function createInfoButton(element) {
10440
+ function ensureTooltipStyles(doc) {
10441
+ if (doc.head.querySelector("[data-fb-tooltip-styles]")) return;
10442
+ const style = doc.createElement("style");
10443
+ style.setAttribute("data-fb-tooltip-styles", "");
10444
+ style.textContent = `[id^="tooltip-"].hidden { display: none; }`;
10445
+ doc.head.appendChild(style);
10446
+ }
10447
+ function createInfoButton(element, state) {
10488
10448
  const infoBtn = document.createElement("button");
10489
10449
  infoBtn.type = "button";
10490
10450
  infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
10491
10451
  infoBtn.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
10452
+ ensureTooltipStyles(document);
10492
10453
  const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
10493
10454
  const tooltip = document.createElement("div");
10494
10455
  tooltip.id = tooltipId;
@@ -10496,6 +10457,7 @@ function createInfoButton(element) {
10496
10457
  tooltip.style.position = "fixed";
10497
10458
  tooltip.textContent = element.description || element.hint || "Field information";
10498
10459
  document.body.appendChild(tooltip);
10460
+ state.tooltipElements.add(tooltip);
10499
10461
  infoBtn.onclick = (e) => {
10500
10462
  e.preventDefault();
10501
10463
  e.stopPropagation();
@@ -10503,7 +10465,7 @@ function createInfoButton(element) {
10503
10465
  };
10504
10466
  return infoBtn;
10505
10467
  }
10506
- function createLabelContainer(element) {
10468
+ function createLabelContainer(element, state) {
10507
10469
  const label = document.createElement("div");
10508
10470
  label.className = "flex items-center";
10509
10471
  label.style.marginBottom = "var(--fb-label-margin-bottom, 2px)";
@@ -10511,7 +10473,7 @@ function createLabelContainer(element) {
10511
10473
  const title = createFieldLabel(element);
10512
10474
  label.appendChild(title);
10513
10475
  if (element.description || element.hint) {
10514
- const infoBtn = createInfoButton(element);
10476
+ const infoBtn = createInfoButton(element, state);
10515
10477
  label.appendChild(infoBtn);
10516
10478
  }
10517
10479
  return label;
@@ -10586,7 +10548,7 @@ function dispatchToRenderer(element, ctx, wrapper, pathKey) {
10586
10548
  break;
10587
10549
  case "container":
10588
10550
  if (isMultiple) {
10589
- renderMultipleContainerElement(element, ctx, wrapper);
10551
+ renderMultipleContainerElement(element, ctx, wrapper, pathKey);
10590
10552
  } else {
10591
10553
  renderSingleContainerElement(element, ctx, wrapper, pathKey);
10592
10554
  }
@@ -10639,7 +10601,7 @@ function renderElement2(element, ctx) {
10639
10601
  wrapper.setAttribute("data-fb-width", element.width || "full");
10640
10602
  const ops = getComponentOperations(element.type);
10641
10603
  if (!ops?.ownsLabel) {
10642
- const label = createLabelContainer(element);
10604
+ const label = createLabelContainer(element, ctx.state);
10643
10605
  wrapper.appendChild(label);
10644
10606
  }
10645
10607
  const pathKey = pathJoin(ctx.path, element.key);
@@ -10668,6 +10630,7 @@ var defaultConfig = {
10668
10630
  onDownloadError: null,
10669
10631
  debounceMs: 300,
10670
10632
  verboseErrors: false,
10633
+ postMessageTarget: null,
10671
10634
  enableFilePreview: true,
10672
10635
  maxPreviewSize: "200px",
10673
10636
  readonly: false,
@@ -10689,6 +10652,7 @@ var defaultConfig = {
10689
10652
  openInNewTab: "Open in new tab",
10690
10653
  changeButton: "Change",
10691
10654
  placeholderText: "Enter text",
10655
+ selectPlaceholder: "Select\u2026",
10692
10656
  previewAlt: "Preview",
10693
10657
  previewUnavailable: "Preview unavailable",
10694
10658
  previewError: "Preview error",
@@ -10764,6 +10728,7 @@ var defaultConfig = {
10764
10728
  openInNewTab: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0439 \u0432\u043A\u043B\u0430\u0434\u043A\u0435",
10765
10729
  changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
10766
10730
  placeholderText: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442",
10731
+ selectPlaceholder: "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435\u2026",
10767
10732
  previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
10768
10733
  previewUnavailable: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D",
10769
10734
  previewError: "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440\u0430",
@@ -10828,20 +10793,23 @@ var defaultConfig = {
10828
10793
  },
10829
10794
  theme: {}
10830
10795
  };
10831
- function createInstanceState(config) {
10832
- const mergedTranslations = {
10833
- ...defaultConfig.translations
10834
- };
10835
- if (config?.translations) {
10836
- for (const [locale, userTranslations] of Object.entries(
10837
- config.translations
10838
- )) {
10839
- mergedTranslations[locale] = {
10840
- ...defaultConfig.translations[locale] || {},
10796
+ function mergeTranslations(base, overrides) {
10797
+ const merged = { ...base };
10798
+ if (overrides) {
10799
+ for (const [locale, userTranslations] of Object.entries(overrides)) {
10800
+ merged[locale] = {
10801
+ ...base[locale] || {},
10841
10802
  ...userTranslations
10842
10803
  };
10843
10804
  }
10844
10805
  }
10806
+ return merged;
10807
+ }
10808
+ function createInstanceState(config) {
10809
+ const mergedTranslations = mergeTranslations(
10810
+ defaultConfig.translations,
10811
+ config?.translations
10812
+ );
10845
10813
  return {
10846
10814
  schema: null,
10847
10815
  formRoot: null,
@@ -10857,7 +10825,9 @@ function createInstanceState(config) {
10857
10825
  prefill: {},
10858
10826
  syntheticElementIds: /* @__PURE__ */ new WeakMap(),
10859
10827
  syntheticElementIdCounter: 0,
10860
- enableIfObservers: /* @__PURE__ */ new Set()
10828
+ enableIfObservers: /* @__PURE__ */ new Set(),
10829
+ autoExpandObservers: /* @__PURE__ */ new Set(),
10830
+ tooltipElements: /* @__PURE__ */ new Set()
10861
10831
  };
10862
10832
  }
10863
10833
  function generateInstanceId() {
@@ -11158,6 +11128,11 @@ function findOwnField(scope, lookupKey, ownBoundary) {
11158
11128
  }
11159
11129
  var FormBuilderInstance = class {
11160
11130
  constructor(config) {
11131
+ // The bound prefill-hint click handler currently attached to the form root.
11132
+ // Kept so renderForm()/destroy() can remove it — re-binding on every render
11133
+ // stacked listeners (hint clicks applied values N times) and destroy()
11134
+ // left the last one on the host-owned root, retaining the instance.
11135
+ this.prefillHintHandler = null;
11161
11136
  this.instanceId = generateInstanceId();
11162
11137
  this.state = createInstanceState(config);
11163
11138
  if (this.state.config.verboseErrors) {
@@ -11191,10 +11166,21 @@ var FormBuilderInstance = class {
11191
11166
  this.state.formRoot = element;
11192
11167
  }
11193
11168
  /**
11194
- * Configure the form builder
11169
+ * Configure the form builder. Translations deep-merge per locale (same as
11170
+ * the constructor); a locale without translations — configured or default —
11171
+ * is rejected, matching setLocale.
11195
11172
  */
11196
11173
  configure(config) {
11197
- Object.assign(this.state.config, config);
11174
+ const translations = mergeTranslations(
11175
+ this.state.config.translations,
11176
+ config.translations
11177
+ );
11178
+ if (config.locale !== void 0 && !translations[config.locale]) {
11179
+ throw new Error(
11180
+ `configure: no translations configured for locale "${config.locale}"`
11181
+ );
11182
+ }
11183
+ Object.assign(this.state.config, config, { translations });
11198
11184
  }
11199
11185
  /**
11200
11186
  * Set file upload handler
@@ -11227,17 +11213,25 @@ var FormBuilderInstance = class {
11227
11213
  this.state.config.readonly = mode === "readonly";
11228
11214
  }
11229
11215
  /**
11230
- * Set locale
11216
+ * Set locale. Custom locales are allowed — their translations must have
11217
+ * been provided via the constructor or configure() first.
11231
11218
  */
11232
11219
  setLocale(locale) {
11233
- if (this.state.config.translations[locale]) {
11234
- this.state.config.locale = locale;
11220
+ if (!this.state.config.translations[locale]) {
11221
+ throw new Error(
11222
+ `setLocale: no translations configured for locale "${locale}"`
11223
+ );
11235
11224
  }
11225
+ this.state.config.locale = locale;
11236
11226
  }
11237
11227
  /**
11238
11228
  * Trigger onChange callbacks with debouncing
11239
11229
  * @param fieldPath - Optional field path for field-specific change events
11240
- * @param fieldValue - Optional field value for field-specific change events
11230
+ * @param fieldValue - Optional field value for field-specific change events.
11231
+ * When omitted while fieldPath is given, the value is read from the
11232
+ * freshly extracted form data at debounce time — used by structural
11233
+ * changes (multi-item add/remove), where the handler has no cheap
11234
+ * current value but the array is trivially derivable after the fact.
11241
11235
  */
11242
11236
  triggerOnChange(fieldPath, fieldValue) {
11243
11237
  if (this.state.config.readonly) return;
@@ -11250,12 +11244,49 @@ var FormBuilderInstance = class {
11250
11244
  if (this.state.config.onChange) {
11251
11245
  this.state.config.onChange(formData);
11252
11246
  }
11253
- if (this.state.config.onFieldChange && fieldPath !== void 0 && fieldValue !== void 0) {
11254
- this.state.config.onFieldChange(fieldPath, fieldValue, formData);
11247
+ if (this.state.config.onFieldChange && fieldPath !== void 0) {
11248
+ const resolvedValue = fieldValue !== void 0 ? fieldValue : this.resolveDomPathValue(formData.data, fieldPath);
11249
+ this.state.config.onFieldChange(fieldPath, resolvedValue, formData);
11255
11250
  }
11256
11251
  this.state.debounceTimer = null;
11257
11252
  }, this.state.config.debounceMs);
11258
11253
  }
11254
+ /**
11255
+ * Resolve a DOM field path against the extracted form data.
11256
+ *
11257
+ * A plain getValueByPath is wrong for paths inside a multiple container:
11258
+ * row markers keep gaps after a deletion (`s[2]` may be the first surviving
11259
+ * row) while the extracted array is re-packed contiguously — the naive
11260
+ * lookup would read a different row, or nothing. Each `[N]` segment is
11261
+ * mapped from its marker to the row's position among the container's
11262
+ * rendered rows, the same DOM order extraction used to build the array.
11263
+ * A bracketed segment that is not a container marker (a multi-value leaf
11264
+ * like `tags[1]`, whose indices are contiguous) falls back to the index.
11265
+ */
11266
+ resolveDomPathValue(data, domPath) {
11267
+ let cur = data;
11268
+ let domPrefix = "";
11269
+ for (const seg of domPath.split(".")) {
11270
+ if (cur === null || cur === void 0) return void 0;
11271
+ const marker = seg.match(/^(.+)\[(\d+)\]$/);
11272
+ if (!marker) {
11273
+ domPrefix = domPrefix ? `${domPrefix}.${seg}` : seg;
11274
+ cur = cur[seg];
11275
+ continue;
11276
+ }
11277
+ const key = marker[1];
11278
+ domPrefix = domPrefix ? `${domPrefix}.${key}` : key;
11279
+ const arr = cur[key];
11280
+ if (!Array.isArray(arr)) return void 0;
11281
+ const rows = this.state.formRoot ? findDirectContainerRows(this.state.formRoot, domPrefix) : [];
11282
+ domPrefix = `${domPrefix}[${marker[2]}]`;
11283
+ const pos = rows.findIndex(
11284
+ (row) => row.getAttribute("data-container-item") === domPrefix
11285
+ );
11286
+ cur = pos >= 0 ? arr[pos] : arr[parseInt(marker[2], 10)];
11287
+ }
11288
+ return cur;
11289
+ }
11259
11290
  /**
11260
11291
  * Register an external action that will be displayed as a button
11261
11292
  * External actions can be form-level (no related_field) or field-level (with related_field)
@@ -11295,21 +11326,10 @@ var FormBuilderInstance = class {
11295
11326
  */
11296
11327
  findFormElementByFieldPath(fieldPath) {
11297
11328
  if (!this.state.formRoot) return null;
11298
- let element = this.state.formRoot.querySelector(
11329
+ const element = this.state.formRoot.querySelector(
11299
11330
  `[name="${fieldPath}"]`
11300
11331
  );
11301
11332
  if (element) return element;
11302
- const variations = [
11303
- fieldPath,
11304
- fieldPath.replace(/\[(\d+)\]/g, "[$1]"),
11305
- fieldPath.replace(/\./g, "[") + "]".repeat((fieldPath.match(/\./g) || []).length)
11306
- ];
11307
- for (const variation of variations) {
11308
- element = this.state.formRoot.querySelector(
11309
- `[name="${variation}"]`
11310
- );
11311
- if (element) return element;
11312
- }
11313
11333
  const schemaElement = this.findSchemaElement(fieldPath);
11314
11334
  if (!schemaElement) return null;
11315
11335
  const fieldWrappers = this.state.formRoot.querySelectorAll(".fb-field-wrapper");
@@ -11555,10 +11575,13 @@ var FormBuilderInstance = class {
11555
11575
  renderForm(root, schema, prefill, actions) {
11556
11576
  const errors = validateSchema(schema);
11557
11577
  if (errors.length > 0) {
11558
- console.error("Schema validation errors:", errors);
11559
- return;
11578
+ throw new Error(`renderForm: invalid schema:
11579
+ - ${errors.join("\n- ")}`);
11560
11580
  }
11561
11581
  this.disconnectEnableIfObservers();
11582
+ this.disconnectAutoExpandObservers();
11583
+ this.removeTooltipElements();
11584
+ this.removePrefillHintListener();
11562
11585
  this.state.formRoot = root;
11563
11586
  this.state.schema = schema;
11564
11587
  this.state.externalActions = actions || null;
@@ -11581,7 +11604,7 @@ var FormBuilderInstance = class {
11581
11604
  }
11582
11605
  schema.elements.forEach((element) => {
11583
11606
  if (element.type !== "markdown" && (element.hidden || element.type === "hidden")) {
11584
- const val = prefill?.[element.key] ?? element.default ?? null;
11607
+ const val = prefill && element.key in prefill ? prefill[element.key] : element.default ?? null;
11585
11608
  fieldsWrapper.appendChild(createHiddenInput(element.key, val));
11586
11609
  return;
11587
11610
  }
@@ -11598,7 +11621,10 @@ var FormBuilderInstance = class {
11598
11621
  rootContainer.appendChild(fieldsWrapper);
11599
11622
  root.appendChild(rootContainer);
11600
11623
  if (!this.state.config.readonly) {
11601
- root.addEventListener("click", this.handlePrefillHintClick.bind(this));
11624
+ this.prefillHintHandler = this.handlePrefillHintClick.bind(
11625
+ this
11626
+ );
11627
+ root.addEventListener("click", this.prefillHintHandler);
11602
11628
  }
11603
11629
  if (this.state.config.readonly && this.state.externalActions && Array.isArray(this.state.externalActions)) {
11604
11630
  this.renderExternalActions();
@@ -11614,7 +11640,7 @@ var FormBuilderInstance = class {
11614
11640
  return { valid: true, errors: [], data: {} };
11615
11641
  const errors = [];
11616
11642
  const data = {};
11617
- const validateElement2 = (element, ctx, customScopeRoot = null) => {
11643
+ const validateElement = (element, ctx, customScopeRoot = null) => {
11618
11644
  const key = element.key ?? "";
11619
11645
  const scopeRoot = customScopeRoot || this.state.formRoot;
11620
11646
  const componentContext = {
@@ -11622,7 +11648,10 @@ var FormBuilderInstance = class {
11622
11648
  state: this.state,
11623
11649
  instance: this,
11624
11650
  path: ctx.path,
11625
- skipValidation
11651
+ skipValidation,
11652
+ // Containers recurse into their children through this — threaded per
11653
+ // pass, never module state (see ComponentContext.validateElement).
11654
+ validateElement
11626
11655
  };
11627
11656
  const componentResult = validateElementWithComponent(
11628
11657
  element,
@@ -11640,7 +11669,6 @@ var FormBuilderInstance = class {
11640
11669
  console.warn(`Unknown field type "${element.type}" for key "${key}"`);
11641
11670
  return { value: null, spread: false };
11642
11671
  };
11643
- setValidateElement(validateElement2);
11644
11672
  this.state.schema.elements.forEach((element) => {
11645
11673
  if (element.enableIf) {
11646
11674
  try {
@@ -11658,24 +11686,12 @@ var FormBuilderInstance = class {
11658
11686
  if (element.type === "markdown") {
11659
11687
  return;
11660
11688
  }
11661
- if (element.hidden || element.type === "hidden") {
11662
- const hiddenInput = this.state.formRoot.querySelector(
11663
- `input[type="hidden"][data-hidden-field="true"][name="${element.key}"]`
11664
- );
11665
- const raw = hiddenInput?.value ?? "";
11666
- if (raw !== "") {
11667
- data[element.key] = deserializeHiddenValue(raw);
11668
- } else {
11669
- data[element.key] = element.default !== void 0 ? element.default : null;
11670
- }
11671
- } else {
11672
- const result = validateElement2(element, { path: "" });
11673
- if (result.skip) return;
11674
- if (result.spread && result.value !== null && typeof result.value === "object") {
11675
- Object.assign(data, result.value);
11676
- } else if (element.key) {
11677
- data[element.key] = result.value;
11678
- }
11689
+ const result = validateElement(element, { path: "" });
11690
+ if (result.skip) return;
11691
+ if (result.spread && result.value !== null && typeof result.value === "object") {
11692
+ Object.assign(data, result.value);
11693
+ } else if (element.key) {
11694
+ data[element.key] = result.value;
11679
11695
  }
11680
11696
  });
11681
11697
  return {
@@ -11696,16 +11712,7 @@ var FormBuilderInstance = class {
11696
11712
  submitForm() {
11697
11713
  const result = this.validateForm(false);
11698
11714
  if (result.valid) {
11699
- if (typeof window !== "undefined" && window.parent) {
11700
- window.parent.postMessage(
11701
- {
11702
- type: "formSubmit",
11703
- data: result.data,
11704
- schema: this.state.schema
11705
- },
11706
- "*"
11707
- );
11708
- }
11715
+ this.postToParent("formSubmit", result.data);
11709
11716
  }
11710
11717
  return result;
11711
11718
  }
@@ -11714,17 +11721,27 @@ var FormBuilderInstance = class {
11714
11721
  */
11715
11722
  saveDraft() {
11716
11723
  const result = this.validateForm(true);
11717
- if (typeof window !== "undefined" && window.parent) {
11718
- window.parent.postMessage(
11719
- {
11720
- type: "formDraft",
11721
- data: result.data,
11722
- schema: this.state.schema
11723
- },
11724
- "*"
11724
+ this.postToParent("formDraft", result.data);
11725
+ return result;
11726
+ }
11727
+ /**
11728
+ * Post form data to the parent frame — only when the host opted in via
11729
+ * `postMessageTarget`. Outside an iframe `window.parent === window`, so an
11730
+ * unconditional post broadcast form data and the full schema to any
11731
+ * embedding page (targetOrigin "*") on every submit. See CHANGELOG 0.6.0.
11732
+ */
11733
+ postToParent(type, data) {
11734
+ const target = this.state.config.postMessageTarget;
11735
+ if (target === "") {
11736
+ throw new Error(
11737
+ 'postMessageTarget: "" is not a valid target origin \u2014 use null to disable posting or "*" to knowingly broadcast'
11725
11738
  );
11726
11739
  }
11727
- return result;
11740
+ if (!target || typeof window === "undefined" || !window.parent) return;
11741
+ window.parent.postMessage(
11742
+ { type, data, schema: this.state.schema },
11743
+ target
11744
+ );
11728
11745
  }
11729
11746
  /**
11730
11747
  * Clear the form - reset all field values to empty while preserving form structure
@@ -11994,6 +12011,9 @@ var FormBuilderInstance = class {
11994
12011
  this.state.debounceTimer = null;
11995
12012
  }
11996
12013
  this.disconnectEnableIfObservers();
12014
+ this.disconnectAutoExpandObservers();
12015
+ this.removeTooltipElements();
12016
+ this.removePrefillHintListener();
11997
12017
  this.state.resourceIndex.clear();
11998
12018
  if (this.state.formRoot) {
11999
12019
  clear(this.state.formRoot);
@@ -12011,6 +12031,24 @@ var FormBuilderInstance = class {
12011
12031
  }
12012
12032
  this.state.enableIfObservers.clear();
12013
12033
  }
12034
+ disconnectAutoExpandObservers() {
12035
+ for (const observer of this.state.autoExpandObservers) {
12036
+ observer.disconnect();
12037
+ }
12038
+ this.state.autoExpandObservers.clear();
12039
+ }
12040
+ removePrefillHintListener() {
12041
+ if (this.prefillHintHandler && this.state.formRoot) {
12042
+ this.state.formRoot.removeEventListener("click", this.prefillHintHandler);
12043
+ }
12044
+ this.prefillHintHandler = null;
12045
+ }
12046
+ removeTooltipElements() {
12047
+ for (const tooltip of this.state.tooltipElements) {
12048
+ tooltip.remove();
12049
+ }
12050
+ this.state.tooltipElements.clear();
12051
+ }
12014
12052
  };
12015
12053
 
12016
12054
  // src/index.ts