@dmitryvim/form-builder 0.5.4 → 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
  }
@@ -58,7 +58,7 @@ function formatFileSize(bytes) {
58
58
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
59
59
  }
60
60
  function serializeHiddenValue(value) {
61
- if (value === null || value === void 0) return "";
61
+ if (value === void 0) return "";
62
62
  return JSON.stringify(value);
63
63
  }
64
64
  function deserializeHiddenValue(raw) {
@@ -69,6 +69,23 @@ function deserializeHiddenValue(raw) {
69
69
  return raw;
70
70
  }
71
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
+ }
72
89
  function createHiddenInput(name, value) {
73
90
  const input = document.createElement("input");
74
91
  input.type = "hidden";
@@ -306,6 +323,14 @@ function validateSchema(schema) {
306
323
  }
307
324
  }
308
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
+ });
309
334
  elements.forEach((element, index) => {
310
335
  const elementPath = `${path}[${index}]`;
311
336
  if (!element.type) {
@@ -473,12 +498,55 @@ function deepEqual(a, b) {
473
498
  }
474
499
 
475
500
  // src/utils/styles.ts
476
- 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);
477
506
  const name = input.getAttribute("name");
478
- if (!name) return;
479
- const doc = input.ownerDocument || document;
480
- const errorNode = doc.getElementById(`error-${name}`);
481
- 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();
482
550
  }
483
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>';
484
552
  function ensureThemingHooks(doc) {
@@ -600,7 +668,7 @@ function ensureThemingHooks(doc) {
600
668
  `;
601
669
  doc.head.appendChild(style);
602
670
  }
603
- function applyAutoExpand(textarea, options = {}) {
671
+ function applyAutoExpand(textarea, options) {
604
672
  textarea.style.overflow = "hidden";
605
673
  textarea.style.resize = "none";
606
674
  const minRows = Math.max(1, options.minRows ?? 1);
@@ -630,6 +698,7 @@ function applyAutoExpand(textarea, options = {}) {
630
698
  const ro = new ResizeObserver((entries) => {
631
699
  if (!textarea.isConnected) {
632
700
  ro.disconnect();
701
+ options.observers?.delete(ro);
633
702
  return;
634
703
  }
635
704
  const entry = entries[0];
@@ -639,6 +708,7 @@ function applyAutoExpand(textarea, options = {}) {
639
708
  resize();
640
709
  });
641
710
  ro.observe(textarea);
711
+ options.observers?.add(ro);
642
712
  }
643
713
  function applySingleLineMode(textarea) {
644
714
  textarea.addEventListener("keydown", (e) => {
@@ -950,10 +1020,10 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
950
1020
  `;
951
1021
  textInput.name = pathKey;
952
1022
  textInput.placeholder = element.placeholder ?? t("placeholderText", state);
953
- textInput.value = ctx.prefill[element.key] || element.default || "";
1023
+ textInput.value = ctx.prefill[element.key] ?? element.default ?? "";
954
1024
  textInput.readOnly = readonly;
955
1025
  applySingleLineMode(textInput);
956
- applyAutoExpand(textInput);
1026
+ applyAutoExpand(textInput, { observers: state.autoExpandObservers });
957
1027
  if (!readonly) {
958
1028
  textInput.addEventListener("focus", () => {
959
1029
  textInput.style.borderColor = "var(--fb-border-focus-color)";
@@ -1011,7 +1081,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1011
1081
  const chip = input.closest(".fb-chip");
1012
1082
  const sib = chip?.nextElementSibling;
1013
1083
  if (sib && sib.classList.contains("error-message")) {
1014
- sib.id = `error-${input.name}`;
1084
+ sib.setAttribute("data-error-for", input.name);
1015
1085
  }
1016
1086
  });
1017
1087
  }
@@ -1026,7 +1096,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1026
1096
  input.rows = 1;
1027
1097
  input.className = "fb-chip-input";
1028
1098
  input.value = value;
1029
- input.placeholder = element.placeholder || t("placeholderText", state);
1099
+ input.placeholder = element.placeholder ?? t("placeholderText", state);
1030
1100
  input.readOnly = readonly;
1031
1101
  chip.appendChild(input);
1032
1102
  if (!readonly && ctx.instance) {
@@ -1040,7 +1110,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1040
1110
  input.addEventListener("input", handleChange);
1041
1111
  }
1042
1112
  applySingleLineMode(input);
1043
- applyAutoExpand(input);
1113
+ applyAutoExpand(input, { observers: state.autoExpandObservers });
1044
1114
  if (!readonly) {
1045
1115
  const rem = document.createElement("button");
1046
1116
  rem.type = "button";
@@ -1104,40 +1174,6 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1104
1174
  function validateTextElement(element, key, context) {
1105
1175
  const errors = [];
1106
1176
  const { scopeRoot, skipValidation } = context;
1107
- const markValidity = (input, errorMessage) => {
1108
- if (!input) return;
1109
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
1110
- let errorElement = document.getElementById(errorId);
1111
- if (errorMessage) {
1112
- input.classList.add("invalid");
1113
- input.title = errorMessage;
1114
- if (!errorElement) {
1115
- errorElement = document.createElement("div");
1116
- errorElement.id = errorId;
1117
- errorElement.className = "error-message";
1118
- errorElement.style.cssText = `
1119
- color: var(--fb-error-color);
1120
- font-size: var(--fb-font-size-small);
1121
- margin-top: 0.25rem;
1122
- `;
1123
- const chipAncestor = input.closest?.(".fb-chip");
1124
- const anchor = chipAncestor || input;
1125
- if (anchor.nextSibling) {
1126
- anchor.parentNode?.insertBefore(errorElement, anchor.nextSibling);
1127
- } else {
1128
- anchor.parentNode?.appendChild(errorElement);
1129
- }
1130
- }
1131
- errorElement.textContent = errorMessage;
1132
- errorElement.style.display = "block";
1133
- } else {
1134
- input.classList.remove("invalid");
1135
- input.title = "";
1136
- if (errorElement) {
1137
- errorElement.remove();
1138
- }
1139
- }
1140
- };
1141
1177
  const validateTextInput = (input, val, fieldKey) => {
1142
1178
  let hasError = false;
1143
1179
  const { state } = context;
@@ -1145,12 +1181,12 @@ function validateTextElement(element, key, context) {
1145
1181
  if (element.minLength !== void 0 && element.minLength !== null && val.length < element.minLength) {
1146
1182
  const msg = t("minLength", state, { min: element.minLength });
1147
1183
  errors.push(`${fieldKey}: ${msg}`);
1148
- markValidity(input, msg);
1184
+ markFieldValidity(input, msg);
1149
1185
  hasError = true;
1150
1186
  } else if (element.maxLength !== void 0 && element.maxLength !== null && val.length > element.maxLength) {
1151
1187
  const msg = t("maxLength", state, { max: element.maxLength });
1152
1188
  errors.push(`${fieldKey}: ${msg}`);
1153
- markValidity(input, msg);
1189
+ markFieldValidity(input, msg);
1154
1190
  hasError = true;
1155
1191
  } else if (element.pattern) {
1156
1192
  try {
@@ -1158,19 +1194,19 @@ function validateTextElement(element, key, context) {
1158
1194
  if (!re.test(val)) {
1159
1195
  const msg = t("patternMismatch", state);
1160
1196
  errors.push(`${fieldKey}: ${msg}`);
1161
- markValidity(input, msg);
1197
+ markFieldValidity(input, msg);
1162
1198
  hasError = true;
1163
1199
  }
1164
1200
  } catch {
1165
1201
  const msg = t("invalidPattern", state);
1166
1202
  errors.push(`${fieldKey}: ${msg}`);
1167
- markValidity(input, msg);
1203
+ markFieldValidity(input, msg);
1168
1204
  hasError = true;
1169
1205
  }
1170
1206
  }
1171
1207
  }
1172
1208
  if (!hasError) {
1173
- markValidity(input, null);
1209
+ markFieldValidity(input, null);
1174
1210
  }
1175
1211
  };
1176
1212
  if (element.multiple) {
@@ -1205,7 +1241,7 @@ function validateTextElement(element, key, context) {
1205
1241
  if (!skipValidation && element.required && val === "") {
1206
1242
  const msg = t("required", context.state);
1207
1243
  errors.push(`${key}: ${msg}`);
1208
- markValidity(input, msg);
1244
+ markFieldValidity(input, msg);
1209
1245
  return { value: null, errors };
1210
1246
  }
1211
1247
  if (input) {
@@ -1267,8 +1303,8 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1267
1303
  line-height: var(--fb-line-height, 1.5);
1268
1304
  `;
1269
1305
  textareaInput.name = pathKey;
1270
- textareaInput.placeholder = element.placeholder ?? "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
1271
- 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 ?? "";
1272
1308
  textareaInput.readOnly = readonly;
1273
1309
  if (!readonly && ctx.instance) {
1274
1310
  const handleChange = () => {
@@ -1278,7 +1314,10 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1278
1314
  textareaInput.addEventListener("blur", handleChange);
1279
1315
  textareaInput.addEventListener("input", handleChange);
1280
1316
  }
1281
- applyAutoExpand(textareaInput, { minRows: element.rows ?? 1 });
1317
+ applyAutoExpand(textareaInput, {
1318
+ minRows: element.rows ?? 1,
1319
+ observers: state.autoExpandObservers
1320
+ });
1282
1321
  textareaWrapper.appendChild(textareaInput);
1283
1322
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1284
1323
  const counter = createCharCounter(element, textareaInput);
@@ -1321,7 +1360,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1321
1360
  font-family: var(--fb-font-family);
1322
1361
  line-height: var(--fb-line-height, 1.5);
1323
1362
  `;
1324
- textareaInput.placeholder = element.placeholder || t("placeholderText", state);
1363
+ textareaInput.placeholder = element.placeholder ?? t("placeholderText", state);
1325
1364
  textareaInput.value = value;
1326
1365
  textareaInput.readOnly = readonly;
1327
1366
  if (!readonly && ctx.instance) {
@@ -1332,7 +1371,10 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1332
1371
  textareaInput.addEventListener("blur", handleChange);
1333
1372
  textareaInput.addEventListener("input", handleChange);
1334
1373
  }
1335
- applyAutoExpand(textareaInput, { minRows: element.rows ?? 1 });
1374
+ applyAutoExpand(textareaInput, {
1375
+ minRows: element.rows ?? 1,
1376
+ observers: state.autoExpandObservers
1377
+ });
1336
1378
  textareaContainer.appendChild(textareaInput);
1337
1379
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1338
1380
  const counter = createCharCounter(element, textareaInput);
@@ -1551,6 +1593,17 @@ function createNumberRangeHint(element, input) {
1551
1593
  updateColor();
1552
1594
  return hint;
1553
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
+ }
1554
1607
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1555
1608
  const state = ctx.state;
1556
1609
  const readonly = isElementReadonly(element, state, ctx);
@@ -1559,11 +1612,12 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1559
1612
  const numberInput = document.createElement("input");
1560
1613
  numberInput.type = "number";
1561
1614
  numberInput.name = pathKey;
1562
- numberInput.placeholder = element.placeholder || "0";
1615
+ numberInput.placeholder = element.placeholder ?? "0";
1563
1616
  if (element.min !== void 0) numberInput.min = element.min.toString();
1564
1617
  if (element.max !== void 0) numberInput.max = element.max.toString();
1565
- if (element.step !== void 0) numberInput.step = element.step.toString();
1566
- 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 ?? "";
1567
1621
  numberInput.readOnly = readonly;
1568
1622
  if (!element.stepper) {
1569
1623
  numberInput.className = "w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
@@ -1602,7 +1656,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1602
1656
  const minCount = element.minCount ?? (element.required ? 1 : 0);
1603
1657
  const maxCount = element.maxCount ?? Infinity;
1604
1658
  while (values.length < minCount) {
1605
- values.push(element.default || "");
1659
+ values.push(element.default ?? "");
1606
1660
  }
1607
1661
  const container = document.createElement("div");
1608
1662
  container.className = "fb-row";
@@ -1631,10 +1685,11 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1631
1685
  width: 100%;
1632
1686
  box-sizing: border-box;
1633
1687
  `;
1634
- numberInput.placeholder = element.placeholder || "0";
1688
+ numberInput.placeholder = element.placeholder ?? "0";
1635
1689
  if (element.min !== void 0) numberInput.min = element.min.toString();
1636
1690
  if (element.max !== void 0) numberInput.max = element.max.toString();
1637
- if (element.step !== void 0) numberInput.step = element.step.toString();
1691
+ numberInput.step = numberStepAttr(element);
1692
+ applyDecimalsMarker(numberInput, element);
1638
1693
  numberInput.value = value.toString();
1639
1694
  numberInput.readOnly = readonly;
1640
1695
  if (!readonly && ctx.instance) {
@@ -1698,8 +1753,8 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1698
1753
  const handle = createAddItemRow(
1699
1754
  "number",
1700
1755
  () => {
1701
- values.push(element.default || "");
1702
- addNumberItem(element.default || "");
1756
+ values.push(element.default ?? "");
1757
+ addNumberItem(element.default ?? "");
1703
1758
  updateAddButton();
1704
1759
  updateRemoveButtons();
1705
1760
  ctx.instance?.triggerOnChange(pathKey);
@@ -1720,54 +1775,22 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1720
1775
  function validateNumberElement(element, key, context) {
1721
1776
  const errors = [];
1722
1777
  const { scopeRoot, skipValidation } = context;
1723
- const markValidity = (input, errorMessage) => {
1724
- if (!input) return;
1725
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
1726
- let errorElement = document.getElementById(errorId);
1727
- if (errorMessage) {
1728
- input.classList.add("invalid");
1729
- input.title = errorMessage;
1730
- if (!errorElement) {
1731
- errorElement = document.createElement("div");
1732
- errorElement.id = errorId;
1733
- errorElement.className = "error-message";
1734
- errorElement.style.cssText = `
1735
- color: var(--fb-error-color);
1736
- font-size: var(--fb-font-size-small);
1737
- margin-top: 0.25rem;
1738
- `;
1739
- if (input.nextSibling) {
1740
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
1741
- } else {
1742
- input.parentNode?.appendChild(errorElement);
1743
- }
1744
- }
1745
- errorElement.textContent = errorMessage;
1746
- errorElement.style.display = "block";
1747
- } else {
1748
- input.classList.remove("invalid");
1749
- input.title = "";
1750
- if (errorElement) {
1751
- errorElement.remove();
1752
- }
1753
- }
1754
- };
1755
1778
  const validateNumberInput = (input, v, fieldKey) => {
1756
1779
  let hasError = false;
1757
1780
  const { state } = context;
1758
1781
  if (!skipValidation && element.min !== void 0 && element.min !== null && v < element.min) {
1759
1782
  const msg = t("minValue", state, { min: element.min });
1760
1783
  errors.push(`${fieldKey}: ${msg}`);
1761
- markValidity(input, msg);
1784
+ markFieldValidity(input, msg);
1762
1785
  hasError = true;
1763
1786
  } else if (!skipValidation && element.max !== void 0 && element.max !== null && v > element.max) {
1764
1787
  const msg = t("maxValue", state, { max: element.max });
1765
1788
  errors.push(`${fieldKey}: ${msg}`);
1766
- markValidity(input, msg);
1789
+ markFieldValidity(input, msg);
1767
1790
  hasError = true;
1768
1791
  }
1769
1792
  if (!hasError) {
1770
- markValidity(input, null);
1793
+ markFieldValidity(input, null);
1771
1794
  }
1772
1795
  };
1773
1796
  if (element.multiple) {
@@ -1779,14 +1802,14 @@ function validateNumberElement(element, key, context) {
1779
1802
  const raw = input?.value ?? "";
1780
1803
  if (raw === "") {
1781
1804
  values.push(null);
1782
- markValidity(input, null);
1805
+ markFieldValidity(input, null);
1783
1806
  return;
1784
1807
  }
1785
1808
  const v = parseFloat(raw);
1786
1809
  if (!skipValidation && !Number.isFinite(v)) {
1787
1810
  const msg = t("notANumber", context.state);
1788
1811
  errors.push(`${key}[${index}]: ${msg}`);
1789
- markValidity(input, msg);
1812
+ markFieldValidity(input, msg);
1790
1813
  values.push(null);
1791
1814
  return;
1792
1815
  }
@@ -1816,18 +1839,18 @@ function validateNumberElement(element, key, context) {
1816
1839
  if (!skipValidation && element.required && raw === "") {
1817
1840
  const msg = t("required", state);
1818
1841
  errors.push(`${key}: ${msg}`);
1819
- markValidity(input, msg);
1842
+ markFieldValidity(input, msg);
1820
1843
  return { value: null, errors };
1821
1844
  }
1822
1845
  if (raw === "") {
1823
- markValidity(input, null);
1846
+ markFieldValidity(input, null);
1824
1847
  return { value: null, errors };
1825
1848
  }
1826
1849
  const v = parseFloat(raw);
1827
1850
  if (!skipValidation && !Number.isFinite(v)) {
1828
1851
  const msg = t("notANumber", state);
1829
1852
  errors.push(`${key}: ${msg}`);
1830
- markValidity(input, msg);
1853
+ markFieldValidity(input, msg);
1831
1854
  return { value: null, errors };
1832
1855
  }
1833
1856
  validateNumberInput(input, v, key);
@@ -1877,6 +1900,38 @@ function updateNumberField(element, fieldPath, value, context) {
1877
1900
  }
1878
1901
 
1879
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
+ }
1880
1935
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1881
1936
  const state = ctx.state;
1882
1937
  const readonly = isElementReadonly(element, state, ctx);
@@ -1889,18 +1944,18 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1889
1944
  `;
1890
1945
  selectInput.name = pathKey;
1891
1946
  selectInput.disabled = readonly;
1892
- (element.options || []).forEach((option) => {
1893
- const optionEl = document.createElement("option");
1894
- optionEl.value = option.value;
1895
- optionEl.textContent = option.label;
1896
- if ((ctx.prefill[element.key] || element.default) === option.value) {
1897
- optionEl.selected = true;
1898
- }
1899
- selectInput.appendChild(optionEl);
1900
- });
1947
+ appendSelectOptions(
1948
+ selectInput,
1949
+ element,
1950
+ ctx.prefill[element.key] ?? element.default,
1951
+ state
1952
+ );
1901
1953
  if (!readonly && ctx.instance) {
1902
1954
  const handleChange = () => {
1903
- ctx.instance.triggerOnChange(pathKey, selectInput.value);
1955
+ ctx.instance.triggerOnChange(
1956
+ pathKey,
1957
+ selectInput.value === "" ? null : selectInput.value
1958
+ );
1904
1959
  };
1905
1960
  selectInput.addEventListener("change", handleChange);
1906
1961
  }
@@ -1920,7 +1975,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1920
1975
  const minCount = element.minCount ?? (element.required ? 1 : 0);
1921
1976
  const maxCount = element.maxCount ?? Infinity;
1922
1977
  while (values.length < minCount) {
1923
- values.push(element.default || element.options?.[0]?.value || "");
1978
+ values.push(element.default ?? "");
1924
1979
  }
1925
1980
  const container = document.createElement("div");
1926
1981
  container.className = "fb-row";
@@ -1945,15 +2000,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1945
2000
  font-family: var(--fb-font-family);
1946
2001
  `;
1947
2002
  selectInput.disabled = readonly;
1948
- (element.options || []).forEach((option) => {
1949
- const optionElement = document.createElement("option");
1950
- optionElement.value = option.value;
1951
- optionElement.textContent = option.label;
1952
- if (value === option.value) {
1953
- optionElement.selected = true;
1954
- }
1955
- selectInput.appendChild(optionElement);
1956
- });
2003
+ appendSelectOptions(selectInput, element, value, state);
1957
2004
  if (!readonly && ctx.instance) {
1958
2005
  const handleChange = () => {
1959
2006
  ctx.instance.triggerOnChange(selectInput.name, selectInput.value);
@@ -2006,7 +2053,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2006
2053
  const handle = createAddItemRow(
2007
2054
  "select",
2008
2055
  () => {
2009
- const defaultValue = element.default || element.options?.[0]?.value || "";
2056
+ const defaultValue = element.default ?? "";
2010
2057
  values.push(defaultValue);
2011
2058
  addSelectItem(defaultValue);
2012
2059
  updateAddButton();
@@ -2035,38 +2082,6 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2035
2082
  function validateSelectElement(element, key, context) {
2036
2083
  const errors = [];
2037
2084
  const { scopeRoot, skipValidation } = context;
2038
- const markValidity = (input, errorMessage) => {
2039
- if (!input) return;
2040
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
2041
- let errorElement = document.getElementById(errorId);
2042
- if (errorMessage) {
2043
- input.classList.add("invalid");
2044
- input.title = errorMessage;
2045
- if (!errorElement) {
2046
- errorElement = document.createElement("div");
2047
- errorElement.id = errorId;
2048
- errorElement.className = "error-message";
2049
- errorElement.style.cssText = `
2050
- color: var(--fb-error-color);
2051
- font-size: var(--fb-font-size-small);
2052
- margin-top: 0.25rem;
2053
- `;
2054
- if (input.nextSibling) {
2055
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
2056
- } else {
2057
- input.parentNode?.appendChild(errorElement);
2058
- }
2059
- }
2060
- errorElement.textContent = errorMessage;
2061
- errorElement.style.display = "block";
2062
- } else {
2063
- input.classList.remove("invalid");
2064
- input.title = "";
2065
- if (errorElement) {
2066
- errorElement.remove();
2067
- }
2068
- }
2069
- };
2070
2085
  const validateMultipleCount = (key2, values, element2, filterFn) => {
2071
2086
  if (skipValidation) return;
2072
2087
  const { state } = context;
@@ -2090,27 +2105,36 @@ function validateSelectElement(element, key, context) {
2090
2105
  const values = [];
2091
2106
  inputs.forEach((input) => {
2092
2107
  const val = input?.value ?? "";
2093
- values.push(val);
2094
- markValidity(input, null);
2108
+ values.push(val === "" ? null : val);
2109
+ markFieldValidity(input, null);
2095
2110
  });
2096
- validateMultipleCount(key, values, element, (v) => v !== "");
2111
+ validateMultipleCount(key, values, element, (v) => v != null);
2097
2112
  return { value: values, errors };
2098
2113
  } else {
2099
- const input = scopeRoot.querySelector(
2100
- `[name="${key}"]`
2101
- );
2114
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
2102
2115
  const val = input?.value ?? "";
2103
2116
  if (!skipValidation && element.required && val === "") {
2104
2117
  const msg = t("required", context.state);
2105
2118
  errors.push(`${key}: ${msg}`);
2106
- markValidity(input, msg);
2119
+ markFieldValidity(input, msg);
2107
2120
  return { value: null, errors };
2108
2121
  } else {
2109
- markValidity(input, null);
2122
+ markFieldValidity(input, null);
2110
2123
  }
2111
2124
  return { value: val === "" ? null : val, errors };
2112
2125
  }
2113
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
+ }
2114
2138
  function updateSelectField(element, fieldPath, value, context) {
2115
2139
  const { scopeRoot } = context;
2116
2140
  if ("multiple" in element && element.multiple) {
@@ -2125,10 +2149,17 @@ function updateSelectField(element, fieldPath, value, context) {
2125
2149
  );
2126
2150
  selects.forEach((select, index) => {
2127
2151
  if (index < value.length) {
2128
- 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;
2129
2160
  const options = select.querySelectorAll("option");
2130
2161
  options.forEach((option) => {
2131
- option.selected = option.value === String(value[index]);
2162
+ option.selected = option.value === strValue;
2132
2163
  });
2133
2164
  select.classList.remove("invalid");
2134
2165
  select.title = "";
@@ -2145,10 +2176,12 @@ function updateSelectField(element, fieldPath, value, context) {
2145
2176
  `[name="${fieldPath}"]`
2146
2177
  );
2147
2178
  if (select) {
2148
- select.value = value != null ? String(value) : "";
2179
+ const strValue = value != null ? String(value) : "";
2180
+ assertValueInOptions(select, strValue, fieldPath);
2181
+ select.value = strValue;
2149
2182
  const options = select.querySelectorAll("option");
2150
2183
  options.forEach((option) => {
2151
- option.selected = option.value === String(value);
2184
+ option.selected = option.value === strValue;
2152
2185
  });
2153
2186
  select.classList.remove("invalid");
2154
2187
  select.title = "";
@@ -2351,7 +2384,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2351
2384
  const minCount = element.minCount ?? (element.required ? 1 : 0);
2352
2385
  const maxCount = element.maxCount ?? Infinity;
2353
2386
  while (values.length < minCount) {
2354
- values.push(element.default || element.options?.[0]?.value || "");
2387
+ values.push(element.default ?? "");
2355
2388
  }
2356
2389
  const readonly = isElementReadonly(element, state, ctx);
2357
2390
  const container = document.createElement("div");
@@ -2445,7 +2478,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2445
2478
  const handle = createAddItemRow(
2446
2479
  "switcher",
2447
2480
  () => {
2448
- const defaultValue = element.default || element.options?.[0]?.value || "";
2481
+ const defaultValue = element.default ?? "";
2449
2482
  values.push(defaultValue);
2450
2483
  addSwitcherItem(defaultValue);
2451
2484
  updateAddButton();
@@ -2474,38 +2507,6 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2474
2507
  function validateSwitcherElement(element, key, context) {
2475
2508
  const errors = [];
2476
2509
  const { scopeRoot, skipValidation } = context;
2477
- const markValidity = (input, errorMessage) => {
2478
- if (!input) return;
2479
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
2480
- let errorElement = document.getElementById(errorId);
2481
- if (errorMessage) {
2482
- input.classList.add("invalid");
2483
- input.title = errorMessage;
2484
- if (!errorElement) {
2485
- errorElement = document.createElement("div");
2486
- errorElement.id = errorId;
2487
- errorElement.className = "error-message";
2488
- errorElement.style.cssText = `
2489
- color: var(--fb-error-color);
2490
- font-size: var(--fb-font-size-small);
2491
- margin-top: 0.25rem;
2492
- `;
2493
- if (input.nextSibling) {
2494
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
2495
- } else {
2496
- input.parentNode?.appendChild(errorElement);
2497
- }
2498
- }
2499
- errorElement.textContent = errorMessage;
2500
- errorElement.style.display = "block";
2501
- } else {
2502
- input.classList.remove("invalid");
2503
- input.title = "";
2504
- if (errorElement) {
2505
- errorElement.remove();
2506
- }
2507
- }
2508
- };
2509
2510
  const validateMultipleCount = (fieldKey, values, el, filterFn) => {
2510
2511
  if (skipValidation) return;
2511
2512
  const { state } = context;
@@ -2532,16 +2533,16 @@ function validateSwitcherElement(element, key, context) {
2532
2533
  const values = [];
2533
2534
  inputs.forEach((input) => {
2534
2535
  const val = input?.value ?? "";
2535
- values.push(val);
2536
+ values.push(val === "" ? null : val);
2536
2537
  if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2537
2538
  const msg = t("invalidOption", context.state);
2538
- markValidity(input, msg);
2539
+ markFieldValidity(input, msg);
2539
2540
  errors.push(`${key}: ${msg}`);
2540
2541
  } else {
2541
- markValidity(input, null);
2542
+ markFieldValidity(input, null);
2542
2543
  }
2543
2544
  });
2544
- validateMultipleCount(key, values, element, (v) => v !== "");
2545
+ validateMultipleCount(key, values, element, (v) => v != null);
2545
2546
  return { value: values, errors };
2546
2547
  } else {
2547
2548
  const input = scopeRoot.querySelector(
@@ -2551,16 +2552,16 @@ function validateSwitcherElement(element, key, context) {
2551
2552
  if (!skipValidation && element.required && val === "") {
2552
2553
  const msg = t("required", context.state);
2553
2554
  errors.push(`${key}: ${msg}`);
2554
- markValidity(input, msg);
2555
+ markFieldValidity(input, msg);
2555
2556
  return { value: null, errors };
2556
2557
  }
2557
2558
  if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2558
2559
  const msg = t("invalidOption", context.state);
2559
2560
  errors.push(`${key}: ${msg}`);
2560
- markValidity(input, msg);
2561
+ markFieldValidity(input, msg);
2561
2562
  return { value: null, errors };
2562
2563
  }
2563
- markValidity(input, null);
2564
+ markFieldValidity(input, null);
2564
2565
  return { value: val === "" ? null : val, errors };
2565
2566
  }
2566
2567
  }
@@ -2732,6 +2733,7 @@ function renderBooleanElement(element, ctx, wrapper, pathKey) {
2732
2733
  const hiddenInput = document.createElement("input");
2733
2734
  hiddenInput.type = "hidden";
2734
2735
  hiddenInput.name = pathKey;
2736
+ hiddenInput.setAttribute("data-boolean-field", "true");
2735
2737
  hiddenInput.value = initial ? "true" : "false";
2736
2738
  const row = document.createElement("div");
2737
2739
  row.className = "fb-toggle-row";
@@ -5807,7 +5809,7 @@ function createReadonlyColourUI(value) {
5807
5809
  container.appendChild(hexText);
5808
5810
  return container;
5809
5811
  }
5810
- function createEditColourUI(value, pathKey, ctx) {
5812
+ function createEditColourUI(value, pathKey, ctx, placeholder) {
5811
5813
  const normalizedValue = normalizeColourValue(value);
5812
5814
  const pickerWrapper = document.createElement("div");
5813
5815
  pickerWrapper.className = "colour-picker-wrapper";
@@ -5831,9 +5833,10 @@ function createEditColourUI(value, pathKey, ctx) {
5831
5833
  const hexInput = document.createElement("input");
5832
5834
  hexInput.type = "text";
5833
5835
  hexInput.className = "colour-hex-input";
5836
+ hexInput.setAttribute("data-colour-field", "true");
5834
5837
  hexInput.name = pathKey;
5835
5838
  hexInput.value = normalizedValue;
5836
- hexInput.placeholder = "#000000";
5839
+ hexInput.placeholder = placeholder ?? "#000000";
5837
5840
  hexInput.style.cssText = `
5838
5841
  width: 100px;
5839
5842
  padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
@@ -5922,12 +5925,17 @@ function createEditColourUI(value, pathKey, ctx) {
5922
5925
  function renderColourElement(element, ctx, wrapper, pathKey) {
5923
5926
  const state = ctx.state;
5924
5927
  const readonly = isElementReadonly(element, state, ctx);
5925
- const initialValue = ctx.prefill[element.key] || element.default || "#000000";
5928
+ const initialValue = ctx.prefill[element.key] ?? element.default ?? "#000000";
5926
5929
  if (readonly) {
5927
5930
  const readonlyUI = createReadonlyColourUI(initialValue);
5928
5931
  wrapper.appendChild(readonlyUI);
5929
5932
  } else {
5930
- const editUI = createEditColourUI(initialValue, pathKey, ctx);
5933
+ const editUI = createEditColourUI(
5934
+ initialValue,
5935
+ pathKey,
5936
+ ctx,
5937
+ element.placeholder
5938
+ );
5931
5939
  wrapper.appendChild(editUI);
5932
5940
  }
5933
5941
  if (!readonly) {
@@ -5949,7 +5957,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5949
5957
  const minCount = element.minCount ?? (element.required ? 1 : 0);
5950
5958
  const maxCount = element.maxCount ?? Infinity;
5951
5959
  while (values.length < minCount) {
5952
- values.push(element.default || "#000000");
5960
+ values.push(element.default ?? "#000000");
5953
5961
  }
5954
5962
  const container = document.createElement("div");
5955
5963
  container.className = "fb-row";
@@ -5973,7 +5981,12 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5973
5981
  }
5974
5982
  } else {
5975
5983
  const tempPathKey = `${pathKey}[${container.children.length}]`;
5976
- const editUI = createEditColourUI(value, tempPathKey, ctx);
5984
+ const editUI = createEditColourUI(
5985
+ value,
5986
+ tempPathKey,
5987
+ ctx,
5988
+ element.placeholder
5989
+ );
5977
5990
  editUI.style.flex = "1";
5978
5991
  itemWrapper.appendChild(editUI);
5979
5992
  }
@@ -6035,7 +6048,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6035
6048
  const handle = createAddItemRow(
6036
6049
  "colour",
6037
6050
  () => {
6038
- const defaultColour = element.default || "#000000";
6051
+ const defaultColour = element.default ?? "#000000";
6039
6052
  values.push(defaultColour);
6040
6053
  addColourItem(defaultColour);
6041
6054
  updateAddButton();
@@ -6068,58 +6081,26 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6068
6081
  function validateColourElement(element, key, context) {
6069
6082
  const errors = [];
6070
6083
  const { scopeRoot, skipValidation } = context;
6071
- const markValidity = (input, errorMessage) => {
6072
- if (!input) return;
6073
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
6074
- let errorElement = document.getElementById(errorId);
6075
- if (errorMessage) {
6076
- input.classList.add("invalid");
6077
- input.title = errorMessage;
6078
- if (!errorElement) {
6079
- errorElement = document.createElement("div");
6080
- errorElement.id = errorId;
6081
- errorElement.className = "error-message";
6082
- errorElement.style.cssText = `
6083
- color: var(--fb-error-color);
6084
- font-size: var(--fb-font-size-small);
6085
- margin-top: 0.25rem;
6086
- `;
6087
- if (input.nextSibling) {
6088
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
6089
- } else {
6090
- input.parentNode?.appendChild(errorElement);
6091
- }
6092
- }
6093
- errorElement.textContent = errorMessage;
6094
- errorElement.style.display = "block";
6095
- } else {
6096
- input.classList.remove("invalid");
6097
- input.title = "";
6098
- if (errorElement) {
6099
- errorElement.remove();
6100
- }
6101
- }
6102
- };
6103
6084
  const validateColourValue = (input, val, fieldKey) => {
6104
6085
  const { state } = context;
6105
6086
  if (!val) {
6106
6087
  if (!skipValidation && element.required) {
6107
6088
  const msg = t("required", state);
6108
6089
  errors.push(`${fieldKey}: ${msg}`);
6109
- markValidity(input, msg);
6090
+ markFieldValidity(input, msg);
6110
6091
  return "";
6111
6092
  }
6112
- markValidity(input, null);
6093
+ markFieldValidity(input, null);
6113
6094
  return "";
6114
6095
  }
6115
6096
  const normalized = normalizeColourValue(val);
6116
6097
  if (!skipValidation && !isValidHexColour(normalized)) {
6117
6098
  const msg = t("invalidHexColour", state);
6118
6099
  errors.push(`${fieldKey}: ${msg}`);
6119
- markValidity(input, msg);
6100
+ markFieldValidity(input, msg);
6120
6101
  return val;
6121
6102
  }
6122
- markValidity(input, null);
6103
+ markFieldValidity(input, null);
6123
6104
  return normalized;
6124
6105
  };
6125
6106
  if (element.multiple) {
@@ -6156,7 +6137,7 @@ function validateColourElement(element, key, context) {
6156
6137
  if (!skipValidation && element.required && val === "") {
6157
6138
  const msg = t("required", context.state);
6158
6139
  errors.push(`${key}: ${msg}`);
6159
- markValidity(hexInput, msg);
6140
+ markFieldValidity(hexInput, msg);
6160
6141
  return { value: "", errors };
6161
6142
  }
6162
6143
  const validated = validateColourValue(hexInput, val, key);
@@ -6539,42 +6520,6 @@ function validateSliderElement(element, key, context) {
6539
6520
  const max = element.max;
6540
6521
  const step = element.step ?? 1;
6541
6522
  const scale = element.scale || "linear";
6542
- const markValidity = (input, errorMessage) => {
6543
- if (!input) return;
6544
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
6545
- let errorElement = document.getElementById(errorId);
6546
- if (errorMessage) {
6547
- input.classList.add("invalid");
6548
- input.title = errorMessage;
6549
- if (!errorElement) {
6550
- errorElement = document.createElement("div");
6551
- errorElement.id = errorId;
6552
- errorElement.className = "error-message";
6553
- errorElement.style.cssText = `
6554
- color: var(--fb-error-color);
6555
- font-size: var(--fb-font-size-small);
6556
- margin-top: 0.25rem;
6557
- `;
6558
- const sliderContainer = input.closest(".slider-container");
6559
- if (sliderContainer && sliderContainer.nextSibling) {
6560
- sliderContainer.parentNode?.insertBefore(
6561
- errorElement,
6562
- sliderContainer.nextSibling
6563
- );
6564
- } else if (sliderContainer) {
6565
- sliderContainer.parentNode?.appendChild(errorElement);
6566
- }
6567
- }
6568
- errorElement.textContent = errorMessage;
6569
- errorElement.style.display = "block";
6570
- } else {
6571
- input.classList.remove("invalid");
6572
- input.title = "";
6573
- if (errorElement) {
6574
- errorElement.remove();
6575
- }
6576
- }
6577
- };
6578
6523
  const validateSliderValue = (slider, fieldKey) => {
6579
6524
  const { state } = context;
6580
6525
  const rawValue = slider.value;
@@ -6582,10 +6527,10 @@ function validateSliderElement(element, key, context) {
6582
6527
  if (!skipValidation && element.required) {
6583
6528
  const msg = t("required", state);
6584
6529
  errors.push(`${fieldKey}: ${msg}`);
6585
- markValidity(slider, msg);
6530
+ markFieldValidity(slider, msg);
6586
6531
  return null;
6587
6532
  }
6588
- markValidity(slider, null);
6533
+ markFieldValidity(slider, null);
6589
6534
  return null;
6590
6535
  }
6591
6536
  let value;
@@ -6601,17 +6546,17 @@ function validateSliderElement(element, key, context) {
6601
6546
  if (value < min) {
6602
6547
  const msg = t("minValue", state, { min });
6603
6548
  errors.push(`${fieldKey}: ${msg}`);
6604
- markValidity(slider, msg);
6549
+ markFieldValidity(slider, msg);
6605
6550
  return value;
6606
6551
  }
6607
6552
  if (value > max) {
6608
6553
  const msg = t("maxValue", state, { max });
6609
6554
  errors.push(`${fieldKey}: ${msg}`);
6610
- markValidity(slider, msg);
6555
+ markFieldValidity(slider, msg);
6611
6556
  return value;
6612
6557
  }
6613
6558
  }
6614
- markValidity(slider, null);
6559
+ markFieldValidity(slider, null);
6615
6560
  return value;
6616
6561
  };
6617
6562
  if (element.multiple) {
@@ -6756,22 +6701,12 @@ function extractRootFormData(formRoot) {
6756
6701
  inputs.forEach((input) => {
6757
6702
  const fieldName = input.getAttribute("name");
6758
6703
  if (fieldName && !fieldName.includes("[") && !fieldName.includes(".")) {
6759
- if (input instanceof HTMLSelectElement) {
6760
- data[fieldName] = input.value;
6761
- } else if (input instanceof HTMLInputElement) {
6762
- if (input.type === "checkbox") {
6763
- data[fieldName] = input.checked;
6764
- } else if (input.type === "radio") {
6765
- if (input.checked) {
6766
- data[fieldName] = input.value;
6767
- }
6768
- } else if (input.dataset.hiddenField) {
6769
- data[fieldName] = deserializeHiddenValue(input.value);
6770
- } else {
6704
+ if (input instanceof HTMLInputElement && input.type === "radio") {
6705
+ if (input.checked) {
6771
6706
  data[fieldName] = input.value;
6772
6707
  }
6773
- } else if (input instanceof HTMLTextAreaElement) {
6774
- data[fieldName] = input.value;
6708
+ } else {
6709
+ data[fieldName] = readTypedInputValue(input);
6775
6710
  }
6776
6711
  }
6777
6712
  });
@@ -6840,7 +6775,7 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
6840
6775
  };
6841
6776
  element.elements.forEach((child) => {
6842
6777
  if (child.type !== "markdown" && (child.hidden || child.type === "hidden")) {
6843
- 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;
6844
6779
  itemsWrap.appendChild(
6845
6780
  createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal)
6846
6781
  );
@@ -6941,7 +6876,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, pathKey) {
6941
6876
  );
6942
6877
  element.elements.forEach((child) => {
6943
6878
  if (child.type !== "markdown" && (child.hidden || child.type === "hidden")) {
6944
- 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;
6945
6880
  childWrapper.appendChild(
6946
6881
  createHiddenInput(pathJoin(subCtx.path, child.key), hiddenValue)
6947
6882
  );
@@ -7050,19 +6985,16 @@ function renderMultipleContainerElement(element, ctx, wrapper, pathKey) {
7050
6985
  }
7051
6986
  }
7052
6987
  }
7053
- var validateElementFunc = null;
7054
- function setValidateElement(fn) {
7055
- validateElementFunc = fn;
7056
- }
7057
- function validateElement(element, ctx, customScopeRoot) {
7058
- if (!validateElementFunc) {
6988
+ function requireValidateElement(context) {
6989
+ if (!context.validateElement) {
7059
6990
  throw new Error(
7060
- "validateElement not initialized. Should be set from FormBuilderInstance"
6991
+ "validateContainerElement: context.validateElement missing \u2014 container validation requires the instance validator"
7061
6992
  );
7062
6993
  }
7063
- return validateElementFunc(element, ctx, customScopeRoot);
6994
+ return context.validateElement;
7064
6995
  }
7065
6996
  function validateContainerElement(element, key, context) {
6997
+ const validateChild = requireValidateElement(context);
7066
6998
  const errors = [];
7067
6999
  const { scopeRoot, skipValidation, path } = context;
7068
7000
  if (!("elements" in element)) {
@@ -7113,7 +7045,7 @@ function validateContainerElement(element, key, context) {
7113
7045
  }
7114
7046
  }
7115
7047
  const childKey = `${key}[${domIndex}].${child.key}`;
7116
- const childResult = validateElement(
7048
+ const childResult = validateChild(
7117
7049
  { ...child, key: childKey },
7118
7050
  { path },
7119
7051
  itemContainer
@@ -7154,7 +7086,7 @@ function validateContainerElement(element, key, context) {
7154
7086
  }
7155
7087
  {
7156
7088
  const childKey = `${key}.${child.key}`;
7157
- const childResult = validateElement(
7089
+ const childResult = validateChild(
7158
7090
  { ...child, key: childKey },
7159
7091
  { path },
7160
7092
  containerContainer
@@ -7645,7 +7577,7 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
7645
7577
  rebuild();
7646
7578
  } catch (e) {
7647
7579
  const errMsg = e instanceof Error ? e.message : String(e);
7648
- console.error(t("tableImportError", state).replace("{error}", errMsg));
7580
+ console.error(t("tableImportError", state, { error: errMsg }));
7649
7581
  } finally {
7650
7582
  overlay.remove();
7651
7583
  }
@@ -8587,7 +8519,7 @@ function updateTableField(element, fieldPath, value, context) {
8587
8519
  }
8588
8520
 
8589
8521
  // src/components/richinput.ts
8590
- function applyAutoExpand2(textarea, backdrop) {
8522
+ function applyAutoExpand2(textarea, backdrop, observers) {
8591
8523
  textarea.style.overflow = "hidden";
8592
8524
  textarea.style.resize = "none";
8593
8525
  const lineCount = (textarea.value.match(/\n/g) || []).length + 1;
@@ -8609,6 +8541,7 @@ function applyAutoExpand2(textarea, backdrop) {
8609
8541
  const ro = new ResizeObserver((entries) => {
8610
8542
  if (!textarea.isConnected) {
8611
8543
  ro.disconnect();
8544
+ observers.delete(ro);
8612
8545
  return;
8613
8546
  }
8614
8547
  const entry = entries[0];
@@ -8618,6 +8551,7 @@ function applyAutoExpand2(textarea, backdrop) {
8618
8551
  resize();
8619
8552
  });
8620
8553
  ro.observe(textarea);
8554
+ observers.add(ro);
8621
8555
  }
8622
8556
  function buildFileLabels(files, state) {
8623
8557
  const labels = /* @__PURE__ */ new Map();
@@ -9187,7 +9121,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
9187
9121
  `;
9188
9122
  const textarea = document.createElement("textarea");
9189
9123
  textarea.name = `${pathKey}__text`;
9190
- textarea.placeholder = element.placeholder || t("richinputPlaceholder", state);
9124
+ textarea.placeholder = element.placeholder ?? t("richinputPlaceholder", state);
9191
9125
  const rawInitialText = initialValue.text ?? "";
9192
9126
  textarea.value = rawInitialText ? replaceRidsWithFilenames(rawInitialText, files, state) : "";
9193
9127
  textarea.style.cssText = `
@@ -9204,7 +9138,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
9204
9138
  z-index: 1;
9205
9139
  caret-color: var(--fb-text-color, #111827);
9206
9140
  `;
9207
- applyAutoExpand2(textarea, backdrop);
9141
+ applyAutoExpand2(textarea, backdrop, ctx.state.autoExpandObservers);
9208
9142
  textarea.addEventListener("scroll", () => {
9209
9143
  backdrop.scrollTop = textarea.scrollTop;
9210
9144
  });
@@ -10177,12 +10111,7 @@ function validateHiddenElement(element, key, context) {
10177
10111
  const input = scopeRoot.querySelector(
10178
10112
  `input[type="hidden"][data-hidden-field="true"][name="${key}"]`
10179
10113
  );
10180
- const raw = input?.value ?? "";
10181
- if (raw === "") {
10182
- const defaultVal = "default" in element ? element.default : null;
10183
- return { value: defaultVal !== void 0 ? defaultVal : null, errors: [] };
10184
- }
10185
- return { value: deserializeHiddenValue(raw), errors: [] };
10114
+ return { value: deserializeHiddenValue(input?.value ?? ""), errors: [] };
10186
10115
  }
10187
10116
  function updateHiddenField(_element, fieldPath, value, context) {
10188
10117
  const { scopeRoot } = context;
@@ -10373,25 +10302,13 @@ function extractDOMValue(fieldPath, formRoot) {
10373
10302
  if (!input) {
10374
10303
  return void 0;
10375
10304
  }
10376
- if (input instanceof HTMLSelectElement) {
10377
- return input.value;
10378
- } else if (input instanceof HTMLInputElement) {
10379
- if (input.type === "checkbox") {
10380
- return input.checked;
10381
- } else if (input.type === "radio") {
10382
- const checked = formRoot.querySelector(
10383
- `[name="${fieldPath}"]:checked`
10384
- );
10385
- return checked ? checked.value : void 0;
10386
- } else if (input.dataset.hiddenField) {
10387
- return deserializeHiddenValue(input.value);
10388
- } else {
10389
- return input.value;
10390
- }
10391
- } else if (input instanceof HTMLTextAreaElement) {
10392
- 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;
10393
10310
  }
10394
- return void 0;
10311
+ return readTypedInputValue(input);
10395
10312
  }
10396
10313
  function buildScopedDataAtPath(path, value) {
10397
10314
  const segments = path.match(/[^.[\]]+|\[\d+\]/g);
@@ -10520,11 +10437,19 @@ function createFieldLabel(element) {
10520
10437
  }
10521
10438
  return title;
10522
10439
  }
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
+ }
10523
10447
  function createInfoButton(element, state) {
10524
10448
  const infoBtn = document.createElement("button");
10525
10449
  infoBtn.type = "button";
10526
10450
  infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
10527
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);
10528
10453
  const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
10529
10454
  const tooltip = document.createElement("div");
10530
10455
  tooltip.id = tooltipId;
@@ -10705,6 +10630,7 @@ var defaultConfig = {
10705
10630
  onDownloadError: null,
10706
10631
  debounceMs: 300,
10707
10632
  verboseErrors: false,
10633
+ postMessageTarget: null,
10708
10634
  enableFilePreview: true,
10709
10635
  maxPreviewSize: "200px",
10710
10636
  readonly: false,
@@ -10726,6 +10652,7 @@ var defaultConfig = {
10726
10652
  openInNewTab: "Open in new tab",
10727
10653
  changeButton: "Change",
10728
10654
  placeholderText: "Enter text",
10655
+ selectPlaceholder: "Select\u2026",
10729
10656
  previewAlt: "Preview",
10730
10657
  previewUnavailable: "Preview unavailable",
10731
10658
  previewError: "Preview error",
@@ -10801,6 +10728,7 @@ var defaultConfig = {
10801
10728
  openInNewTab: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0439 \u0432\u043A\u043B\u0430\u0434\u043A\u0435",
10802
10729
  changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
10803
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",
10804
10732
  previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
10805
10733
  previewUnavailable: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D",
10806
10734
  previewError: "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440\u0430",
@@ -10865,20 +10793,23 @@ var defaultConfig = {
10865
10793
  },
10866
10794
  theme: {}
10867
10795
  };
10868
- function createInstanceState(config) {
10869
- const mergedTranslations = {
10870
- ...defaultConfig.translations
10871
- };
10872
- if (config?.translations) {
10873
- for (const [locale, userTranslations] of Object.entries(
10874
- config.translations
10875
- )) {
10876
- mergedTranslations[locale] = {
10877
- ...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] || {},
10878
10802
  ...userTranslations
10879
10803
  };
10880
10804
  }
10881
10805
  }
10806
+ return merged;
10807
+ }
10808
+ function createInstanceState(config) {
10809
+ const mergedTranslations = mergeTranslations(
10810
+ defaultConfig.translations,
10811
+ config?.translations
10812
+ );
10882
10813
  return {
10883
10814
  schema: null,
10884
10815
  formRoot: null,
@@ -10895,6 +10826,7 @@ function createInstanceState(config) {
10895
10826
  syntheticElementIds: /* @__PURE__ */ new WeakMap(),
10896
10827
  syntheticElementIdCounter: 0,
10897
10828
  enableIfObservers: /* @__PURE__ */ new Set(),
10829
+ autoExpandObservers: /* @__PURE__ */ new Set(),
10898
10830
  tooltipElements: /* @__PURE__ */ new Set()
10899
10831
  };
10900
10832
  }
@@ -11196,6 +11128,11 @@ function findOwnField(scope, lookupKey, ownBoundary) {
11196
11128
  }
11197
11129
  var FormBuilderInstance = class {
11198
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;
11199
11136
  this.instanceId = generateInstanceId();
11200
11137
  this.state = createInstanceState(config);
11201
11138
  if (this.state.config.verboseErrors) {
@@ -11229,10 +11166,21 @@ var FormBuilderInstance = class {
11229
11166
  this.state.formRoot = element;
11230
11167
  }
11231
11168
  /**
11232
- * 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.
11233
11172
  */
11234
11173
  configure(config) {
11235
- 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 });
11236
11184
  }
11237
11185
  /**
11238
11186
  * Set file upload handler
@@ -11265,12 +11213,16 @@ var FormBuilderInstance = class {
11265
11213
  this.state.config.readonly = mode === "readonly";
11266
11214
  }
11267
11215
  /**
11268
- * Set locale
11216
+ * Set locale. Custom locales are allowed — their translations must have
11217
+ * been provided via the constructor or configure() first.
11269
11218
  */
11270
11219
  setLocale(locale) {
11271
- if (this.state.config.translations[locale]) {
11272
- 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
+ );
11273
11224
  }
11225
+ this.state.config.locale = locale;
11274
11226
  }
11275
11227
  /**
11276
11228
  * Trigger onChange callbacks with debouncing
@@ -11623,11 +11575,13 @@ var FormBuilderInstance = class {
11623
11575
  renderForm(root, schema, prefill, actions) {
11624
11576
  const errors = validateSchema(schema);
11625
11577
  if (errors.length > 0) {
11626
- console.error("Schema validation errors:", errors);
11627
- return;
11578
+ throw new Error(`renderForm: invalid schema:
11579
+ - ${errors.join("\n- ")}`);
11628
11580
  }
11629
11581
  this.disconnectEnableIfObservers();
11582
+ this.disconnectAutoExpandObservers();
11630
11583
  this.removeTooltipElements();
11584
+ this.removePrefillHintListener();
11631
11585
  this.state.formRoot = root;
11632
11586
  this.state.schema = schema;
11633
11587
  this.state.externalActions = actions || null;
@@ -11650,7 +11604,7 @@ var FormBuilderInstance = class {
11650
11604
  }
11651
11605
  schema.elements.forEach((element) => {
11652
11606
  if (element.type !== "markdown" && (element.hidden || element.type === "hidden")) {
11653
- const val = prefill?.[element.key] ?? element.default ?? null;
11607
+ const val = prefill && element.key in prefill ? prefill[element.key] : element.default ?? null;
11654
11608
  fieldsWrapper.appendChild(createHiddenInput(element.key, val));
11655
11609
  return;
11656
11610
  }
@@ -11667,7 +11621,10 @@ var FormBuilderInstance = class {
11667
11621
  rootContainer.appendChild(fieldsWrapper);
11668
11622
  root.appendChild(rootContainer);
11669
11623
  if (!this.state.config.readonly) {
11670
- root.addEventListener("click", this.handlePrefillHintClick.bind(this));
11624
+ this.prefillHintHandler = this.handlePrefillHintClick.bind(
11625
+ this
11626
+ );
11627
+ root.addEventListener("click", this.prefillHintHandler);
11671
11628
  }
11672
11629
  if (this.state.config.readonly && this.state.externalActions && Array.isArray(this.state.externalActions)) {
11673
11630
  this.renderExternalActions();
@@ -11683,7 +11640,7 @@ var FormBuilderInstance = class {
11683
11640
  return { valid: true, errors: [], data: {} };
11684
11641
  const errors = [];
11685
11642
  const data = {};
11686
- const validateElement2 = (element, ctx, customScopeRoot = null) => {
11643
+ const validateElement = (element, ctx, customScopeRoot = null) => {
11687
11644
  const key = element.key ?? "";
11688
11645
  const scopeRoot = customScopeRoot || this.state.formRoot;
11689
11646
  const componentContext = {
@@ -11691,7 +11648,10 @@ var FormBuilderInstance = class {
11691
11648
  state: this.state,
11692
11649
  instance: this,
11693
11650
  path: ctx.path,
11694
- skipValidation
11651
+ skipValidation,
11652
+ // Containers recurse into their children through this — threaded per
11653
+ // pass, never module state (see ComponentContext.validateElement).
11654
+ validateElement
11695
11655
  };
11696
11656
  const componentResult = validateElementWithComponent(
11697
11657
  element,
@@ -11709,7 +11669,6 @@ var FormBuilderInstance = class {
11709
11669
  console.warn(`Unknown field type "${element.type}" for key "${key}"`);
11710
11670
  return { value: null, spread: false };
11711
11671
  };
11712
- setValidateElement(validateElement2);
11713
11672
  this.state.schema.elements.forEach((element) => {
11714
11673
  if (element.enableIf) {
11715
11674
  try {
@@ -11727,7 +11686,7 @@ var FormBuilderInstance = class {
11727
11686
  if (element.type === "markdown") {
11728
11687
  return;
11729
11688
  }
11730
- const result = validateElement2(element, { path: "" });
11689
+ const result = validateElement(element, { path: "" });
11731
11690
  if (result.skip) return;
11732
11691
  if (result.spread && result.value !== null && typeof result.value === "object") {
11733
11692
  Object.assign(data, result.value);
@@ -11753,16 +11712,7 @@ var FormBuilderInstance = class {
11753
11712
  submitForm() {
11754
11713
  const result = this.validateForm(false);
11755
11714
  if (result.valid) {
11756
- if (typeof window !== "undefined" && window.parent) {
11757
- window.parent.postMessage(
11758
- {
11759
- type: "formSubmit",
11760
- data: result.data,
11761
- schema: this.state.schema
11762
- },
11763
- "*"
11764
- );
11765
- }
11715
+ this.postToParent("formSubmit", result.data);
11766
11716
  }
11767
11717
  return result;
11768
11718
  }
@@ -11771,17 +11721,27 @@ var FormBuilderInstance = class {
11771
11721
  */
11772
11722
  saveDraft() {
11773
11723
  const result = this.validateForm(true);
11774
- if (typeof window !== "undefined" && window.parent) {
11775
- window.parent.postMessage(
11776
- {
11777
- type: "formDraft",
11778
- data: result.data,
11779
- schema: this.state.schema
11780
- },
11781
- "*"
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'
11782
11738
  );
11783
11739
  }
11784
- 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
+ );
11785
11745
  }
11786
11746
  /**
11787
11747
  * Clear the form - reset all field values to empty while preserving form structure
@@ -12051,7 +12011,9 @@ var FormBuilderInstance = class {
12051
12011
  this.state.debounceTimer = null;
12052
12012
  }
12053
12013
  this.disconnectEnableIfObservers();
12014
+ this.disconnectAutoExpandObservers();
12054
12015
  this.removeTooltipElements();
12016
+ this.removePrefillHintListener();
12055
12017
  this.state.resourceIndex.clear();
12056
12018
  if (this.state.formRoot) {
12057
12019
  clear(this.state.formRoot);
@@ -12069,6 +12031,18 @@ var FormBuilderInstance = class {
12069
12031
  }
12070
12032
  this.state.enableIfObservers.clear();
12071
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
+ }
12072
12046
  removeTooltipElements() {
12073
12047
  for (const tooltip of this.state.tooltipElements) {
12074
12048
  tooltip.remove();