@dmitryvim/form-builder 0.5.2 → 0.5.3

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.
@@ -19,6 +19,71 @@ function t(key, state, params) {
19
19
  return text;
20
20
  }
21
21
 
22
+ // src/utils/helpers.ts
23
+ function isElementReadonly(element, state, ctx) {
24
+ return element.readonly === true || state.config.readonly === true || (ctx == null ? void 0 : ctx.inheritedReadonly) === true;
25
+ }
26
+ function isPlainObject(obj) {
27
+ return obj && typeof obj === "object" && obj.constructor === Object;
28
+ }
29
+ function escapeHtml(text) {
30
+ const div = document.createElement("div");
31
+ div.textContent = text;
32
+ return div.innerHTML;
33
+ }
34
+ function getElementLookupKey(element, state) {
35
+ if (element.key) {
36
+ return element.key;
37
+ }
38
+ const cached = state.syntheticElementIds.get(element);
39
+ if (cached !== void 0) {
40
+ return cached;
41
+ }
42
+ const id = `fb-synthetic-${state.syntheticElementIdCounter++}`;
43
+ state.syntheticElementIds.set(element, id);
44
+ return id;
45
+ }
46
+ function pathJoin(base, key) {
47
+ return base ? `${base}.${key}` : key;
48
+ }
49
+ function findDirectContainerRows(scopeRoot, containerPath) {
50
+ if (!containerPath) return [];
51
+ const all = scopeRoot.querySelectorAll("[data-container-item]");
52
+ return Array.from(all).filter((el) => {
53
+ const attr = el.getAttribute("data-container-item") || "";
54
+ if (!attr.startsWith(`${containerPath}[`)) return false;
55
+ return /^\[\d+\]$/.test(attr.slice(containerPath.length));
56
+ });
57
+ }
58
+ function clear(node) {
59
+ while (node.firstChild) node.removeChild(node.firstChild);
60
+ }
61
+ function formatFileSize(bytes) {
62
+ if (bytes < 1024) return `${bytes} B`;
63
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
64
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
65
+ }
66
+ function serializeHiddenValue(value) {
67
+ if (value === null || value === void 0) return "";
68
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
69
+ }
70
+ function deserializeHiddenValue(raw) {
71
+ if (raw === "") return null;
72
+ try {
73
+ return JSON.parse(raw);
74
+ } catch {
75
+ return raw;
76
+ }
77
+ }
78
+ function createHiddenInput(name, value) {
79
+ const input = document.createElement("input");
80
+ input.type = "hidden";
81
+ input.name = name;
82
+ input.setAttribute("data-hidden-field", "true");
83
+ input.value = serializeHiddenValue(value);
84
+ return input;
85
+ }
86
+
22
87
  // src/utils/validation.ts
23
88
  function addLengthHint(element, parts, state) {
24
89
  if (element.minLength != null || element.maxLength != null) {
@@ -191,6 +256,65 @@ function validateSchema(schema) {
191
256
  }
192
257
  }
193
258
  }
259
+ function validateCountBounds(element, elementPath, errors2) {
260
+ var _a, _b;
261
+ const el = element;
262
+ if (el.type === "group") {
263
+ if (!isPlainObject(el.repeat)) return;
264
+ checkBounds(
265
+ elementPath,
266
+ (_a = el.repeat) == null ? void 0 : _a.min,
267
+ (_b = el.repeat) == null ? void 0 : _b.max,
268
+ "repeat.min",
269
+ "repeat.max",
270
+ el.required === true,
271
+ errors2
272
+ );
273
+ return;
274
+ }
275
+ const isMultiple = el.multiple === true || el.type === "files";
276
+ if (!isMultiple) return;
277
+ checkBounds(
278
+ elementPath,
279
+ el.minCount,
280
+ el.maxCount,
281
+ "minCount",
282
+ "maxCount",
283
+ el.required === true,
284
+ errors2
285
+ );
286
+ }
287
+ function checkBounds(elementPath, minCount, maxCount, minName, maxName, requiredImpliesFloor, errors2) {
288
+ for (const [name, bound] of [
289
+ [minName, minCount],
290
+ [maxName, maxCount]
291
+ ]) {
292
+ if (bound !== void 0 && typeof bound !== "number") {
293
+ errors2.push(
294
+ `${elementPath}: ${name} must be a number (got ${typeof bound})`
295
+ );
296
+ }
297
+ }
298
+ const min = typeof minCount === "number" ? minCount : void 0;
299
+ const max = typeof maxCount === "number" ? maxCount : void 0;
300
+ if (max !== void 0 && (max < 0 || Number.isNaN(max))) {
301
+ errors2.push(
302
+ `${elementPath}: ${maxName} must be a non-negative number or Infinity (got ${max})`
303
+ );
304
+ }
305
+ if (min !== void 0 && (min < 0 || !Number.isFinite(min))) {
306
+ errors2.push(
307
+ `${elementPath}: ${minName} must be a finite non-negative number (got ${min})`
308
+ );
309
+ }
310
+ const effectiveMin = min != null ? min : requiredImpliesFloor ? 1 : void 0;
311
+ if (effectiveMin !== void 0 && max !== void 0 && effectiveMin > max) {
312
+ const shown = min !== void 0 ? `${minName} (${min})` : `required: true (implies ${minName} 1)`;
313
+ errors2.push(
314
+ `${elementPath}: ${shown} cannot be greater than ${maxName} (${max})`
315
+ );
316
+ }
317
+ }
194
318
  function validateElements(elements, path) {
195
319
  elements.forEach((element, index) => {
196
320
  const elementPath = `${path}[${index}]`;
@@ -200,6 +324,7 @@ function validateSchema(schema) {
200
324
  if (!element.key && element.type !== "markdown") {
201
325
  errors.push(`${elementPath}: missing key`);
202
326
  }
327
+ validateCountBounds(element, elementPath, errors);
203
328
  if (element.type === "markdown") {
204
329
  const content = element.content;
205
330
  if (typeof content !== "string") {
@@ -280,71 +405,6 @@ function validateSchema(schema) {
280
405
  return errors;
281
406
  }
282
407
 
283
- // src/utils/helpers.ts
284
- function isElementReadonly(element, state, ctx) {
285
- return element.readonly === true || state.config.readonly === true || (ctx == null ? void 0 : ctx.inheritedReadonly) === true;
286
- }
287
- function isPlainObject(obj) {
288
- return obj && typeof obj === "object" && obj.constructor === Object;
289
- }
290
- function escapeHtml(text) {
291
- const div = document.createElement("div");
292
- div.textContent = text;
293
- return div.innerHTML;
294
- }
295
- function getElementLookupKey(element, state) {
296
- if (element.key) {
297
- return element.key;
298
- }
299
- const cached = state.syntheticElementIds.get(element);
300
- if (cached !== void 0) {
301
- return cached;
302
- }
303
- const id = `fb-synthetic-${state.syntheticElementIdCounter++}`;
304
- state.syntheticElementIds.set(element, id);
305
- return id;
306
- }
307
- function pathJoin(base, key) {
308
- return base ? `${base}.${key}` : key;
309
- }
310
- function findDirectContainerRows(scopeRoot, containerPath) {
311
- if (!containerPath) return [];
312
- const all = scopeRoot.querySelectorAll("[data-container-item]");
313
- return Array.from(all).filter((el) => {
314
- const attr = el.getAttribute("data-container-item") || "";
315
- if (!attr.startsWith(`${containerPath}[`)) return false;
316
- return /^\[\d+\]$/.test(attr.slice(containerPath.length));
317
- });
318
- }
319
- function clear(node) {
320
- while (node.firstChild) node.removeChild(node.firstChild);
321
- }
322
- function formatFileSize(bytes) {
323
- if (bytes < 1024) return `${bytes} B`;
324
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
325
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
326
- }
327
- function serializeHiddenValue(value) {
328
- if (value === null || value === void 0) return "";
329
- return typeof value === "object" ? JSON.stringify(value) : String(value);
330
- }
331
- function deserializeHiddenValue(raw) {
332
- if (raw === "") return null;
333
- try {
334
- return JSON.parse(raw);
335
- } catch {
336
- return raw;
337
- }
338
- }
339
- function createHiddenInput(name, value) {
340
- const input = document.createElement("input");
341
- input.type = "hidden";
342
- input.name = name;
343
- input.setAttribute("data-hidden-field", "true");
344
- input.value = serializeHiddenValue(value);
345
- return input;
346
- }
347
-
348
408
  // src/utils/enable-conditions.ts
349
409
  function getValueByPath(data, path) {
350
410
  if (!data || typeof data !== "object") {
@@ -6878,11 +6938,14 @@ function getChildWrapperClass(columns) {
6878
6938
  const cols = columns || 1;
6879
6939
  return cols === 1 ? "fb-row" : `grid grid-cols-${cols} gap-2`;
6880
6940
  }
6881
- function mountRemoveButton(item, onRemove, state) {
6941
+ function mountRemoveButton(item, onRemove, state, containerLabel) {
6882
6942
  const rem = document.createElement("button");
6883
6943
  rem.type = "button";
6884
6944
  rem.className = "fb-item-remove";
6885
- rem.setAttribute("aria-label", t("removeElement", state));
6945
+ rem.setAttribute(
6946
+ "aria-label",
6947
+ containerLabel ? t("removeRowFrom", state, { label: containerLabel }) : t("removeRow", state)
6948
+ );
6886
6949
  rem.style.cssText = `
6887
6950
  width: 24px;
6888
6951
  height: 24px;
@@ -6972,7 +7035,12 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6972
7035
  });
6973
7036
  item.appendChild(childWrapper);
6974
7037
  if (!containerIsReadonly) {
6975
- mountRemoveButton(item, () => handleRemoveItem(item), state);
7038
+ mountRemoveButton(
7039
+ item,
7040
+ () => handleRemoveItem(item),
7041
+ state,
7042
+ element.label
7043
+ );
6976
7044
  }
6977
7045
  return item;
6978
7046
  };
@@ -7138,7 +7206,7 @@ function validateContainerElement(element, key, context) {
7138
7206
  );
7139
7207
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
7140
7208
  Object.assign(itemData, childResult.value);
7141
- } else {
7209
+ } else if (!childResult.skip && child.key) {
7142
7210
  itemData[child.key] = childResult.value;
7143
7211
  }
7144
7212
  });
@@ -7180,7 +7248,7 @@ function validateContainerElement(element, key, context) {
7180
7248
  );
7181
7249
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
7182
7250
  Object.assign(containerData, childResult.value);
7183
- } else {
7251
+ } else if (!childResult.skip && child.key) {
7184
7252
  containerData[child.key] = childResult.value;
7185
7253
  }
7186
7254
  }
@@ -7200,15 +7268,18 @@ function updateContainerField(element, fieldPath, value, context) {
7200
7268
  );
7201
7269
  return;
7202
7270
  }
7271
+ const rows = findDirectContainerRows(scopeRoot, fieldPath);
7203
7272
  value.forEach((itemValue, index) => {
7204
- if (isPlainObject(itemValue)) {
7273
+ var _a;
7274
+ const rowPath = (_a = rows[index]) == null ? void 0 : _a.getAttribute("data-container-item");
7275
+ if (isPlainObject(itemValue) && rowPath) {
7205
7276
  element.elements.forEach((childElement) => {
7206
- var _a, _b;
7277
+ var _a2, _b;
7207
7278
  if (childElement.type === "markdown" || !childElement.key) return;
7208
- const childPath = `${fieldPath}[${index}].${childElement.key}`;
7279
+ const childPath = `${rowPath}.${childElement.key}`;
7209
7280
  if (childElement.type === "richinput" && childElement.flatOutput) {
7210
7281
  const richChild = childElement;
7211
- const textKey = (_a = richChild.textKey) != null ? _a : "text";
7282
+ const textKey = (_a2 = richChild.textKey) != null ? _a2 : "text";
7212
7283
  const filesKey = (_b = richChild.filesKey) != null ? _b : "files";
7213
7284
  const containerValue = itemValue;
7214
7285
  const compositeValue = {};
@@ -7228,10 +7299,9 @@ function updateContainerField(element, fieldPath, value, context) {
7228
7299
  });
7229
7300
  }
7230
7301
  });
7231
- const existingContainers = findDirectContainerRows(scopeRoot, fieldPath);
7232
- if (value.length !== existingContainers.length) {
7302
+ if (value.length !== rows.length) {
7233
7303
  console.warn(
7234
- `updateContainerField: Multiple container field "${fieldPath}" item count mismatch. Consider re-rendering for add/remove.`
7304
+ `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.`
7235
7305
  );
7236
7306
  }
7237
7307
  } else {
@@ -10800,6 +10870,8 @@ var defaultConfig = {
10800
10870
  en: {
10801
10871
  // UI texts
10802
10872
  removeElement: "Remove",
10873
+ removeRow: "Remove row",
10874
+ removeRowFrom: "Remove row from {label}",
10803
10875
  clickDragText: "Click or drag file",
10804
10876
  clickDragTextMultiple: "Click or drag files",
10805
10877
  noFileSelected: "No file selected",
@@ -10873,6 +10945,8 @@ var defaultConfig = {
10873
10945
  ru: {
10874
10946
  // UI texts
10875
10947
  removeElement: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
10948
+ removeRow: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443",
10949
+ removeRowFrom: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443 \u0438\u0437 \xAB{label}\xBB",
10876
10950
  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",
10877
10951
  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",
10878
10952
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
@@ -11262,6 +11336,18 @@ var exampleThemes = {
11262
11336
  };
11263
11337
 
11264
11338
  // src/instance/FormBuilderInstance.ts
11339
+ function findOwnField(scope, lookupKey, ownBoundary) {
11340
+ const matches = scope.querySelectorAll(
11341
+ `[data-field-key="${lookupKey}"]`
11342
+ );
11343
+ for (const el of Array.from(matches)) {
11344
+ const boundary = el.closest(
11345
+ "[data-container-item], [data-container]"
11346
+ );
11347
+ if (boundary === ownBoundary) return el;
11348
+ }
11349
+ return null;
11350
+ }
11265
11351
  var FormBuilderInstance = class {
11266
11352
  constructor(config) {
11267
11353
  this.instanceId = generateInstanceId();
@@ -11988,29 +12074,29 @@ var FormBuilderInstance = class {
11988
12074
  const formRoot = this.state.formRoot;
11989
12075
  const lookupKey = getElementLookupKey(element, this.state);
11990
12076
  if (!currentPath) {
11991
- return formRoot.querySelector(`[data-field-key="${lookupKey}"]`);
12077
+ return findOwnField(formRoot, lookupKey, null);
11992
12078
  }
11993
12079
  const pathMatch = currentPath.match(/^(.+)\[(\d+)\]$/);
11994
12080
  if (pathMatch) {
11995
12081
  const containerEl2 = formRoot.querySelector(
11996
12082
  `[data-container-item="${pathMatch[1]}[${pathMatch[2]}]"]`
11997
12083
  );
11998
- return containerEl2 ? containerEl2.querySelector(`[data-field-key="${lookupKey}"]`) : null;
12084
+ return containerEl2 ? findOwnField(containerEl2, lookupKey, containerEl2) : null;
11999
12085
  }
12000
12086
  const containerEl = formRoot.querySelector(
12001
12087
  `[data-container="${currentPath}"]`
12002
12088
  );
12003
- return containerEl ? containerEl.querySelector(`[data-field-key="${lookupKey}"]`) : null;
12089
+ return containerEl ? findOwnField(containerEl, lookupKey, containerEl) : null;
12004
12090
  }
12005
12091
  /**
12006
12092
  * Apply enableIf show/hide logic to a single field wrapper.
12007
12093
  * Extracted to reduce cyclomatic complexity of checkElements.
12008
12094
  */
12009
- applyEnableIfVisibility(element, wrapper, currentPath, fullPath, formData) {
12095
+ applyEnableIfVisibility(element, wrapper, domPath, dataPath, fullDomPath, formData) {
12010
12096
  var _a, _b, _c, _d;
12011
12097
  try {
12012
12098
  const scope = (_a = element.enableIf.scope) != null ? _a : "relative";
12013
- const containerData = scope === "relative" && currentPath ? getValueByPath(formData, currentPath) : void 0;
12099
+ const containerData = scope === "relative" && dataPath ? getValueByPath(formData, dataPath) : void 0;
12014
12100
  const shouldEnable = evaluateEnableCondition(
12015
12101
  element.enableIf,
12016
12102
  formData,
@@ -12018,10 +12104,12 @@ var FormBuilderInstance = class {
12018
12104
  );
12019
12105
  const isCurrentlyDisabled = wrapper.getAttribute("data-conditionally-disabled") === "true";
12020
12106
  if (shouldEnable && isCurrentlyDisabled) {
12021
- const containerPrefill = currentPath ? getValueByPath(formData, currentPath) : formData;
12107
+ const containerPrefill = dataPath ? getValueByPath(formData, dataPath) : formData;
12022
12108
  const prefillContext = containerPrefill && typeof containerPrefill === "object" ? containerPrefill : {};
12023
12109
  const newWrapper = renderElement2(element, {
12024
- path: currentPath,
12110
+ // DOM path: the re-rendered control's `name` must match the row it
12111
+ // lives in, not the row's position in the extracted array.
12112
+ path: domPath,
12025
12113
  prefill: prefillContext,
12026
12114
  formData,
12027
12115
  state: this.state,
@@ -12041,7 +12129,7 @@ var FormBuilderInstance = class {
12041
12129
  }
12042
12130
  } catch (error) {
12043
12131
  console.error(
12044
- `Error re-evaluating enableIf for field "${(_d = element.key) != null ? _d : "<no key>"}" at path "${fullPath}":`,
12132
+ `Error re-evaluating enableIf for field "${(_d = element.key) != null ? _d : "<no key>"}" at path "${fullDomPath}":`,
12045
12133
  error
12046
12134
  );
12047
12135
  }
@@ -12053,40 +12141,47 @@ var FormBuilderInstance = class {
12053
12141
  reevaluateConditionalFields() {
12054
12142
  if (!this.state.schema || !this.state.formRoot) return;
12055
12143
  const formData = this.validateForm(true).data;
12056
- const checkElements = (elements, currentPath) => {
12144
+ const checkElements = (elements, domPath, dataPath) => {
12057
12145
  elements.forEach((element) => {
12058
- var _a, _b;
12059
- const fullPath = currentPath ? `${currentPath}.${(_a = element.key) != null ? _a : ""}` : (_b = element.key) != null ? _b : "";
12146
+ var _a;
12147
+ const key = (_a = element.key) != null ? _a : "";
12148
+ const fullDomPath = domPath ? `${domPath}.${key}` : key;
12149
+ const fullDataPath = dataPath ? `${dataPath}.${key}` : key;
12060
12150
  if (element.enableIf) {
12061
- const fieldWrapper = this.findFieldWrapper(element, currentPath);
12151
+ const fieldWrapper = this.findFieldWrapper(element, domPath);
12062
12152
  if (fieldWrapper) {
12063
12153
  this.applyEnableIfVisibility(
12064
12154
  element,
12065
12155
  fieldWrapper,
12066
- currentPath,
12067
- fullPath,
12156
+ domPath,
12157
+ dataPath,
12158
+ fullDomPath,
12068
12159
  formData
12069
12160
  );
12070
12161
  }
12071
12162
  }
12072
12163
  if ((element.type === "container" || element.type === "group") && "elements" in element && element.elements) {
12073
- const containerData = element.key ? getValueByPath(formData, fullPath) : void 0;
12164
+ const containerData = element.key ? getValueByPath(formData, fullDataPath) : void 0;
12074
12165
  if (Array.isArray(containerData)) {
12075
12166
  const directItems = findDirectContainerRows(
12076
12167
  this.state.formRoot,
12077
- fullPath
12168
+ fullDomPath
12078
12169
  );
12079
- directItems.forEach((el) => {
12080
- const attr = el.getAttribute("data-container-item") || "";
12081
- checkElements(element.elements, attr);
12170
+ directItems.forEach((el, rowIndex) => {
12171
+ const marker = el.getAttribute("data-container-item") || "";
12172
+ checkElements(
12173
+ element.elements,
12174
+ marker,
12175
+ `${fullDataPath}[${rowIndex}]`
12176
+ );
12082
12177
  });
12083
12178
  } else {
12084
- checkElements(element.elements, fullPath);
12179
+ checkElements(element.elements, fullDomPath, fullDataPath);
12085
12180
  }
12086
12181
  }
12087
12182
  });
12088
12183
  };
12089
- checkElements(this.state.schema.elements, "");
12184
+ checkElements(this.state.schema.elements, "", "");
12090
12185
  }
12091
12186
  /**
12092
12187
  * Destroy instance and clean up resources