@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.
package/dist/esm/index.js CHANGED
@@ -15,6 +15,71 @@ 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
+ const div = document.createElement("div");
27
+ div.textContent = text;
28
+ return div.innerHTML;
29
+ }
30
+ function getElementLookupKey(element, state) {
31
+ if (element.key) {
32
+ return element.key;
33
+ }
34
+ const cached = state.syntheticElementIds.get(element);
35
+ if (cached !== void 0) {
36
+ return cached;
37
+ }
38
+ const id = `fb-synthetic-${state.syntheticElementIdCounter++}`;
39
+ state.syntheticElementIds.set(element, id);
40
+ return id;
41
+ }
42
+ function pathJoin(base, key) {
43
+ return base ? `${base}.${key}` : key;
44
+ }
45
+ function findDirectContainerRows(scopeRoot, containerPath) {
46
+ if (!containerPath) return [];
47
+ const all = scopeRoot.querySelectorAll("[data-container-item]");
48
+ return Array.from(all).filter((el) => {
49
+ const attr = el.getAttribute("data-container-item") || "";
50
+ if (!attr.startsWith(`${containerPath}[`)) return false;
51
+ return /^\[\d+\]$/.test(attr.slice(containerPath.length));
52
+ });
53
+ }
54
+ function clear(node) {
55
+ while (node.firstChild) node.removeChild(node.firstChild);
56
+ }
57
+ function formatFileSize(bytes) {
58
+ if (bytes < 1024) return `${bytes} B`;
59
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
60
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
61
+ }
62
+ function serializeHiddenValue(value) {
63
+ if (value === null || value === void 0) return "";
64
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
65
+ }
66
+ function deserializeHiddenValue(raw) {
67
+ if (raw === "") return null;
68
+ try {
69
+ return JSON.parse(raw);
70
+ } catch {
71
+ return raw;
72
+ }
73
+ }
74
+ function createHiddenInput(name, value) {
75
+ const input = document.createElement("input");
76
+ input.type = "hidden";
77
+ input.name = name;
78
+ input.setAttribute("data-hidden-field", "true");
79
+ input.value = serializeHiddenValue(value);
80
+ return input;
81
+ }
82
+
18
83
  // src/utils/validation.ts
19
84
  function addLengthHint(element, parts, state) {
20
85
  if (element.minLength != null || element.maxLength != null) {
@@ -184,6 +249,64 @@ function validateSchema(schema) {
184
249
  }
185
250
  }
186
251
  }
252
+ function validateCountBounds(element, elementPath, errors2) {
253
+ const el = element;
254
+ if (el.type === "group") {
255
+ if (!isPlainObject(el.repeat)) return;
256
+ checkBounds(
257
+ elementPath,
258
+ el.repeat?.min,
259
+ el.repeat?.max,
260
+ "repeat.min",
261
+ "repeat.max",
262
+ el.required === true,
263
+ errors2
264
+ );
265
+ return;
266
+ }
267
+ const isMultiple = el.multiple === true || el.type === "files";
268
+ if (!isMultiple) return;
269
+ checkBounds(
270
+ elementPath,
271
+ el.minCount,
272
+ el.maxCount,
273
+ "minCount",
274
+ "maxCount",
275
+ el.required === true,
276
+ errors2
277
+ );
278
+ }
279
+ function checkBounds(elementPath, minCount, maxCount, minName, maxName, requiredImpliesFloor, errors2) {
280
+ for (const [name, bound] of [
281
+ [minName, minCount],
282
+ [maxName, maxCount]
283
+ ]) {
284
+ if (bound !== void 0 && typeof bound !== "number") {
285
+ errors2.push(
286
+ `${elementPath}: ${name} must be a number (got ${typeof bound})`
287
+ );
288
+ }
289
+ }
290
+ const min = typeof minCount === "number" ? minCount : void 0;
291
+ const max = typeof maxCount === "number" ? maxCount : void 0;
292
+ if (max !== void 0 && (max < 0 || Number.isNaN(max))) {
293
+ errors2.push(
294
+ `${elementPath}: ${maxName} must be a non-negative number or Infinity (got ${max})`
295
+ );
296
+ }
297
+ if (min !== void 0 && (min < 0 || !Number.isFinite(min))) {
298
+ errors2.push(
299
+ `${elementPath}: ${minName} must be a finite non-negative number (got ${min})`
300
+ );
301
+ }
302
+ const effectiveMin = min ?? (requiredImpliesFloor ? 1 : void 0);
303
+ if (effectiveMin !== void 0 && max !== void 0 && effectiveMin > max) {
304
+ const shown = min !== void 0 ? `${minName} (${min})` : `required: true (implies ${minName} 1)`;
305
+ errors2.push(
306
+ `${elementPath}: ${shown} cannot be greater than ${maxName} (${max})`
307
+ );
308
+ }
309
+ }
187
310
  function validateElements(elements, path) {
188
311
  elements.forEach((element, index) => {
189
312
  const elementPath = `${path}[${index}]`;
@@ -193,6 +316,7 @@ function validateSchema(schema) {
193
316
  if (!element.key && element.type !== "markdown") {
194
317
  errors.push(`${elementPath}: missing key`);
195
318
  }
319
+ validateCountBounds(element, elementPath, errors);
196
320
  if (element.type === "markdown") {
197
321
  const content = element.content;
198
322
  if (typeof content !== "string") {
@@ -273,71 +397,6 @@ function validateSchema(schema) {
273
397
  return errors;
274
398
  }
275
399
 
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
400
  // src/utils/enable-conditions.ts
342
401
  function getValueByPath(data, path) {
343
402
  if (!data || typeof data !== "object") {
@@ -6772,11 +6831,14 @@ function getChildWrapperClass(columns) {
6772
6831
  const cols = columns || 1;
6773
6832
  return cols === 1 ? "fb-row" : `grid grid-cols-${cols} gap-2`;
6774
6833
  }
6775
- function mountRemoveButton(item, onRemove, state) {
6834
+ function mountRemoveButton(item, onRemove, state, containerLabel) {
6776
6835
  const rem = document.createElement("button");
6777
6836
  rem.type = "button";
6778
6837
  rem.className = "fb-item-remove";
6779
- rem.setAttribute("aria-label", t("removeElement", state));
6838
+ rem.setAttribute(
6839
+ "aria-label",
6840
+ containerLabel ? t("removeRowFrom", state, { label: containerLabel }) : t("removeRow", state)
6841
+ );
6780
6842
  rem.style.cssText = `
6781
6843
  width: 24px;
6782
6844
  height: 24px;
@@ -6864,7 +6926,12 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6864
6926
  });
6865
6927
  item.appendChild(childWrapper);
6866
6928
  if (!containerIsReadonly) {
6867
- mountRemoveButton(item, () => handleRemoveItem(item), state);
6929
+ mountRemoveButton(
6930
+ item,
6931
+ () => handleRemoveItem(item),
6932
+ state,
6933
+ element.label
6934
+ );
6868
6935
  }
6869
6936
  return item;
6870
6937
  };
@@ -7027,7 +7094,7 @@ function validateContainerElement(element, key, context) {
7027
7094
  );
7028
7095
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
7029
7096
  Object.assign(itemData, childResult.value);
7030
- } else {
7097
+ } else if (!childResult.skip && child.key) {
7031
7098
  itemData[child.key] = childResult.value;
7032
7099
  }
7033
7100
  });
@@ -7068,7 +7135,7 @@ function validateContainerElement(element, key, context) {
7068
7135
  );
7069
7136
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
7070
7137
  Object.assign(containerData, childResult.value);
7071
- } else {
7138
+ } else if (!childResult.skip && child.key) {
7072
7139
  containerData[child.key] = childResult.value;
7073
7140
  }
7074
7141
  }
@@ -7088,11 +7155,13 @@ function updateContainerField(element, fieldPath, value, context) {
7088
7155
  );
7089
7156
  return;
7090
7157
  }
7158
+ const rows = findDirectContainerRows(scopeRoot, fieldPath);
7091
7159
  value.forEach((itemValue, index) => {
7092
- if (isPlainObject(itemValue)) {
7160
+ const rowPath = rows[index]?.getAttribute("data-container-item");
7161
+ if (isPlainObject(itemValue) && rowPath) {
7093
7162
  element.elements.forEach((childElement) => {
7094
7163
  if (childElement.type === "markdown" || !childElement.key) return;
7095
- const childPath = `${fieldPath}[${index}].${childElement.key}`;
7164
+ const childPath = `${rowPath}.${childElement.key}`;
7096
7165
  if (childElement.type === "richinput" && childElement.flatOutput) {
7097
7166
  const richChild = childElement;
7098
7167
  const textKey = richChild.textKey ?? "text";
@@ -7115,10 +7184,9 @@ function updateContainerField(element, fieldPath, value, context) {
7115
7184
  });
7116
7185
  }
7117
7186
  });
7118
- const existingContainers = findDirectContainerRows(scopeRoot, fieldPath);
7119
- if (value.length !== existingContainers.length) {
7187
+ if (value.length !== rows.length) {
7120
7188
  console.warn(
7121
- `updateContainerField: Multiple container field "${fieldPath}" item count mismatch. Consider re-rendering for add/remove.`
7189
+ `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
7190
  );
7123
7191
  }
7124
7192
  } else {
@@ -10610,6 +10678,8 @@ var defaultConfig = {
10610
10678
  en: {
10611
10679
  // UI texts
10612
10680
  removeElement: "Remove",
10681
+ removeRow: "Remove row",
10682
+ removeRowFrom: "Remove row from {label}",
10613
10683
  clickDragText: "Click or drag file",
10614
10684
  clickDragTextMultiple: "Click or drag files",
10615
10685
  noFileSelected: "No file selected",
@@ -10683,6 +10753,8 @@ var defaultConfig = {
10683
10753
  ru: {
10684
10754
  // UI texts
10685
10755
  removeElement: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
10756
+ removeRow: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443",
10757
+ removeRowFrom: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u0443 \u0438\u0437 \xAB{label}\xBB",
10686
10758
  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
10759
  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
10760
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
@@ -11072,6 +11144,18 @@ var exampleThemes = {
11072
11144
  };
11073
11145
 
11074
11146
  // src/instance/FormBuilderInstance.ts
11147
+ function findOwnField(scope, lookupKey, ownBoundary) {
11148
+ const matches = scope.querySelectorAll(
11149
+ `[data-field-key="${lookupKey}"]`
11150
+ );
11151
+ for (const el of Array.from(matches)) {
11152
+ const boundary = el.closest(
11153
+ "[data-container-item], [data-container]"
11154
+ );
11155
+ if (boundary === ownBoundary) return el;
11156
+ }
11157
+ return null;
11158
+ }
11075
11159
  var FormBuilderInstance = class {
11076
11160
  constructor(config) {
11077
11161
  this.instanceId = generateInstanceId();
@@ -11794,28 +11878,28 @@ var FormBuilderInstance = class {
11794
11878
  const formRoot = this.state.formRoot;
11795
11879
  const lookupKey = getElementLookupKey(element, this.state);
11796
11880
  if (!currentPath) {
11797
- return formRoot.querySelector(`[data-field-key="${lookupKey}"]`);
11881
+ return findOwnField(formRoot, lookupKey, null);
11798
11882
  }
11799
11883
  const pathMatch = currentPath.match(/^(.+)\[(\d+)\]$/);
11800
11884
  if (pathMatch) {
11801
11885
  const containerEl2 = formRoot.querySelector(
11802
11886
  `[data-container-item="${pathMatch[1]}[${pathMatch[2]}]"]`
11803
11887
  );
11804
- return containerEl2 ? containerEl2.querySelector(`[data-field-key="${lookupKey}"]`) : null;
11888
+ return containerEl2 ? findOwnField(containerEl2, lookupKey, containerEl2) : null;
11805
11889
  }
11806
11890
  const containerEl = formRoot.querySelector(
11807
11891
  `[data-container="${currentPath}"]`
11808
11892
  );
11809
- return containerEl ? containerEl.querySelector(`[data-field-key="${lookupKey}"]`) : null;
11893
+ return containerEl ? findOwnField(containerEl, lookupKey, containerEl) : null;
11810
11894
  }
11811
11895
  /**
11812
11896
  * Apply enableIf show/hide logic to a single field wrapper.
11813
11897
  * Extracted to reduce cyclomatic complexity of checkElements.
11814
11898
  */
11815
- applyEnableIfVisibility(element, wrapper, currentPath, fullPath, formData) {
11899
+ applyEnableIfVisibility(element, wrapper, domPath, dataPath, fullDomPath, formData) {
11816
11900
  try {
11817
11901
  const scope = element.enableIf.scope ?? "relative";
11818
- const containerData = scope === "relative" && currentPath ? getValueByPath(formData, currentPath) : void 0;
11902
+ const containerData = scope === "relative" && dataPath ? getValueByPath(formData, dataPath) : void 0;
11819
11903
  const shouldEnable = evaluateEnableCondition(
11820
11904
  element.enableIf,
11821
11905
  formData,
@@ -11823,10 +11907,12 @@ var FormBuilderInstance = class {
11823
11907
  );
11824
11908
  const isCurrentlyDisabled = wrapper.getAttribute("data-conditionally-disabled") === "true";
11825
11909
  if (shouldEnable && isCurrentlyDisabled) {
11826
- const containerPrefill = currentPath ? getValueByPath(formData, currentPath) : formData;
11910
+ const containerPrefill = dataPath ? getValueByPath(formData, dataPath) : formData;
11827
11911
  const prefillContext = containerPrefill && typeof containerPrefill === "object" ? containerPrefill : {};
11828
11912
  const newWrapper = renderElement2(element, {
11829
- path: currentPath,
11913
+ // DOM path: the re-rendered control's `name` must match the row it
11914
+ // lives in, not the row's position in the extracted array.
11915
+ path: domPath,
11830
11916
  prefill: prefillContext,
11831
11917
  formData,
11832
11918
  state: this.state,
@@ -11846,7 +11932,7 @@ var FormBuilderInstance = class {
11846
11932
  }
11847
11933
  } catch (error) {
11848
11934
  console.error(
11849
- `Error re-evaluating enableIf for field "${element.key ?? "<no key>"}" at path "${fullPath}":`,
11935
+ `Error re-evaluating enableIf for field "${element.key ?? "<no key>"}" at path "${fullDomPath}":`,
11850
11936
  error
11851
11937
  );
11852
11938
  }
@@ -11858,39 +11944,46 @@ var FormBuilderInstance = class {
11858
11944
  reevaluateConditionalFields() {
11859
11945
  if (!this.state.schema || !this.state.formRoot) return;
11860
11946
  const formData = this.validateForm(true).data;
11861
- const checkElements = (elements, currentPath) => {
11947
+ const checkElements = (elements, domPath, dataPath) => {
11862
11948
  elements.forEach((element) => {
11863
- const fullPath = currentPath ? `${currentPath}.${element.key ?? ""}` : element.key ?? "";
11949
+ const key = element.key ?? "";
11950
+ const fullDomPath = domPath ? `${domPath}.${key}` : key;
11951
+ const fullDataPath = dataPath ? `${dataPath}.${key}` : key;
11864
11952
  if (element.enableIf) {
11865
- const fieldWrapper = this.findFieldWrapper(element, currentPath);
11953
+ const fieldWrapper = this.findFieldWrapper(element, domPath);
11866
11954
  if (fieldWrapper) {
11867
11955
  this.applyEnableIfVisibility(
11868
11956
  element,
11869
11957
  fieldWrapper,
11870
- currentPath,
11871
- fullPath,
11958
+ domPath,
11959
+ dataPath,
11960
+ fullDomPath,
11872
11961
  formData
11873
11962
  );
11874
11963
  }
11875
11964
  }
11876
11965
  if ((element.type === "container" || element.type === "group") && "elements" in element && element.elements) {
11877
- const containerData = element.key ? getValueByPath(formData, fullPath) : void 0;
11966
+ const containerData = element.key ? getValueByPath(formData, fullDataPath) : void 0;
11878
11967
  if (Array.isArray(containerData)) {
11879
11968
  const directItems = findDirectContainerRows(
11880
11969
  this.state.formRoot,
11881
- fullPath
11970
+ fullDomPath
11882
11971
  );
11883
- directItems.forEach((el) => {
11884
- const attr = el.getAttribute("data-container-item") || "";
11885
- checkElements(element.elements, attr);
11972
+ directItems.forEach((el, rowIndex) => {
11973
+ const marker = el.getAttribute("data-container-item") || "";
11974
+ checkElements(
11975
+ element.elements,
11976
+ marker,
11977
+ `${fullDataPath}[${rowIndex}]`
11978
+ );
11886
11979
  });
11887
11980
  } else {
11888
- checkElements(element.elements, fullPath);
11981
+ checkElements(element.elements, fullDomPath, fullDataPath);
11889
11982
  }
11890
11983
  }
11891
11984
  });
11892
11985
  };
11893
- checkElements(this.state.schema.elements, "");
11986
+ checkElements(this.state.schema.elements, "", "");
11894
11987
  }
11895
11988
  /**
11896
11989
  * Destroy instance and clean up resources