@dmitryvim/form-builder 0.5.2 → 0.5.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
@@ -15,6 +15,69 @@ function t(key, state, params) {
15
15
  return text;
16
16
  }
17
17
 
18
+ // src/utils/helpers.ts
19
+ function isElementReadonly(element, state, ctx) {
20
+ return element.readonly === true || state.config.readonly === true || ctx?.inheritedReadonly === true;
21
+ }
22
+ function isPlainObject(obj) {
23
+ return obj && typeof obj === "object" && obj.constructor === Object;
24
+ }
25
+ function escapeHtml(text) {
26
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
27
+ }
28
+ function getElementLookupKey(element, state) {
29
+ if (element.key) {
30
+ return element.key;
31
+ }
32
+ const cached = state.syntheticElementIds.get(element);
33
+ if (cached !== void 0) {
34
+ return cached;
35
+ }
36
+ const id = `fb-synthetic-${state.syntheticElementIdCounter++}`;
37
+ state.syntheticElementIds.set(element, id);
38
+ return id;
39
+ }
40
+ function pathJoin(base, key) {
41
+ return base ? `${base}.${key}` : key;
42
+ }
43
+ function findDirectContainerRows(scopeRoot, containerPath) {
44
+ if (!containerPath) return [];
45
+ const all = scopeRoot.querySelectorAll("[data-container-item]");
46
+ return Array.from(all).filter((el) => {
47
+ const attr = el.getAttribute("data-container-item") || "";
48
+ if (!attr.startsWith(`${containerPath}[`)) return false;
49
+ return /^\[\d+\]$/.test(attr.slice(containerPath.length));
50
+ });
51
+ }
52
+ function clear(node) {
53
+ while (node.firstChild) node.removeChild(node.firstChild);
54
+ }
55
+ function formatFileSize(bytes) {
56
+ if (bytes < 1024) return `${bytes} B`;
57
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
58
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
59
+ }
60
+ function serializeHiddenValue(value) {
61
+ if (value === null || value === void 0) return "";
62
+ return JSON.stringify(value);
63
+ }
64
+ function deserializeHiddenValue(raw) {
65
+ if (raw === "") return null;
66
+ try {
67
+ return JSON.parse(raw);
68
+ } catch {
69
+ return raw;
70
+ }
71
+ }
72
+ function createHiddenInput(name, value) {
73
+ const input = document.createElement("input");
74
+ input.type = "hidden";
75
+ input.name = name;
76
+ input.setAttribute("data-hidden-field", "true");
77
+ input.value = serializeHiddenValue(value);
78
+ return input;
79
+ }
80
+
18
81
  // src/utils/validation.ts
19
82
  function addLengthHint(element, parts, state) {
20
83
  if (element.minLength != null || element.maxLength != null) {
@@ -184,6 +247,64 @@ function validateSchema(schema) {
184
247
  }
185
248
  }
186
249
  }
250
+ function validateCountBounds(element, elementPath, errors2) {
251
+ const el = element;
252
+ if (el.type === "group") {
253
+ if (!isPlainObject(el.repeat)) return;
254
+ checkBounds(
255
+ elementPath,
256
+ el.repeat?.min,
257
+ el.repeat?.max,
258
+ "repeat.min",
259
+ "repeat.max",
260
+ el.required === true,
261
+ errors2
262
+ );
263
+ return;
264
+ }
265
+ const isMultiple = el.multiple === true || el.type === "files";
266
+ if (!isMultiple) return;
267
+ checkBounds(
268
+ elementPath,
269
+ el.minCount,
270
+ el.maxCount,
271
+ "minCount",
272
+ "maxCount",
273
+ el.required === true,
274
+ errors2
275
+ );
276
+ }
277
+ function checkBounds(elementPath, minCount, maxCount, minName, maxName, requiredImpliesFloor, errors2) {
278
+ for (const [name, bound] of [
279
+ [minName, minCount],
280
+ [maxName, maxCount]
281
+ ]) {
282
+ if (bound !== void 0 && typeof bound !== "number") {
283
+ errors2.push(
284
+ `${elementPath}: ${name} must be a number (got ${typeof bound})`
285
+ );
286
+ }
287
+ }
288
+ const min = typeof minCount === "number" ? minCount : void 0;
289
+ const max = typeof maxCount === "number" ? maxCount : void 0;
290
+ if (max !== void 0 && (max < 0 || Number.isNaN(max))) {
291
+ errors2.push(
292
+ `${elementPath}: ${maxName} must be a non-negative number or Infinity (got ${max})`
293
+ );
294
+ }
295
+ if (min !== void 0 && (min < 0 || !Number.isFinite(min))) {
296
+ errors2.push(
297
+ `${elementPath}: ${minName} must be a finite non-negative number (got ${min})`
298
+ );
299
+ }
300
+ const effectiveMin = min ?? (requiredImpliesFloor ? 1 : void 0);
301
+ if (effectiveMin !== void 0 && max !== void 0 && effectiveMin > max) {
302
+ const shown = min !== void 0 ? `${minName} (${min})` : `required: true (implies ${minName} 1)`;
303
+ errors2.push(
304
+ `${elementPath}: ${shown} cannot be greater than ${maxName} (${max})`
305
+ );
306
+ }
307
+ }
187
308
  function validateElements(elements, path) {
188
309
  elements.forEach((element, index) => {
189
310
  const elementPath = `${path}[${index}]`;
@@ -193,6 +314,15 @@ function validateSchema(schema) {
193
314
  if (!element.key && element.type !== "markdown") {
194
315
  errors.push(`${elementPath}: missing key`);
195
316
  }
317
+ validateCountBounds(element, elementPath, errors);
318
+ if (element.type === "number" && "decimals" in element) {
319
+ const decimals = element.decimals;
320
+ if (decimals !== void 0 && (!Number.isInteger(decimals) || decimals < 0)) {
321
+ errors.push(
322
+ `${elementPath}: decimals must be a non-negative integer (got ${JSON.stringify(decimals)})`
323
+ );
324
+ }
325
+ }
196
326
  if (element.type === "markdown") {
197
327
  const content = element.content;
198
328
  if (typeof content !== "string") {
@@ -273,71 +403,6 @@ function validateSchema(schema) {
273
403
  return errors;
274
404
  }
275
405
 
276
- // src/utils/helpers.ts
277
- function isElementReadonly(element, state, ctx) {
278
- return element.readonly === true || state.config.readonly === true || ctx?.inheritedReadonly === true;
279
- }
280
- function isPlainObject(obj) {
281
- return obj && typeof obj === "object" && obj.constructor === Object;
282
- }
283
- function escapeHtml(text) {
284
- const div = document.createElement("div");
285
- div.textContent = text;
286
- return div.innerHTML;
287
- }
288
- function getElementLookupKey(element, state) {
289
- if (element.key) {
290
- return element.key;
291
- }
292
- const cached = state.syntheticElementIds.get(element);
293
- if (cached !== void 0) {
294
- return cached;
295
- }
296
- const id = `fb-synthetic-${state.syntheticElementIdCounter++}`;
297
- state.syntheticElementIds.set(element, id);
298
- return id;
299
- }
300
- function pathJoin(base, key) {
301
- return base ? `${base}.${key}` : key;
302
- }
303
- function findDirectContainerRows(scopeRoot, containerPath) {
304
- if (!containerPath) return [];
305
- const all = scopeRoot.querySelectorAll("[data-container-item]");
306
- return Array.from(all).filter((el) => {
307
- const attr = el.getAttribute("data-container-item") || "";
308
- if (!attr.startsWith(`${containerPath}[`)) return false;
309
- return /^\[\d+\]$/.test(attr.slice(containerPath.length));
310
- });
311
- }
312
- function clear(node) {
313
- while (node.firstChild) node.removeChild(node.firstChild);
314
- }
315
- function formatFileSize(bytes) {
316
- if (bytes < 1024) return `${bytes} B`;
317
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
318
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
319
- }
320
- function serializeHiddenValue(value) {
321
- if (value === null || value === void 0) return "";
322
- return typeof value === "object" ? JSON.stringify(value) : String(value);
323
- }
324
- function deserializeHiddenValue(raw) {
325
- if (raw === "") return null;
326
- try {
327
- return JSON.parse(raw);
328
- } catch {
329
- return raw;
330
- }
331
- }
332
- function createHiddenInput(name, value) {
333
- const input = document.createElement("input");
334
- input.type = "hidden";
335
- input.name = name;
336
- input.setAttribute("data-hidden-field", "true");
337
- input.value = serializeHiddenValue(value);
338
- return input;
339
- }
340
-
341
406
  // src/utils/enable-conditions.ts
342
407
  function getValueByPath(data, path) {
343
408
  if (!data || typeof data !== "object") {
@@ -884,7 +949,7 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
884
949
  overflow-wrap: anywhere;
885
950
  `;
886
951
  textInput.name = pathKey;
887
- textInput.placeholder = element.placeholder ?? "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
952
+ textInput.placeholder = element.placeholder ?? t("placeholderText", state);
888
953
  textInput.value = ctx.prefill[element.key] || element.default || "";
889
954
  textInput.readOnly = readonly;
890
955
  applySingleLineMode(textInput);
@@ -996,6 +1061,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
996
1061
  updateIndices();
997
1062
  updateAddButton();
998
1063
  updateRemoveButtons();
1064
+ ctx.instance?.triggerOnChange(pathKey);
999
1065
  };
1000
1066
  chip.appendChild(rem);
1001
1067
  }
@@ -1020,6 +1086,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1020
1086
  addChip(element.default || "");
1021
1087
  updateAddButton();
1022
1088
  updateRemoveButtons();
1089
+ ctx.instance?.triggerOnChange(pathKey);
1023
1090
  },
1024
1091
  { label: element.addLabel }
1025
1092
  );
@@ -1133,7 +1200,7 @@ function validateTextElement(element, key, context) {
1133
1200
  }
1134
1201
  return { value: values, errors };
1135
1202
  } else {
1136
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1203
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
1137
1204
  const val = input?.value ?? "";
1138
1205
  if (!skipValidation && element.required && val === "") {
1139
1206
  const msg = t("required", context.state);
@@ -1303,6 +1370,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1303
1370
  updateIndices();
1304
1371
  updateAddButton();
1305
1372
  updateRemoveButtons();
1373
+ ctx.instance?.triggerOnChange(pathKey);
1306
1374
  }
1307
1375
  };
1308
1376
  item.appendChild(removeBtn);
@@ -1322,6 +1390,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1322
1390
  addTextareaItem(element.default || "");
1323
1391
  updateAddButton();
1324
1392
  updateRemoveButtons();
1393
+ ctx.instance?.triggerOnChange(pathKey);
1325
1394
  },
1326
1395
  { label: element.addLabel }
1327
1396
  );
@@ -1613,6 +1682,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1613
1682
  updateIndices();
1614
1683
  updateAddButton();
1615
1684
  updateRemoveButtons();
1685
+ ctx.instance?.triggerOnChange(pathKey);
1616
1686
  }
1617
1687
  };
1618
1688
  item.appendChild(removeBtn);
@@ -1632,6 +1702,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1632
1702
  addNumberItem(element.default || "");
1633
1703
  updateAddButton();
1634
1704
  updateRemoveButtons();
1705
+ ctx.instance?.triggerOnChange(pathKey);
1635
1706
  },
1636
1707
  { label: element.addLabel }
1637
1708
  );
@@ -1720,8 +1791,7 @@ function validateNumberElement(element, key, context) {
1720
1791
  return;
1721
1792
  }
1722
1793
  validateNumberInput(input, v, `${key}[${index}]`);
1723
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals ?? 0 : 0;
1724
- values.push(Number(v.toFixed(d)));
1794
+ values.push(applyDecimals(v, element.decimals));
1725
1795
  });
1726
1796
  if (!skipValidation) {
1727
1797
  const { state } = context;
@@ -1740,7 +1810,7 @@ function validateNumberElement(element, key, context) {
1740
1810
  }
1741
1811
  return { value: values, errors };
1742
1812
  } else {
1743
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1813
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
1744
1814
  const raw = input?.value ?? "";
1745
1815
  const { state } = context;
1746
1816
  if (!skipValidation && element.required && raw === "") {
@@ -1761,10 +1831,13 @@ function validateNumberElement(element, key, context) {
1761
1831
  return { value: null, errors };
1762
1832
  }
1763
1833
  validateNumberInput(input, v, key);
1764
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals ?? 0 : 0;
1765
- return { value: Number(v.toFixed(d)), errors };
1834
+ return { value: applyDecimals(v, element.decimals), errors };
1766
1835
  }
1767
1836
  }
1837
+ function applyDecimals(v, decimals) {
1838
+ if (!Number.isInteger(decimals) || decimals < 0) return v;
1839
+ return Number(v.toFixed(decimals));
1840
+ }
1768
1841
  function updateNumberField(element, fieldPath, value, context) {
1769
1842
  const { scopeRoot } = context;
1770
1843
  if (element.multiple) {
@@ -1917,6 +1990,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1917
1990
  updateIndices();
1918
1991
  updateAddButton();
1919
1992
  updateRemoveButtons();
1993
+ ctx.instance?.triggerOnChange(pathKey);
1920
1994
  }
1921
1995
  };
1922
1996
  item.appendChild(removeBtn);
@@ -1937,6 +2011,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1937
2011
  addSelectItem(defaultValue);
1938
2012
  updateAddButton();
1939
2013
  updateRemoveButtons();
2014
+ ctx.instance?.triggerOnChange(pathKey);
1940
2015
  },
1941
2016
  { label: element.addLabel }
1942
2017
  );
@@ -2022,7 +2097,7 @@ function validateSelectElement(element, key, context) {
2022
2097
  return { value: values, errors };
2023
2098
  } else {
2024
2099
  const input = scopeRoot.querySelector(
2025
- `[name$="${key}"]`
2100
+ `[name="${key}"]`
2026
2101
  );
2027
2102
  const val = input?.value ?? "";
2028
2103
  if (!skipValidation && element.required && val === "") {
@@ -2354,6 +2429,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2354
2429
  updateIndices();
2355
2430
  updateAddButton();
2356
2431
  updateRemoveButtons();
2432
+ ctx.instance?.triggerOnChange(pathKey);
2357
2433
  }
2358
2434
  };
2359
2435
  item.appendChild(removeBtn);
@@ -2374,6 +2450,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2374
2450
  addSwitcherItem(defaultValue);
2375
2451
  updateAddButton();
2376
2452
  updateRemoveButtons();
2453
+ ctx.instance?.triggerOnChange(pathKey);
2377
2454
  },
2378
2455
  { label: element.addLabel }
2379
2456
  );
@@ -2468,7 +2545,7 @@ function validateSwitcherElement(element, key, context) {
2468
2545
  return { value: values, errors };
2469
2546
  } else {
2470
2547
  const input = scopeRoot.querySelector(
2471
- `input[type="hidden"][name$="${key}"]`
2548
+ `input[type="hidden"][name="${key}"]`
2472
2549
  );
2473
2550
  const val = input?.value ?? "";
2474
2551
  if (!skipValidation && element.required && val === "") {
@@ -5510,7 +5587,7 @@ function validateSingleFile(element, key, context) {
5510
5587
  const { scopeRoot, skipValidation, state } = context;
5511
5588
  const errors = [];
5512
5589
  const input = scopeRoot.querySelector(
5513
- `input[name$="${key}"][type="hidden"]`
5590
+ `input[name="${key}"][type="hidden"]`
5514
5591
  );
5515
5592
  const rid = input?.value ?? "";
5516
5593
  if (!skipValidation && element.required && rid === "") {
@@ -5942,6 +6019,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5942
6019
  updateIndices();
5943
6020
  updateAddButton();
5944
6021
  updateRemoveButtons();
6022
+ ctx.instance?.triggerOnChange(pathKey);
5945
6023
  }
5946
6024
  };
5947
6025
  item.appendChild(removeBtn);
@@ -5962,6 +6040,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5962
6040
  addColourItem(defaultColour);
5963
6041
  updateAddButton();
5964
6042
  updateRemoveButtons();
6043
+ ctx.instance?.triggerOnChange(pathKey);
5965
6044
  },
5966
6045
  { label: element.addLabel }
5967
6046
  );
@@ -6398,6 +6477,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
6398
6477
  updateIndices();
6399
6478
  updateAddButton();
6400
6479
  updateRemoveButtons();
6480
+ ctx.instance?.triggerOnChange(pathKey);
6401
6481
  }
6402
6482
  };
6403
6483
  item.appendChild(removeBtn);
@@ -6417,6 +6497,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
6417
6497
  addSliderItem(defaultValue);
6418
6498
  updateAddButton();
6419
6499
  updateRemoveButtons();
6500
+ ctx.instance?.triggerOnChange(pathKey);
6420
6501
  },
6421
6502
  { label: element.addLabel }
6422
6503
  );
@@ -6684,6 +6765,8 @@ function extractRootFormData(formRoot) {
6684
6765
  if (input.checked) {
6685
6766
  data[fieldName] = input.value;
6686
6767
  }
6768
+ } else if (input.dataset.hiddenField) {
6769
+ data[fieldName] = deserializeHiddenValue(input.value);
6687
6770
  } else {
6688
6771
  data[fieldName] = input.value;
6689
6772
  }
@@ -6772,11 +6855,14 @@ function getChildWrapperClass(columns) {
6772
6855
  const cols = columns || 1;
6773
6856
  return cols === 1 ? "fb-row" : `grid grid-cols-${cols} gap-2`;
6774
6857
  }
6775
- function mountRemoveButton(item, onRemove, state) {
6858
+ function mountRemoveButton(item, onRemove, state, containerLabel) {
6776
6859
  const rem = document.createElement("button");
6777
6860
  rem.type = "button";
6778
6861
  rem.className = "fb-item-remove";
6779
- rem.setAttribute("aria-label", t("removeElement", state));
6862
+ rem.setAttribute(
6863
+ "aria-label",
6864
+ containerLabel ? t("removeRowFrom", state, { label: containerLabel }) : t("removeRow", state)
6865
+ );
6780
6866
  rem.style.cssText = `
6781
6867
  width: 24px;
6782
6868
  height: 24px;
@@ -6799,7 +6885,7 @@ function mountRemoveButton(item, onRemove, state) {
6799
6885
  item.classList.add("fb-row-removable");
6800
6886
  item.insertBefore(rem, item.firstChild);
6801
6887
  }
6802
- function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6888
+ function renderMultipleContainerElement(element, ctx, wrapper, pathKey) {
6803
6889
  const state = ctx.state;
6804
6890
  const containerIsReadonly = isElementReadonly(element, state, ctx);
6805
6891
  const childInheritedReadonly = containerIsReadonly || ctx.inheritedReadonly;
@@ -6832,6 +6918,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6832
6918
  if (countItems() <= min) return;
6833
6919
  item.remove();
6834
6920
  updateAddButton();
6921
+ ctx.instance?.triggerOnChange(pathKey);
6835
6922
  };
6836
6923
  const createContainerItem = (idx, rowPrefill, formData) => {
6837
6924
  const subCtx = {
@@ -6864,7 +6951,12 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6864
6951
  });
6865
6952
  item.appendChild(childWrapper);
6866
6953
  if (!containerIsReadonly) {
6867
- mountRemoveButton(item, () => handleRemoveItem(item), state);
6954
+ mountRemoveButton(
6955
+ item,
6956
+ () => handleRemoveItem(item),
6957
+ state,
6958
+ element.label
6959
+ );
6868
6960
  }
6869
6961
  return item;
6870
6962
  };
@@ -6883,6 +6975,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6883
6975
  itemsWrap.appendChild(item);
6884
6976
  }
6885
6977
  updateAddButton();
6978
+ ctx.instance?.triggerOnChange(pathKey);
6886
6979
  };
6887
6980
  let slideAddTile = null;
6888
6981
  let slideAddUpdate = null;
@@ -7027,7 +7120,7 @@ function validateContainerElement(element, key, context) {
7027
7120
  );
7028
7121
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
7029
7122
  Object.assign(itemData, childResult.value);
7030
- } else {
7123
+ } else if (!childResult.skip && child.key) {
7031
7124
  itemData[child.key] = childResult.value;
7032
7125
  }
7033
7126
  });
@@ -7068,7 +7161,7 @@ function validateContainerElement(element, key, context) {
7068
7161
  );
7069
7162
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
7070
7163
  Object.assign(containerData, childResult.value);
7071
- } else {
7164
+ } else if (!childResult.skip && child.key) {
7072
7165
  containerData[child.key] = childResult.value;
7073
7166
  }
7074
7167
  }
@@ -7088,11 +7181,13 @@ function updateContainerField(element, fieldPath, value, context) {
7088
7181
  );
7089
7182
  return;
7090
7183
  }
7184
+ const rows = findDirectContainerRows(scopeRoot, fieldPath);
7091
7185
  value.forEach((itemValue, index) => {
7092
- if (isPlainObject(itemValue)) {
7186
+ const rowPath = rows[index]?.getAttribute("data-container-item");
7187
+ if (isPlainObject(itemValue) && rowPath) {
7093
7188
  element.elements.forEach((childElement) => {
7094
7189
  if (childElement.type === "markdown" || !childElement.key) return;
7095
- const childPath = `${fieldPath}[${index}].${childElement.key}`;
7190
+ const childPath = `${rowPath}.${childElement.key}`;
7096
7191
  if (childElement.type === "richinput" && childElement.flatOutput) {
7097
7192
  const richChild = childElement;
7098
7193
  const textKey = richChild.textKey ?? "text";
@@ -7115,10 +7210,9 @@ function updateContainerField(element, fieldPath, value, context) {
7115
7210
  });
7116
7211
  }
7117
7212
  });
7118
- const existingContainers = findDirectContainerRows(scopeRoot, fieldPath);
7119
- if (value.length !== existingContainers.length) {
7213
+ if (value.length !== rows.length) {
7120
7214
  console.warn(
7121
- `updateContainerField: Multiple container field "${fieldPath}" item count mismatch. Consider re-rendering for add/remove.`
7215
+ `updateContainerField: Multiple container field "${fieldPath}" received ${value.length} items for ${rows.length} rendered rows. Rows are not added or removed here \u2014 re-render for add/remove.`
7122
7216
  );
7123
7217
  }
7124
7218
  } else {
@@ -7175,7 +7269,7 @@ function renderGroupElement(element, ctx, wrapper, pathKey) {
7175
7269
  maxCount: element.repeat?.max
7176
7270
  };
7177
7271
  if (containerElement.multiple) {
7178
- renderMultipleContainerElement(containerElement, ctx, wrapper);
7272
+ renderMultipleContainerElement(containerElement, ctx, wrapper, pathKey);
7179
7273
  } else {
7180
7274
  renderSingleContainerElement(containerElement, ctx, wrapper, pathKey);
7181
7275
  }
@@ -10172,15 +10266,19 @@ var componentRegistry = {
10172
10266
  function getComponentOperations(elementType) {
10173
10267
  return componentRegistry[elementType] || null;
10174
10268
  }
10269
+ function resolveOperations(element) {
10270
+ const isHiddenField = element.type !== "markdown" && (element.type === "hidden" || Boolean(element.hidden));
10271
+ return isHiddenField ? componentRegistry.hidden : getComponentOperations(element.type);
10272
+ }
10175
10273
  function validateElementWithComponent(element, key, context) {
10176
- const ops = getComponentOperations(element.type);
10274
+ const ops = resolveOperations(element);
10177
10275
  if (ops && ops.validate) {
10178
10276
  return ops.validate(element, key, context);
10179
10277
  }
10180
10278
  return null;
10181
10279
  }
10182
10280
  function updateElementWithComponent(element, fieldPath, value, context) {
10183
- const ops = getComponentOperations(element.type);
10281
+ const ops = resolveOperations(element);
10184
10282
  if (ops && ops.update) {
10185
10283
  ops.update(element, fieldPath, value, context);
10186
10284
  return true;
@@ -10192,6 +10290,10 @@ function updateElementWithComponent(element, fieldPath, value, context) {
10192
10290
  function showTooltip(tooltipId, button) {
10193
10291
  const tooltip = document.getElementById(tooltipId);
10194
10292
  if (!tooltip) return;
10293
+ if (!button.isConnected) {
10294
+ tooltip.remove();
10295
+ return;
10296
+ }
10195
10297
  const isCurrentlyVisible = !tooltip.classList.contains("hidden");
10196
10298
  document.querySelectorAll('[id^="tooltip-"]').forEach((t2) => {
10197
10299
  t2.classList.add("hidden");
@@ -10281,6 +10383,8 @@ function extractDOMValue(fieldPath, formRoot) {
10281
10383
  `[name="${fieldPath}"]:checked`
10282
10384
  );
10283
10385
  return checked ? checked.value : void 0;
10386
+ } else if (input.dataset.hiddenField) {
10387
+ return deserializeHiddenValue(input.value);
10284
10388
  } else {
10285
10389
  return input.value;
10286
10390
  }
@@ -10416,7 +10520,7 @@ function createFieldLabel(element) {
10416
10520
  }
10417
10521
  return title;
10418
10522
  }
10419
- function createInfoButton(element) {
10523
+ function createInfoButton(element, state) {
10420
10524
  const infoBtn = document.createElement("button");
10421
10525
  infoBtn.type = "button";
10422
10526
  infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
@@ -10428,6 +10532,7 @@ function createInfoButton(element) {
10428
10532
  tooltip.style.position = "fixed";
10429
10533
  tooltip.textContent = element.description || element.hint || "Field information";
10430
10534
  document.body.appendChild(tooltip);
10535
+ state.tooltipElements.add(tooltip);
10431
10536
  infoBtn.onclick = (e) => {
10432
10537
  e.preventDefault();
10433
10538
  e.stopPropagation();
@@ -10435,7 +10540,7 @@ function createInfoButton(element) {
10435
10540
  };
10436
10541
  return infoBtn;
10437
10542
  }
10438
- function createLabelContainer(element) {
10543
+ function createLabelContainer(element, state) {
10439
10544
  const label = document.createElement("div");
10440
10545
  label.className = "flex items-center";
10441
10546
  label.style.marginBottom = "var(--fb-label-margin-bottom, 2px)";
@@ -10443,7 +10548,7 @@ function createLabelContainer(element) {
10443
10548
  const title = createFieldLabel(element);
10444
10549
  label.appendChild(title);
10445
10550
  if (element.description || element.hint) {
10446
- const infoBtn = createInfoButton(element);
10551
+ const infoBtn = createInfoButton(element, state);
10447
10552
  label.appendChild(infoBtn);
10448
10553
  }
10449
10554
  return label;
@@ -10518,7 +10623,7 @@ function dispatchToRenderer(element, ctx, wrapper, pathKey) {
10518
10623
  break;
10519
10624
  case "container":
10520
10625
  if (isMultiple) {
10521
- renderMultipleContainerElement(element, ctx, wrapper);
10626
+ renderMultipleContainerElement(element, ctx, wrapper, pathKey);
10522
10627
  } else {
10523
10628
  renderSingleContainerElement(element, ctx, wrapper, pathKey);
10524
10629
  }
@@ -10571,7 +10676,7 @@ function renderElement2(element, ctx) {
10571
10676
  wrapper.setAttribute("data-fb-width", element.width || "full");
10572
10677
  const ops = getComponentOperations(element.type);
10573
10678
  if (!ops?.ownsLabel) {
10574
- const label = createLabelContainer(element);
10679
+ const label = createLabelContainer(element, ctx.state);
10575
10680
  wrapper.appendChild(label);
10576
10681
  }
10577
10682
  const pathKey = pathJoin(ctx.path, element.key);
@@ -10610,6 +10715,8 @@ var defaultConfig = {
10610
10715
  en: {
10611
10716
  // UI texts
10612
10717
  removeElement: "Remove",
10718
+ removeRow: "Remove row",
10719
+ removeRowFrom: "Remove row from {label}",
10613
10720
  clickDragText: "Click or drag file",
10614
10721
  clickDragTextMultiple: "Click or drag files",
10615
10722
  noFileSelected: "No file selected",
@@ -10683,6 +10790,8 @@ var defaultConfig = {
10683
10790
  ru: {
10684
10791
  // UI texts
10685
10792
  removeElement: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
10793
+ removeRow: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443",
10794
+ removeRowFrom: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443 \u0438\u0437 \xAB{label}\xBB",
10686
10795
  clickDragText: "\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B",
10687
10796
  clickDragTextMultiple: "\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B\u044B",
10688
10797
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
@@ -10785,7 +10894,8 @@ function createInstanceState(config) {
10785
10894
  prefill: {},
10786
10895
  syntheticElementIds: /* @__PURE__ */ new WeakMap(),
10787
10896
  syntheticElementIdCounter: 0,
10788
- enableIfObservers: /* @__PURE__ */ new Set()
10897
+ enableIfObservers: /* @__PURE__ */ new Set(),
10898
+ tooltipElements: /* @__PURE__ */ new Set()
10789
10899
  };
10790
10900
  }
10791
10901
  function generateInstanceId() {
@@ -11072,6 +11182,18 @@ var exampleThemes = {
11072
11182
  };
11073
11183
 
11074
11184
  // src/instance/FormBuilderInstance.ts
11185
+ function findOwnField(scope, lookupKey, ownBoundary) {
11186
+ const matches = scope.querySelectorAll(
11187
+ `[data-field-key="${lookupKey}"]`
11188
+ );
11189
+ for (const el of Array.from(matches)) {
11190
+ const boundary = el.closest(
11191
+ "[data-container-item], [data-container]"
11192
+ );
11193
+ if (boundary === ownBoundary) return el;
11194
+ }
11195
+ return null;
11196
+ }
11075
11197
  var FormBuilderInstance = class {
11076
11198
  constructor(config) {
11077
11199
  this.instanceId = generateInstanceId();
@@ -11153,7 +11275,11 @@ var FormBuilderInstance = class {
11153
11275
  /**
11154
11276
  * Trigger onChange callbacks with debouncing
11155
11277
  * @param fieldPath - Optional field path for field-specific change events
11156
- * @param fieldValue - Optional field value for field-specific change events
11278
+ * @param fieldValue - Optional field value for field-specific change events.
11279
+ * When omitted while fieldPath is given, the value is read from the
11280
+ * freshly extracted form data at debounce time — used by structural
11281
+ * changes (multi-item add/remove), where the handler has no cheap
11282
+ * current value but the array is trivially derivable after the fact.
11157
11283
  */
11158
11284
  triggerOnChange(fieldPath, fieldValue) {
11159
11285
  if (this.state.config.readonly) return;
@@ -11166,12 +11292,49 @@ var FormBuilderInstance = class {
11166
11292
  if (this.state.config.onChange) {
11167
11293
  this.state.config.onChange(formData);
11168
11294
  }
11169
- if (this.state.config.onFieldChange && fieldPath !== void 0 && fieldValue !== void 0) {
11170
- this.state.config.onFieldChange(fieldPath, fieldValue, formData);
11295
+ if (this.state.config.onFieldChange && fieldPath !== void 0) {
11296
+ const resolvedValue = fieldValue !== void 0 ? fieldValue : this.resolveDomPathValue(formData.data, fieldPath);
11297
+ this.state.config.onFieldChange(fieldPath, resolvedValue, formData);
11171
11298
  }
11172
11299
  this.state.debounceTimer = null;
11173
11300
  }, this.state.config.debounceMs);
11174
11301
  }
11302
+ /**
11303
+ * Resolve a DOM field path against the extracted form data.
11304
+ *
11305
+ * A plain getValueByPath is wrong for paths inside a multiple container:
11306
+ * row markers keep gaps after a deletion (`s[2]` may be the first surviving
11307
+ * row) while the extracted array is re-packed contiguously — the naive
11308
+ * lookup would read a different row, or nothing. Each `[N]` segment is
11309
+ * mapped from its marker to the row's position among the container's
11310
+ * rendered rows, the same DOM order extraction used to build the array.
11311
+ * A bracketed segment that is not a container marker (a multi-value leaf
11312
+ * like `tags[1]`, whose indices are contiguous) falls back to the index.
11313
+ */
11314
+ resolveDomPathValue(data, domPath) {
11315
+ let cur = data;
11316
+ let domPrefix = "";
11317
+ for (const seg of domPath.split(".")) {
11318
+ if (cur === null || cur === void 0) return void 0;
11319
+ const marker = seg.match(/^(.+)\[(\d+)\]$/);
11320
+ if (!marker) {
11321
+ domPrefix = domPrefix ? `${domPrefix}.${seg}` : seg;
11322
+ cur = cur[seg];
11323
+ continue;
11324
+ }
11325
+ const key = marker[1];
11326
+ domPrefix = domPrefix ? `${domPrefix}.${key}` : key;
11327
+ const arr = cur[key];
11328
+ if (!Array.isArray(arr)) return void 0;
11329
+ const rows = this.state.formRoot ? findDirectContainerRows(this.state.formRoot, domPrefix) : [];
11330
+ domPrefix = `${domPrefix}[${marker[2]}]`;
11331
+ const pos = rows.findIndex(
11332
+ (row) => row.getAttribute("data-container-item") === domPrefix
11333
+ );
11334
+ cur = pos >= 0 ? arr[pos] : arr[parseInt(marker[2], 10)];
11335
+ }
11336
+ return cur;
11337
+ }
11175
11338
  /**
11176
11339
  * Register an external action that will be displayed as a button
11177
11340
  * External actions can be form-level (no related_field) or field-level (with related_field)
@@ -11211,21 +11374,10 @@ var FormBuilderInstance = class {
11211
11374
  */
11212
11375
  findFormElementByFieldPath(fieldPath) {
11213
11376
  if (!this.state.formRoot) return null;
11214
- let element = this.state.formRoot.querySelector(
11377
+ const element = this.state.formRoot.querySelector(
11215
11378
  `[name="${fieldPath}"]`
11216
11379
  );
11217
11380
  if (element) return element;
11218
- const variations = [
11219
- fieldPath,
11220
- fieldPath.replace(/\[(\d+)\]/g, "[$1]"),
11221
- fieldPath.replace(/\./g, "[") + "]".repeat((fieldPath.match(/\./g) || []).length)
11222
- ];
11223
- for (const variation of variations) {
11224
- element = this.state.formRoot.querySelector(
11225
- `[name="${variation}"]`
11226
- );
11227
- if (element) return element;
11228
- }
11229
11381
  const schemaElement = this.findSchemaElement(fieldPath);
11230
11382
  if (!schemaElement) return null;
11231
11383
  const fieldWrappers = this.state.formRoot.querySelectorAll(".fb-field-wrapper");
@@ -11475,6 +11627,7 @@ var FormBuilderInstance = class {
11475
11627
  return;
11476
11628
  }
11477
11629
  this.disconnectEnableIfObservers();
11630
+ this.removeTooltipElements();
11478
11631
  this.state.formRoot = root;
11479
11632
  this.state.schema = schema;
11480
11633
  this.state.externalActions = actions || null;
@@ -11574,24 +11727,12 @@ var FormBuilderInstance = class {
11574
11727
  if (element.type === "markdown") {
11575
11728
  return;
11576
11729
  }
11577
- if (element.hidden || element.type === "hidden") {
11578
- const hiddenInput = this.state.formRoot.querySelector(
11579
- `input[type="hidden"][data-hidden-field="true"][name="${element.key}"]`
11580
- );
11581
- const raw = hiddenInput?.value ?? "";
11582
- if (raw !== "") {
11583
- data[element.key] = deserializeHiddenValue(raw);
11584
- } else {
11585
- data[element.key] = element.default !== void 0 ? element.default : null;
11586
- }
11587
- } else {
11588
- const result = validateElement2(element, { path: "" });
11589
- if (result.skip) return;
11590
- if (result.spread && result.value !== null && typeof result.value === "object") {
11591
- Object.assign(data, result.value);
11592
- } else if (element.key) {
11593
- data[element.key] = result.value;
11594
- }
11730
+ const result = validateElement2(element, { path: "" });
11731
+ if (result.skip) return;
11732
+ if (result.spread && result.value !== null && typeof result.value === "object") {
11733
+ Object.assign(data, result.value);
11734
+ } else if (element.key) {
11735
+ data[element.key] = result.value;
11595
11736
  }
11596
11737
  });
11597
11738
  return {
@@ -11794,28 +11935,28 @@ var FormBuilderInstance = class {
11794
11935
  const formRoot = this.state.formRoot;
11795
11936
  const lookupKey = getElementLookupKey(element, this.state);
11796
11937
  if (!currentPath) {
11797
- return formRoot.querySelector(`[data-field-key="${lookupKey}"]`);
11938
+ return findOwnField(formRoot, lookupKey, null);
11798
11939
  }
11799
11940
  const pathMatch = currentPath.match(/^(.+)\[(\d+)\]$/);
11800
11941
  if (pathMatch) {
11801
11942
  const containerEl2 = formRoot.querySelector(
11802
11943
  `[data-container-item="${pathMatch[1]}[${pathMatch[2]}]"]`
11803
11944
  );
11804
- return containerEl2 ? containerEl2.querySelector(`[data-field-key="${lookupKey}"]`) : null;
11945
+ return containerEl2 ? findOwnField(containerEl2, lookupKey, containerEl2) : null;
11805
11946
  }
11806
11947
  const containerEl = formRoot.querySelector(
11807
11948
  `[data-container="${currentPath}"]`
11808
11949
  );
11809
- return containerEl ? containerEl.querySelector(`[data-field-key="${lookupKey}"]`) : null;
11950
+ return containerEl ? findOwnField(containerEl, lookupKey, containerEl) : null;
11810
11951
  }
11811
11952
  /**
11812
11953
  * Apply enableIf show/hide logic to a single field wrapper.
11813
11954
  * Extracted to reduce cyclomatic complexity of checkElements.
11814
11955
  */
11815
- applyEnableIfVisibility(element, wrapper, currentPath, fullPath, formData) {
11956
+ applyEnableIfVisibility(element, wrapper, domPath, dataPath, fullDomPath, formData) {
11816
11957
  try {
11817
11958
  const scope = element.enableIf.scope ?? "relative";
11818
- const containerData = scope === "relative" && currentPath ? getValueByPath(formData, currentPath) : void 0;
11959
+ const containerData = scope === "relative" && dataPath ? getValueByPath(formData, dataPath) : void 0;
11819
11960
  const shouldEnable = evaluateEnableCondition(
11820
11961
  element.enableIf,
11821
11962
  formData,
@@ -11823,10 +11964,12 @@ var FormBuilderInstance = class {
11823
11964
  );
11824
11965
  const isCurrentlyDisabled = wrapper.getAttribute("data-conditionally-disabled") === "true";
11825
11966
  if (shouldEnable && isCurrentlyDisabled) {
11826
- const containerPrefill = currentPath ? getValueByPath(formData, currentPath) : formData;
11967
+ const containerPrefill = dataPath ? getValueByPath(formData, dataPath) : formData;
11827
11968
  const prefillContext = containerPrefill && typeof containerPrefill === "object" ? containerPrefill : {};
11828
11969
  const newWrapper = renderElement2(element, {
11829
- path: currentPath,
11970
+ // DOM path: the re-rendered control's `name` must match the row it
11971
+ // lives in, not the row's position in the extracted array.
11972
+ path: domPath,
11830
11973
  prefill: prefillContext,
11831
11974
  formData,
11832
11975
  state: this.state,
@@ -11846,7 +11989,7 @@ var FormBuilderInstance = class {
11846
11989
  }
11847
11990
  } catch (error) {
11848
11991
  console.error(
11849
- `Error re-evaluating enableIf for field "${element.key ?? "<no key>"}" at path "${fullPath}":`,
11992
+ `Error re-evaluating enableIf for field "${element.key ?? "<no key>"}" at path "${fullDomPath}":`,
11850
11993
  error
11851
11994
  );
11852
11995
  }
@@ -11858,39 +12001,46 @@ var FormBuilderInstance = class {
11858
12001
  reevaluateConditionalFields() {
11859
12002
  if (!this.state.schema || !this.state.formRoot) return;
11860
12003
  const formData = this.validateForm(true).data;
11861
- const checkElements = (elements, currentPath) => {
12004
+ const checkElements = (elements, domPath, dataPath) => {
11862
12005
  elements.forEach((element) => {
11863
- const fullPath = currentPath ? `${currentPath}.${element.key ?? ""}` : element.key ?? "";
12006
+ const key = element.key ?? "";
12007
+ const fullDomPath = domPath ? `${domPath}.${key}` : key;
12008
+ const fullDataPath = dataPath ? `${dataPath}.${key}` : key;
11864
12009
  if (element.enableIf) {
11865
- const fieldWrapper = this.findFieldWrapper(element, currentPath);
12010
+ const fieldWrapper = this.findFieldWrapper(element, domPath);
11866
12011
  if (fieldWrapper) {
11867
12012
  this.applyEnableIfVisibility(
11868
12013
  element,
11869
12014
  fieldWrapper,
11870
- currentPath,
11871
- fullPath,
12015
+ domPath,
12016
+ dataPath,
12017
+ fullDomPath,
11872
12018
  formData
11873
12019
  );
11874
12020
  }
11875
12021
  }
11876
12022
  if ((element.type === "container" || element.type === "group") && "elements" in element && element.elements) {
11877
- const containerData = element.key ? getValueByPath(formData, fullPath) : void 0;
12023
+ const containerData = element.key ? getValueByPath(formData, fullDataPath) : void 0;
11878
12024
  if (Array.isArray(containerData)) {
11879
12025
  const directItems = findDirectContainerRows(
11880
12026
  this.state.formRoot,
11881
- fullPath
12027
+ fullDomPath
11882
12028
  );
11883
- directItems.forEach((el) => {
11884
- const attr = el.getAttribute("data-container-item") || "";
11885
- checkElements(element.elements, attr);
12029
+ directItems.forEach((el, rowIndex) => {
12030
+ const marker = el.getAttribute("data-container-item") || "";
12031
+ checkElements(
12032
+ element.elements,
12033
+ marker,
12034
+ `${fullDataPath}[${rowIndex}]`
12035
+ );
11886
12036
  });
11887
12037
  } else {
11888
- checkElements(element.elements, fullPath);
12038
+ checkElements(element.elements, fullDomPath, fullDataPath);
11889
12039
  }
11890
12040
  }
11891
12041
  });
11892
12042
  };
11893
- checkElements(this.state.schema.elements, "");
12043
+ checkElements(this.state.schema.elements, "", "");
11894
12044
  }
11895
12045
  /**
11896
12046
  * Destroy instance and clean up resources
@@ -11901,6 +12051,7 @@ var FormBuilderInstance = class {
11901
12051
  this.state.debounceTimer = null;
11902
12052
  }
11903
12053
  this.disconnectEnableIfObservers();
12054
+ this.removeTooltipElements();
11904
12055
  this.state.resourceIndex.clear();
11905
12056
  if (this.state.formRoot) {
11906
12057
  clear(this.state.formRoot);
@@ -11918,6 +12069,12 @@ var FormBuilderInstance = class {
11918
12069
  }
11919
12070
  this.state.enableIfObservers.clear();
11920
12071
  }
12072
+ removeTooltipElements() {
12073
+ for (const tooltip of this.state.tooltipElements) {
12074
+ tooltip.remove();
12075
+ }
12076
+ this.state.tooltipElements.clear();
12077
+ }
11921
12078
  };
11922
12079
 
11923
12080
  // src/index.ts