@dmitryvim/form-builder 0.1.29 → 0.1.33

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.
@@ -11,6 +11,8 @@ const state = {
11
11
  downloadFile: null,
12
12
  getThumbnail: null,
13
13
  getDownloadUrl: null,
14
+ // Action handler
15
+ actionHandler: null,
14
16
  // Default implementations
15
17
  enableFilePreview: true,
16
18
  maxPreviewSize: "200px",
@@ -146,6 +148,10 @@ function renderForm(schema, prefill) {
146
148
  formEl.className = "space-y-6";
147
149
 
148
150
  schema.elements.forEach((element, _index) => {
151
+ // Skip rendering hidden elements
152
+ if (element.hidden) {
153
+ return;
154
+ }
149
155
  const block = renderElement(element, {
150
156
  path: "",
151
157
  prefill: prefill || {},
@@ -207,23 +213,44 @@ function renderElement(element, ctx) {
207
213
 
208
214
  switch (element.type) {
209
215
  case "text":
210
- renderTextElement(element, ctx, wrapper, pathKey);
216
+ if (element.multiple) {
217
+ renderMultipleTextElement(element, ctx, wrapper, pathKey);
218
+ } else {
219
+ renderTextElement(element, ctx, wrapper, pathKey);
220
+ }
211
221
  break;
212
222
 
213
223
  case "textarea":
214
- renderTextareaElement(element, ctx, wrapper, pathKey);
224
+ if (element.multiple) {
225
+ renderMultipleTextareaElement(element, ctx, wrapper, pathKey);
226
+ } else {
227
+ renderTextareaElement(element, ctx, wrapper, pathKey);
228
+ }
215
229
  break;
216
230
 
217
231
  case "number":
218
- renderNumberElement(element, ctx, wrapper, pathKey);
232
+ if (element.multiple) {
233
+ renderMultipleNumberElement(element, ctx, wrapper, pathKey);
234
+ } else {
235
+ renderNumberElement(element, ctx, wrapper, pathKey);
236
+ }
219
237
  break;
220
238
 
221
239
  case "select":
222
- renderSelectElement(element, ctx, wrapper, pathKey);
240
+ if (element.multiple) {
241
+ renderMultipleSelectElement(element, ctx, wrapper, pathKey);
242
+ } else {
243
+ renderSelectElement(element, ctx, wrapper, pathKey);
244
+ }
223
245
  break;
224
246
 
225
247
  case "file":
226
- renderFileElement(element, ctx, wrapper, pathKey);
248
+ // Handle multiple files with file type using multiple property
249
+ if (element.multiple) {
250
+ renderMultipleFileElement(element, ctx, wrapper, pathKey);
251
+ } else {
252
+ renderFileElement(element, ctx, wrapper, pathKey);
253
+ }
227
254
  break;
228
255
 
229
256
  case "files":
@@ -234,6 +261,15 @@ function renderElement(element, ctx) {
234
261
  renderGroupElement(element, ctx, wrapper, pathKey);
235
262
  break;
236
263
 
264
+ case "container":
265
+ // Handle containers with multiple property like groups
266
+ if (element.multiple) {
267
+ renderMultipleContainerElement(element, ctx, wrapper, pathKey);
268
+ } else {
269
+ renderSingleContainerElement(element, ctx, wrapper, pathKey);
270
+ }
271
+ break;
272
+
237
273
  default: {
238
274
  const unsupported = document.createElement("div");
239
275
  unsupported.className = "text-red-500 text-sm";
@@ -242,6 +278,43 @@ function renderElement(element, ctx) {
242
278
  }
243
279
  }
244
280
 
281
+ // Add action buttons in readonly mode
282
+ if (
283
+ state.config.readonly &&
284
+ element.actions &&
285
+ Array.isArray(element.actions) &&
286
+ element.actions.length > 0
287
+ ) {
288
+ const actionsContainer = document.createElement("div");
289
+ actionsContainer.className = "mt-3 flex flex-wrap gap-2";
290
+
291
+ element.actions.forEach((action) => {
292
+ if (action.value && action.label) {
293
+ const actionBtn = document.createElement("button");
294
+ actionBtn.type = "button";
295
+ actionBtn.className =
296
+ "px-3 py-1.5 text-sm bg-blue-50 border border-blue-200 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors";
297
+ actionBtn.textContent = action.label;
298
+
299
+ actionBtn.addEventListener("click", (e) => {
300
+ e.preventDefault();
301
+ e.stopPropagation();
302
+
303
+ if (
304
+ state.config.actionHandler &&
305
+ typeof state.config.actionHandler === "function"
306
+ ) {
307
+ state.config.actionHandler(action.value);
308
+ }
309
+ });
310
+
311
+ actionsContainer.appendChild(actionBtn);
312
+ }
313
+ });
314
+
315
+ wrapper.appendChild(actionsContainer);
316
+ }
317
+
245
318
  return wrapper;
246
319
  }
247
320
 
@@ -541,8 +614,15 @@ function renderResourcePills(container, rids, onRemove) {
541
614
 
542
615
  // Add click handler to each slot
543
616
  slot.onclick = () => {
544
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
545
- const filesWrapper = container.closest(".space-y-2");
617
+ // Look for file input - check parent containers that have space-y-2 class
618
+ let filesWrapper = container.parentElement;
619
+ while (filesWrapper && !filesWrapper.classList.contains('space-y-2')) {
620
+ filesWrapper = filesWrapper.parentElement;
621
+ }
622
+ // If no parent with space-y-2, container itself might be the wrapper
623
+ if (!filesWrapper && container.classList.contains('space-y-2')) {
624
+ filesWrapper = container;
625
+ }
546
626
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
547
627
  if (fileInput) fileInput.click();
548
628
  };
@@ -559,8 +639,15 @@ function renderResourcePills(container, rids, onRemove) {
559
639
  uploadLink.textContent = t("uploadText");
560
640
  uploadLink.onclick = (e) => {
561
641
  e.stopPropagation();
562
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
563
- const filesWrapper = container.closest(".space-y-2");
642
+ // Look for file input - check parent containers that have space-y-2 class
643
+ let filesWrapper = container.parentElement;
644
+ while (filesWrapper && !filesWrapper.classList.contains('space-y-2')) {
645
+ filesWrapper = filesWrapper.parentElement;
646
+ }
647
+ // If no parent with space-y-2, container itself might be the wrapper
648
+ if (!filesWrapper && container.classList.contains('space-y-2')) {
649
+ filesWrapper = container;
650
+ }
564
651
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
565
652
  if (fileInput) fileInput.click();
566
653
  };
@@ -719,8 +806,15 @@ function renderResourcePills(container, rids, onRemove) {
719
806
  slot.innerHTML =
720
807
  '<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
721
808
  slot.onclick = () => {
722
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
723
- const filesWrapper = container.closest(".space-y-2");
809
+ // Look for file input - check parent containers that have space-y-2 class
810
+ let filesWrapper = container.parentElement;
811
+ while (filesWrapper && !filesWrapper.classList.contains('space-y-2')) {
812
+ filesWrapper = filesWrapper.parentElement;
813
+ }
814
+ // If no parent with space-y-2, container itself might be the wrapper
815
+ if (!filesWrapper && container.classList.contains('space-y-2')) {
816
+ filesWrapper = container;
817
+ }
724
818
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
725
819
  if (fileInput) fileInput.click();
726
820
  };
@@ -940,94 +1034,277 @@ function validateForm(skipValidation = false) {
940
1034
  switch (element.type) {
941
1035
  case "text":
942
1036
  case "textarea": {
943
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
944
- const val = input?.value ?? "";
945
- if (!skipValidation && element.required && val === "") {
946
- errors.push(`${key}: required`);
947
- markValidity(input, "required");
948
- return "";
949
- }
950
- if (!skipValidation && val) {
951
- if (element.minLength !== null && val.length < element.minLength) {
952
- errors.push(`${key}: minLength=${element.minLength}`);
953
- markValidity(input, `minLength=${element.minLength}`);
1037
+ if (element.multiple) {
1038
+ // Handle multiple text/textarea fields
1039
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1040
+ const values = [];
1041
+
1042
+ inputs.forEach((input, index) => {
1043
+ const val = input?.value ?? "";
1044
+ values.push(val);
1045
+
1046
+ if (!skipValidation && val) {
1047
+ if (element.minLength !== null && val.length < element.minLength) {
1048
+ errors.push(`${key}[${index}]: minLength=${element.minLength}`);
1049
+ markValidity(input, `minLength=${element.minLength}`);
1050
+ }
1051
+ if (element.maxLength !== null && val.length > element.maxLength) {
1052
+ errors.push(`${key}[${index}]: maxLength=${element.maxLength}`);
1053
+ markValidity(input, `maxLength=${element.maxLength}`);
1054
+ }
1055
+ if (element.pattern) {
1056
+ try {
1057
+ const re = new RegExp(element.pattern);
1058
+ if (!re.test(val)) {
1059
+ errors.push(`${key}[${index}]: pattern mismatch`);
1060
+ markValidity(input, "pattern mismatch");
1061
+ }
1062
+ } catch {
1063
+ errors.push(`${key}[${index}]: invalid pattern`);
1064
+ markValidity(input, "invalid pattern");
1065
+ }
1066
+ }
1067
+ } else {
1068
+ markValidity(input, null);
1069
+ }
1070
+ });
1071
+
1072
+ // Validate minCount/maxCount constraints
1073
+ if (!skipValidation) {
1074
+ const minCount = element.minCount ?? 1;
1075
+ const maxCount = element.maxCount ?? 10;
1076
+ const nonEmptyValues = values.filter(v => v.trim() !== "");
1077
+
1078
+ if (element.required && nonEmptyValues.length === 0) {
1079
+ errors.push(`${key}: required`);
1080
+ }
1081
+ if (nonEmptyValues.length < minCount) {
1082
+ errors.push(`${key}: minimum ${minCount} items required`);
1083
+ }
1084
+ if (nonEmptyValues.length > maxCount) {
1085
+ errors.push(`${key}: maximum ${maxCount} items allowed`);
1086
+ }
954
1087
  }
955
- if (element.maxLength !== null && val.length > element.maxLength) {
956
- errors.push(`${key}: maxLength=${element.maxLength}`);
957
- markValidity(input, `maxLength=${element.maxLength}`);
1088
+
1089
+ return values;
1090
+ } else {
1091
+ // Handle single text/textarea field
1092
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1093
+ const val = input?.value ?? "";
1094
+ if (!skipValidation && element.required && val === "") {
1095
+ errors.push(`${key}: required`);
1096
+ markValidity(input, "required");
1097
+ return "";
958
1098
  }
959
- if (element.pattern) {
960
- try {
961
- const re = new RegExp(element.pattern);
962
- if (!re.test(val)) {
963
- errors.push(`${key}: pattern mismatch`);
964
- markValidity(input, "pattern mismatch");
1099
+ if (!skipValidation && val) {
1100
+ if (element.minLength !== null && val.length < element.minLength) {
1101
+ errors.push(`${key}: minLength=${element.minLength}`);
1102
+ markValidity(input, `minLength=${element.minLength}`);
1103
+ }
1104
+ if (element.maxLength !== null && val.length > element.maxLength) {
1105
+ errors.push(`${key}: maxLength=${element.maxLength}`);
1106
+ markValidity(input, `maxLength=${element.maxLength}`);
1107
+ }
1108
+ if (element.pattern) {
1109
+ try {
1110
+ const re = new RegExp(element.pattern);
1111
+ if (!re.test(val)) {
1112
+ errors.push(`${key}: pattern mismatch`);
1113
+ markValidity(input, "pattern mismatch");
1114
+ }
1115
+ } catch {
1116
+ errors.push(`${key}: invalid pattern`);
1117
+ markValidity(input, "invalid pattern");
965
1118
  }
966
- } catch {
967
- errors.push(`${key}: invalid pattern`);
968
- markValidity(input, "invalid pattern");
969
1119
  }
1120
+ } else if (skipValidation) {
1121
+ markValidity(input, null);
1122
+ } else {
1123
+ markValidity(input, null);
970
1124
  }
971
- } else if (skipValidation) {
972
- markValidity(input, null);
973
- } else {
974
- markValidity(input, null);
1125
+ return val;
975
1126
  }
976
- return val;
977
1127
  }
978
1128
  case "number": {
979
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
980
- const raw = input?.value ?? "";
981
- if (!skipValidation && element.required && raw === "") {
982
- errors.push(`${key}: required`);
983
- markValidity(input, "required");
984
- return null;
985
- }
986
- if (raw === "") {
1129
+ if (element.multiple) {
1130
+ // Handle multiple number fields
1131
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1132
+ const values = [];
1133
+
1134
+ inputs.forEach((input, index) => {
1135
+ const raw = input?.value ?? "";
1136
+ if (raw === "") {
1137
+ values.push(null);
1138
+ markValidity(input, null);
1139
+ return;
1140
+ }
1141
+
1142
+ const v = parseFloat(raw);
1143
+ if (!skipValidation && !Number.isFinite(v)) {
1144
+ errors.push(`${key}[${index}]: not a number`);
1145
+ markValidity(input, "not a number");
1146
+ values.push(null);
1147
+ return;
1148
+ }
1149
+
1150
+ if (!skipValidation && element.min !== null && v < element.min) {
1151
+ errors.push(`${key}[${index}]: < min=${element.min}`);
1152
+ markValidity(input, `< min=${element.min}`);
1153
+ }
1154
+ if (!skipValidation && element.max !== null && v > element.max) {
1155
+ errors.push(`${key}[${index}]: > max=${element.max}`);
1156
+ markValidity(input, `> max=${element.max}`);
1157
+ }
1158
+
1159
+ const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
1160
+ markValidity(input, null);
1161
+ values.push(Number(v.toFixed(d)));
1162
+ });
1163
+
1164
+ // Validate minCount/maxCount constraints
1165
+ if (!skipValidation) {
1166
+ const minCount = element.minCount ?? 1;
1167
+ const maxCount = element.maxCount ?? 10;
1168
+ const nonNullValues = values.filter(v => v !== null);
1169
+
1170
+ if (element.required && nonNullValues.length === 0) {
1171
+ errors.push(`${key}: required`);
1172
+ }
1173
+ if (nonNullValues.length < minCount) {
1174
+ errors.push(`${key}: minimum ${minCount} items required`);
1175
+ }
1176
+ if (nonNullValues.length > maxCount) {
1177
+ errors.push(`${key}: maximum ${maxCount} items allowed`);
1178
+ }
1179
+ }
1180
+
1181
+ return values;
1182
+ } else {
1183
+ // Handle single number field
1184
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1185
+ const raw = input?.value ?? "";
1186
+ if (!skipValidation && element.required && raw === "") {
1187
+ errors.push(`${key}: required`);
1188
+ markValidity(input, "required");
1189
+ return null;
1190
+ }
1191
+ if (raw === "") {
1192
+ markValidity(input, null);
1193
+ return null;
1194
+ }
1195
+ const v = parseFloat(raw);
1196
+ if (!skipValidation && !Number.isFinite(v)) {
1197
+ errors.push(`${key}: not a number`);
1198
+ markValidity(input, "not a number");
1199
+ return null;
1200
+ }
1201
+ if (!skipValidation && element.min !== null && v < element.min) {
1202
+ errors.push(`${key}: < min=${element.min}`);
1203
+ markValidity(input, `< min=${element.min}`);
1204
+ }
1205
+ if (!skipValidation && element.max !== null && v > element.max) {
1206
+ errors.push(`${key}: > max=${element.max}`);
1207
+ markValidity(input, `> max=${element.max}`);
1208
+ }
1209
+ const d = Number.isInteger(element.decimals ?? 0)
1210
+ ? element.decimals
1211
+ : 0;
987
1212
  markValidity(input, null);
988
- return null;
989
- }
990
- const v = parseFloat(raw);
991
- if (!skipValidation && !Number.isFinite(v)) {
992
- errors.push(`${key}: not a number`);
993
- markValidity(input, "not a number");
994
- return null;
995
- }
996
- if (!skipValidation && element.min !== null && v < element.min) {
997
- errors.push(`${key}: < min=${element.min}`);
998
- markValidity(input, `< min=${element.min}`);
1213
+ return Number(v.toFixed(d));
999
1214
  }
1000
- if (!skipValidation && element.max !== null && v > element.max) {
1001
- errors.push(`${key}: > max=${element.max}`);
1002
- markValidity(input, `> max=${element.max}`);
1003
- }
1004
- const d = Number.isInteger(element.decimals ?? 0)
1005
- ? element.decimals
1006
- : 0;
1007
- markValidity(input, null);
1008
- return Number(v.toFixed(d));
1009
1215
  }
1010
1216
  case "select": {
1011
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1012
- const val = input?.value ?? "";
1013
- if (!skipValidation && element.required && val === "") {
1014
- errors.push(`${key}: required`);
1015
- markValidity(input, "required");
1016
- return "";
1217
+ if (element.multiple) {
1218
+ // Handle multiple select fields
1219
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1220
+ const values = [];
1221
+
1222
+ inputs.forEach((input) => {
1223
+ const val = input?.value ?? "";
1224
+ values.push(val);
1225
+ markValidity(input, null);
1226
+ });
1227
+
1228
+ // Validate minCount/maxCount constraints
1229
+ if (!skipValidation) {
1230
+ const minCount = element.minCount ?? 1;
1231
+ const maxCount = element.maxCount ?? 10;
1232
+ const nonEmptyValues = values.filter(v => v !== "");
1233
+
1234
+ if (element.required && nonEmptyValues.length === 0) {
1235
+ errors.push(`${key}: required`);
1236
+ }
1237
+ if (nonEmptyValues.length < minCount) {
1238
+ errors.push(`${key}: minimum ${minCount} items required`);
1239
+ }
1240
+ if (nonEmptyValues.length > maxCount) {
1241
+ errors.push(`${key}: maximum ${maxCount} items allowed`);
1242
+ }
1243
+ }
1244
+
1245
+ return values;
1246
+ } else {
1247
+ // Handle single select field
1248
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1249
+ const val = input?.value ?? "";
1250
+ if (!skipValidation && element.required && val === "") {
1251
+ errors.push(`${key}: required`);
1252
+ markValidity(input, "required");
1253
+ return "";
1254
+ }
1255
+ markValidity(input, null);
1256
+ return val;
1017
1257
  }
1018
- markValidity(input, null);
1019
- return val;
1020
1258
  }
1021
1259
  case "file": {
1022
- const input = scopeRoot.querySelector(
1023
- `input[name$="${key}"][type="hidden"]`,
1024
- );
1025
- const rid = input?.value ?? "";
1026
- if (!skipValidation && element.required && rid === "") {
1027
- errors.push(`${key}: required`);
1028
- return null;
1260
+ if (element.multiple) {
1261
+ // Handle file with multiple property like files type
1262
+ // Find the files list by locating the specific file input for this field
1263
+ const fullKey = pathJoin(ctx.path, key);
1264
+ const pickerInput = scopeRoot.querySelector(`input[type="file"][name="${fullKey}"]`);
1265
+ const filesWrapper = pickerInput?.closest('.space-y-2');
1266
+ const container = filesWrapper?.querySelector('.files-list') || null;
1267
+
1268
+ const resourceIds = [];
1269
+ if (container) {
1270
+ const pills = container.querySelectorAll(".resource-pill");
1271
+ pills.forEach((pill) => {
1272
+ const resourceId = pill.dataset.resourceId;
1273
+ if (resourceId) {
1274
+ resourceIds.push(resourceId);
1275
+ }
1276
+ });
1277
+ }
1278
+
1279
+ // Validate minCount/maxCount constraints
1280
+ if (!skipValidation) {
1281
+ const minFiles = element.minCount ?? 0;
1282
+ const maxFiles = element.maxCount ?? Infinity;
1283
+
1284
+ if (element.required && resourceIds.length === 0) {
1285
+ errors.push(`${key}: required`);
1286
+ }
1287
+ if (resourceIds.length < minFiles) {
1288
+ errors.push(`${key}: minimum ${minFiles} files required`);
1289
+ }
1290
+ if (resourceIds.length > maxFiles) {
1291
+ errors.push(`${key}: maximum ${maxFiles} files allowed`);
1292
+ }
1293
+ }
1294
+
1295
+ return resourceIds;
1296
+ } else {
1297
+ // Handle single file
1298
+ const input = scopeRoot.querySelector(
1299
+ `input[name$="${key}"][type="hidden"]`,
1300
+ );
1301
+ const rid = input?.value ?? "";
1302
+ if (!skipValidation && element.required && rid === "") {
1303
+ errors.push(`${key}: required`);
1304
+ return null;
1305
+ }
1306
+ return rid || null;
1029
1307
  }
1030
- return rid || null;
1031
1308
  }
1032
1309
  case "files": {
1033
1310
  // For files, we need to collect all resource IDs
@@ -1071,6 +1348,69 @@ function validateForm(skipValidation = false) {
1071
1348
  }
1072
1349
  case "group": {
1073
1350
  if (element.repeat && isPlainObject(element.repeat)) {
1351
+ const items = [];
1352
+ // Use full path for nested group element search
1353
+ const fullKey = pathJoin(ctx.path, key);
1354
+ const itemElements = scopeRoot.querySelectorAll(`[name^="${fullKey}["]`);
1355
+
1356
+ // Extract actual indices from DOM element names instead of assuming sequential numbering
1357
+ const actualIndices = new Set();
1358
+ itemElements.forEach((el) => {
1359
+ const match = el.name.match(new RegExp(`^${fullKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[(\\d+)\\]`));
1360
+ if (match) {
1361
+ actualIndices.add(parseInt(match[1]));
1362
+ }
1363
+ });
1364
+
1365
+ const sortedIndices = Array.from(actualIndices).sort((a, b) => a - b);
1366
+
1367
+ sortedIndices.forEach((actualIndex) => {
1368
+ const itemData = {};
1369
+ // Find the specific group item container for scoped queries - use full path
1370
+ const fullItemPath = `${fullKey}[${actualIndex}]`;
1371
+ const itemContainer =
1372
+ scopeRoot.querySelector(`[data-group-item="${fullItemPath}"]`) ||
1373
+ scopeRoot;
1374
+ element.elements.forEach((child) => {
1375
+ if (child.hidden) {
1376
+ // For hidden child elements, use their default value
1377
+ itemData[child.key] = child.default !== undefined ? child.default : "";
1378
+ } else {
1379
+ const childKey = `${fullKey}[${actualIndex}].${child.key}`;
1380
+ itemData[child.key] = validateElement(
1381
+ { ...child, key: childKey },
1382
+ ctx,
1383
+ itemContainer,
1384
+ );
1385
+ }
1386
+ });
1387
+ items.push(itemData);
1388
+ });
1389
+ return items;
1390
+ } else {
1391
+ const groupData = {};
1392
+ // Find the specific group container for scoped queries
1393
+ const groupContainer =
1394
+ scopeRoot.querySelector(`[data-group="${key}"]`) || scopeRoot;
1395
+ element.elements.forEach((child) => {
1396
+ if (child.hidden) {
1397
+ // For hidden child elements, use their default value
1398
+ groupData[child.key] = child.default !== undefined ? child.default : "";
1399
+ } else {
1400
+ const childKey = `${key}.${child.key}`;
1401
+ groupData[child.key] = validateElement(
1402
+ { ...child, key: childKey },
1403
+ ctx,
1404
+ groupContainer,
1405
+ );
1406
+ }
1407
+ });
1408
+ return groupData;
1409
+ }
1410
+ }
1411
+ case "container": {
1412
+ if (element.multiple) {
1413
+ // Handle multiple containers like repeating groups
1074
1414
  const items = [];
1075
1415
  const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1076
1416
  const itemCount = Math.max(
@@ -1080,35 +1420,62 @@ function validateForm(skipValidation = false) {
1080
1420
 
1081
1421
  for (let i = 0; i < itemCount; i++) {
1082
1422
  const itemData = {};
1083
- // Find the specific group item container for scoped queries
1423
+ // Find the specific container item container for scoped queries
1084
1424
  const itemContainer =
1085
- scopeRoot.querySelector(`[data-group-item="${key}[${i}]"]`) ||
1425
+ scopeRoot.querySelector(`[data-container-item="${key}[${i}]"]`) ||
1086
1426
  scopeRoot;
1087
1427
  element.elements.forEach((child) => {
1088
- const childKey = `${key}[${i}].${child.key}`;
1089
- itemData[child.key] = validateElement(
1090
- { ...child, key: childKey },
1091
- ctx,
1092
- itemContainer,
1093
- );
1428
+ if (child.hidden) {
1429
+ // For hidden child elements, use their default value
1430
+ itemData[child.key] = child.default !== undefined ? child.default : "";
1431
+ } else {
1432
+ const childKey = `${key}[${i}].${child.key}`;
1433
+ itemData[child.key] = validateElement(
1434
+ { ...child, key: childKey },
1435
+ ctx,
1436
+ itemContainer,
1437
+ );
1438
+ }
1094
1439
  });
1095
1440
  items.push(itemData);
1096
1441
  }
1442
+
1443
+ // Validate minCount/maxCount constraints
1444
+ if (!skipValidation) {
1445
+ const minItems = element.minCount ?? 0;
1446
+ const maxItems = element.maxCount ?? Infinity;
1447
+
1448
+ if (element.required && items.length === 0) {
1449
+ errors.push(`${key}: required`);
1450
+ }
1451
+ if (items.length < minItems) {
1452
+ errors.push(`${key}: minimum ${minItems} items required`);
1453
+ }
1454
+ if (items.length > maxItems) {
1455
+ errors.push(`${key}: maximum ${maxItems} items allowed`);
1456
+ }
1457
+ }
1458
+
1097
1459
  return items;
1098
1460
  } else {
1099
- const groupData = {};
1100
- // Find the specific group container for scoped queries
1101
- const groupContainer =
1102
- scopeRoot.querySelector(`[data-group="${key}"]`) || scopeRoot;
1461
+ const containerData = {};
1462
+ // Find the specific container container for scoped queries
1463
+ const containerContainer =
1464
+ scopeRoot.querySelector(`[data-container="${key}"]`) || scopeRoot;
1103
1465
  element.elements.forEach((child) => {
1104
- const childKey = `${key}.${child.key}`;
1105
- groupData[child.key] = validateElement(
1106
- { ...child, key: childKey },
1107
- ctx,
1108
- groupContainer,
1109
- );
1466
+ if (child.hidden) {
1467
+ // For hidden child elements, use their default value
1468
+ containerData[child.key] = child.default !== undefined ? child.default : "";
1469
+ } else {
1470
+ const childKey = `${key}.${child.key}`;
1471
+ containerData[child.key] = validateElement(
1472
+ { ...child, key: childKey },
1473
+ ctx,
1474
+ containerContainer,
1475
+ );
1476
+ }
1110
1477
  });
1111
- return groupData;
1478
+ return containerData;
1112
1479
  }
1113
1480
  }
1114
1481
  default:
@@ -1117,7 +1484,12 @@ function validateForm(skipValidation = false) {
1117
1484
  }
1118
1485
 
1119
1486
  state.schema.elements.forEach((element) => {
1120
- data[element.key] = validateElement(element, { path: "" });
1487
+ // Handle hidden elements - use their default value instead of reading from DOM
1488
+ if (element.hidden) {
1489
+ data[element.key] = element.default !== undefined ? element.default : "";
1490
+ } else {
1491
+ data[element.key] = validateElement(element, { path: "" });
1492
+ }
1121
1493
  });
1122
1494
 
1123
1495
  return {
@@ -1146,6 +1518,118 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
1146
1518
  wrapper.appendChild(textHint);
1147
1519
  }
1148
1520
 
1521
+ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1522
+ const prefillValues = ctx.prefill[element.key] || [];
1523
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1524
+
1525
+ // Ensure minimum count
1526
+ const minCount = element.minCount ?? 1;
1527
+ const maxCount = element.maxCount ?? 10;
1528
+
1529
+ while (values.length < minCount) {
1530
+ values.push(element.default || "");
1531
+ }
1532
+
1533
+ const container = document.createElement("div");
1534
+ container.className = "space-y-2";
1535
+ wrapper.appendChild(container);
1536
+
1537
+ function updateIndices() {
1538
+ const items = container.querySelectorAll(".multiple-text-item");
1539
+ items.forEach((item, index) => {
1540
+ const input = item.querySelector("input");
1541
+ if (input) {
1542
+ input.name = `${pathKey}[${index}]`;
1543
+ }
1544
+ });
1545
+ }
1546
+
1547
+ function addTextItem(value = "", index = -1) {
1548
+ const itemWrapper = document.createElement("div");
1549
+ itemWrapper.className = "multiple-text-item flex items-center gap-2";
1550
+
1551
+ const textInput = document.createElement("input");
1552
+ textInput.type = "text";
1553
+ textInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1554
+ textInput.placeholder = element.placeholder || "Enter text";
1555
+ textInput.value = value;
1556
+ textInput.readOnly = state.config.readonly;
1557
+
1558
+ itemWrapper.appendChild(textInput);
1559
+
1560
+ // Remove buttons are managed centrally via updateRemoveButtons()
1561
+
1562
+ if (index === -1) {
1563
+ container.appendChild(itemWrapper);
1564
+ } else {
1565
+ container.insertBefore(itemWrapper, container.children[index]);
1566
+ }
1567
+
1568
+ updateIndices();
1569
+ return itemWrapper;
1570
+ }
1571
+
1572
+ function updateRemoveButtons() {
1573
+ if (state.config.readonly) return;
1574
+ const items = container.querySelectorAll('.multiple-text-item');
1575
+ const currentCount = items.length;
1576
+ items.forEach((item) => {
1577
+ let removeBtn = item.querySelector('.remove-item-btn');
1578
+ if (!removeBtn) {
1579
+ removeBtn = document.createElement('button');
1580
+ removeBtn.type = 'button';
1581
+ removeBtn.className = 'remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded';
1582
+ removeBtn.innerHTML = '✕';
1583
+ removeBtn.onclick = () => {
1584
+ const currentIndex = Array.from(container.children).indexOf(item);
1585
+ if (container.children.length > minCount) {
1586
+ values.splice(currentIndex, 1);
1587
+ item.remove();
1588
+ updateIndices();
1589
+ updateAddButton();
1590
+ updateRemoveButtons();
1591
+ }
1592
+ };
1593
+ item.appendChild(removeBtn);
1594
+ }
1595
+ const disabled = currentCount <= minCount;
1596
+ removeBtn.disabled = disabled;
1597
+ removeBtn.style.opacity = disabled ? '0.5' : '1';
1598
+ removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
1599
+ });
1600
+ }
1601
+
1602
+ function updateAddButton() {
1603
+ const existingAddBtn = wrapper.querySelector(".add-text-btn");
1604
+ if (existingAddBtn) existingAddBtn.remove();
1605
+
1606
+ if (!state.config.readonly && values.length < maxCount) {
1607
+ const addBtn = document.createElement("button");
1608
+ addBtn.type = "button";
1609
+ addBtn.className = "add-text-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
1610
+ addBtn.textContent = `+ Add ${element.label || 'Text'}`;
1611
+ addBtn.onclick = () => {
1612
+ values.push(element.default || "");
1613
+ addTextItem(element.default || "");
1614
+ updateAddButton();
1615
+ updateRemoveButtons();
1616
+ };
1617
+ wrapper.appendChild(addBtn);
1618
+ }
1619
+ }
1620
+
1621
+ // Render initial items
1622
+ values.forEach(value => addTextItem(value));
1623
+ updateAddButton();
1624
+ updateRemoveButtons();
1625
+
1626
+ // Add hint
1627
+ const hint = document.createElement("p");
1628
+ hint.className = "text-xs text-gray-500 mt-1";
1629
+ hint.textContent = makeFieldHint(element);
1630
+ wrapper.appendChild(hint);
1631
+ }
1632
+
1149
1633
  function renderTextareaElement(element, ctx, wrapper, pathKey) {
1150
1634
  const textareaInput = document.createElement("textarea");
1151
1635
  textareaInput.className =
@@ -1164,6 +1648,118 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1164
1648
  wrapper.appendChild(textareaHint);
1165
1649
  }
1166
1650
 
1651
+ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1652
+ const prefillValues = ctx.prefill[element.key] || [];
1653
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1654
+
1655
+ // Ensure minimum count
1656
+ const minCount = element.minCount ?? 1;
1657
+ const maxCount = element.maxCount ?? 10;
1658
+
1659
+ while (values.length < minCount) {
1660
+ values.push(element.default || "");
1661
+ }
1662
+
1663
+ const container = document.createElement("div");
1664
+ container.className = "space-y-2";
1665
+ wrapper.appendChild(container);
1666
+
1667
+ function updateIndices() {
1668
+ const items = container.querySelectorAll(".multiple-textarea-item");
1669
+ items.forEach((item, index) => {
1670
+ const textarea = item.querySelector("textarea");
1671
+ if (textarea) {
1672
+ textarea.name = `${pathKey}[${index}]`;
1673
+ }
1674
+ });
1675
+ }
1676
+
1677
+ function addTextareaItem(value = "", index = -1) {
1678
+ const itemWrapper = document.createElement("div");
1679
+ itemWrapper.className = "multiple-textarea-item";
1680
+
1681
+ const textareaInput = document.createElement("textarea");
1682
+ textareaInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
1683
+ textareaInput.placeholder = element.placeholder || "Enter text";
1684
+ textareaInput.rows = element.rows || 4;
1685
+ textareaInput.value = value;
1686
+ textareaInput.readOnly = state.config.readonly;
1687
+
1688
+ itemWrapper.appendChild(textareaInput);
1689
+
1690
+ // Remove buttons are managed centrally via updateRemoveButtons()
1691
+
1692
+ if (index === -1) {
1693
+ container.appendChild(itemWrapper);
1694
+ } else {
1695
+ container.insertBefore(itemWrapper, container.children[index]);
1696
+ }
1697
+
1698
+ updateIndices();
1699
+ return itemWrapper;
1700
+ }
1701
+
1702
+ function updateRemoveButtons() {
1703
+ if (state.config.readonly) return;
1704
+ const items = container.querySelectorAll('.multiple-textarea-item');
1705
+ const currentCount = items.length;
1706
+ items.forEach((item) => {
1707
+ let removeBtn = item.querySelector('.remove-item-btn');
1708
+ if (!removeBtn) {
1709
+ removeBtn = document.createElement('button');
1710
+ removeBtn.type = 'button';
1711
+ removeBtn.className = 'remove-item-btn mt-1 px-2 py-1 text-red-600 hover:bg-red-50 rounded text-sm';
1712
+ removeBtn.innerHTML = '✕ Remove';
1713
+ removeBtn.onclick = () => {
1714
+ const currentIndex = Array.from(container.children).indexOf(item);
1715
+ if (container.children.length > minCount) {
1716
+ values.splice(currentIndex, 1);
1717
+ item.remove();
1718
+ updateIndices();
1719
+ updateAddButton();
1720
+ updateRemoveButtons();
1721
+ }
1722
+ };
1723
+ item.appendChild(removeBtn);
1724
+ }
1725
+ const disabled = currentCount <= minCount;
1726
+ removeBtn.disabled = disabled;
1727
+ removeBtn.style.opacity = disabled ? '0.5' : '1';
1728
+ removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
1729
+ });
1730
+ }
1731
+
1732
+ function updateAddButton() {
1733
+ const existingAddBtn = wrapper.querySelector(".add-textarea-btn");
1734
+ if (existingAddBtn) existingAddBtn.remove();
1735
+
1736
+ if (!state.config.readonly && values.length < maxCount) {
1737
+ const addBtn = document.createElement("button");
1738
+ addBtn.type = "button";
1739
+ addBtn.className = "add-textarea-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
1740
+ addBtn.textContent = `+ Add ${element.label || 'Textarea'}`;
1741
+ addBtn.onclick = () => {
1742
+ values.push(element.default || "");
1743
+ addTextareaItem(element.default || "");
1744
+ updateAddButton();
1745
+ updateRemoveButtons();
1746
+ };
1747
+ wrapper.appendChild(addBtn);
1748
+ }
1749
+ }
1750
+
1751
+ // Render initial items
1752
+ values.forEach(value => addTextareaItem(value));
1753
+ updateAddButton();
1754
+ updateRemoveButtons();
1755
+
1756
+ // Add hint
1757
+ const hint = document.createElement("p");
1758
+ hint.className = "text-xs text-gray-500 mt-1";
1759
+ hint.textContent = makeFieldHint(element);
1760
+ wrapper.appendChild(hint);
1761
+ }
1762
+
1167
1763
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1168
1764
  const numberInput = document.createElement("input");
1169
1765
  numberInput.type = "number";
@@ -1185,6 +1781,121 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1185
1781
  wrapper.appendChild(numberHint);
1186
1782
  }
1187
1783
 
1784
+ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1785
+ const prefillValues = ctx.prefill[element.key] || [];
1786
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1787
+
1788
+ // Ensure minimum count
1789
+ const minCount = element.minCount ?? 1;
1790
+ const maxCount = element.maxCount ?? 10;
1791
+
1792
+ while (values.length < minCount) {
1793
+ values.push(element.default || "");
1794
+ }
1795
+
1796
+ const container = document.createElement("div");
1797
+ container.className = "space-y-2";
1798
+ wrapper.appendChild(container);
1799
+
1800
+ function updateIndices() {
1801
+ const items = container.querySelectorAll(".multiple-number-item");
1802
+ items.forEach((item, index) => {
1803
+ const input = item.querySelector("input");
1804
+ if (input) {
1805
+ input.name = `${pathKey}[${index}]`;
1806
+ }
1807
+ });
1808
+ }
1809
+
1810
+ function addNumberItem(value = "", index = -1) {
1811
+ const itemWrapper = document.createElement("div");
1812
+ itemWrapper.className = "multiple-number-item flex items-center gap-2";
1813
+
1814
+ const numberInput = document.createElement("input");
1815
+ numberInput.type = "number";
1816
+ numberInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1817
+ numberInput.placeholder = element.placeholder || "0";
1818
+ if (element.min !== undefined) numberInput.min = element.min;
1819
+ if (element.max !== undefined) numberInput.max = element.max;
1820
+ if (element.step !== undefined) numberInput.step = element.step;
1821
+ numberInput.value = value;
1822
+ numberInput.readOnly = state.config.readonly;
1823
+
1824
+ itemWrapper.appendChild(numberInput);
1825
+
1826
+ // Remove buttons are managed centrally via updateRemoveButtons()
1827
+
1828
+ if (index === -1) {
1829
+ container.appendChild(itemWrapper);
1830
+ } else {
1831
+ container.insertBefore(itemWrapper, container.children[index]);
1832
+ }
1833
+
1834
+ updateIndices();
1835
+ return itemWrapper;
1836
+ }
1837
+
1838
+ function updateRemoveButtons() {
1839
+ if (state.config.readonly) return;
1840
+ const items = container.querySelectorAll('.multiple-number-item');
1841
+ const currentCount = items.length;
1842
+ items.forEach((item) => {
1843
+ let removeBtn = item.querySelector('.remove-item-btn');
1844
+ if (!removeBtn) {
1845
+ removeBtn = document.createElement('button');
1846
+ removeBtn.type = 'button';
1847
+ removeBtn.className = 'remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded';
1848
+ removeBtn.innerHTML = '✕';
1849
+ removeBtn.onclick = () => {
1850
+ const currentIndex = Array.from(container.children).indexOf(item);
1851
+ if (container.children.length > minCount) {
1852
+ values.splice(currentIndex, 1);
1853
+ item.remove();
1854
+ updateIndices();
1855
+ updateAddButton();
1856
+ updateRemoveButtons();
1857
+ }
1858
+ };
1859
+ item.appendChild(removeBtn);
1860
+ }
1861
+ const disabled = currentCount <= minCount;
1862
+ removeBtn.disabled = disabled;
1863
+ removeBtn.style.opacity = disabled ? '0.5' : '1';
1864
+ removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
1865
+ });
1866
+ }
1867
+
1868
+ function updateAddButton() {
1869
+ const existingAddBtn = wrapper.querySelector(".add-number-btn");
1870
+ if (existingAddBtn) existingAddBtn.remove();
1871
+
1872
+ if (!state.config.readonly && values.length < maxCount) {
1873
+ const addBtn = document.createElement("button");
1874
+ addBtn.type = "button";
1875
+ addBtn.className = "add-number-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
1876
+ addBtn.textContent = `+ Add ${element.label || 'Number'}`;
1877
+ addBtn.onclick = () => {
1878
+ values.push(element.default || "");
1879
+ addNumberItem(element.default || "");
1880
+ updateAddButton();
1881
+ updateRemoveButtons();
1882
+ };
1883
+ wrapper.appendChild(addBtn);
1884
+ }
1885
+ }
1886
+
1887
+ // Render initial items
1888
+ values.forEach(value => addNumberItem(value));
1889
+ updateAddButton();
1890
+ updateRemoveButtons();
1891
+
1892
+ // Add hint
1893
+ const hint = document.createElement("p");
1894
+ hint.className = "text-xs text-gray-500 mt-1";
1895
+ hint.textContent = makeFieldHint(element);
1896
+ wrapper.appendChild(hint);
1897
+ }
1898
+
1188
1899
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1189
1900
  const selectInput = document.createElement("select");
1190
1901
  selectInput.className =
@@ -1211,6 +1922,127 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1211
1922
  wrapper.appendChild(selectHint);
1212
1923
  }
1213
1924
 
1925
+ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1926
+ const prefillValues = ctx.prefill[element.key] || [];
1927
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1928
+
1929
+ // Ensure minimum count
1930
+ const minCount = element.minCount ?? 1;
1931
+ const maxCount = element.maxCount ?? 10;
1932
+
1933
+ while (values.length < minCount) {
1934
+ values.push(element.default || (element.options?.[0]?.value || ""));
1935
+ }
1936
+
1937
+ const container = document.createElement("div");
1938
+ container.className = "space-y-2";
1939
+ wrapper.appendChild(container);
1940
+
1941
+ function updateIndices() {
1942
+ const items = container.querySelectorAll(".multiple-select-item");
1943
+ items.forEach((item, index) => {
1944
+ const select = item.querySelector("select");
1945
+ if (select) {
1946
+ select.name = `${pathKey}[${index}]`;
1947
+ }
1948
+ });
1949
+ }
1950
+
1951
+ function addSelectItem(value = "", index = -1) {
1952
+ const itemWrapper = document.createElement("div");
1953
+ itemWrapper.className = "multiple-select-item flex items-center gap-2";
1954
+
1955
+ const selectInput = document.createElement("select");
1956
+ selectInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1957
+ selectInput.disabled = state.config.readonly;
1958
+
1959
+ // Add options
1960
+ (element.options || []).forEach((option) => {
1961
+ const optionElement = document.createElement("option");
1962
+ optionElement.value = option.value;
1963
+ optionElement.textContent = option.label;
1964
+ if (value === option.value) {
1965
+ optionElement.selected = true;
1966
+ }
1967
+ selectInput.appendChild(optionElement);
1968
+ });
1969
+
1970
+ itemWrapper.appendChild(selectInput);
1971
+
1972
+ // Remove buttons are managed centrally via updateRemoveButtons()
1973
+
1974
+ if (index === -1) {
1975
+ container.appendChild(itemWrapper);
1976
+ } else {
1977
+ container.insertBefore(itemWrapper, container.children[index]);
1978
+ }
1979
+
1980
+ updateIndices();
1981
+ return itemWrapper;
1982
+ }
1983
+
1984
+ function updateRemoveButtons() {
1985
+ if (state.config.readonly) return;
1986
+ const items = container.querySelectorAll('.multiple-select-item');
1987
+ const currentCount = items.length;
1988
+ items.forEach((item) => {
1989
+ let removeBtn = item.querySelector('.remove-item-btn');
1990
+ if (!removeBtn) {
1991
+ removeBtn = document.createElement('button');
1992
+ removeBtn.type = 'button';
1993
+ removeBtn.className = 'remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded';
1994
+ removeBtn.innerHTML = '✕';
1995
+ removeBtn.onclick = () => {
1996
+ const currentIndex = Array.from(container.children).indexOf(item);
1997
+ if (container.children.length > minCount) {
1998
+ values.splice(currentIndex, 1);
1999
+ item.remove();
2000
+ updateIndices();
2001
+ updateAddButton();
2002
+ updateRemoveButtons();
2003
+ }
2004
+ };
2005
+ item.appendChild(removeBtn);
2006
+ }
2007
+ const disabled = currentCount <= minCount;
2008
+ removeBtn.disabled = disabled;
2009
+ removeBtn.style.opacity = disabled ? '0.5' : '1';
2010
+ removeBtn.style.pointerEvents = disabled ? 'none' : 'auto';
2011
+ });
2012
+ }
2013
+
2014
+ function updateAddButton() {
2015
+ const existingAddBtn = wrapper.querySelector(".add-select-btn");
2016
+ if (existingAddBtn) existingAddBtn.remove();
2017
+
2018
+ if (!state.config.readonly && values.length < maxCount) {
2019
+ const addBtn = document.createElement("button");
2020
+ addBtn.type = "button";
2021
+ addBtn.className = "add-select-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
2022
+ addBtn.textContent = `+ Add ${element.label || 'Selection'}`;
2023
+ addBtn.onclick = () => {
2024
+ const defaultValue = element.default || (element.options?.[0]?.value || "");
2025
+ values.push(defaultValue);
2026
+ addSelectItem(defaultValue);
2027
+ updateAddButton();
2028
+ updateRemoveButtons();
2029
+ };
2030
+ wrapper.appendChild(addBtn);
2031
+ }
2032
+ }
2033
+
2034
+ // Render initial items
2035
+ values.forEach(value => addSelectItem(value));
2036
+ updateAddButton();
2037
+ updateRemoveButtons();
2038
+
2039
+ // Add hint
2040
+ const hint = document.createElement("p");
2041
+ hint.className = "text-xs text-gray-500 mt-1";
2042
+ hint.textContent = makeFieldHint(element);
2043
+ wrapper.appendChild(hint);
2044
+ }
2045
+
1214
2046
  function renderFileElement(element, ctx, wrapper, pathKey) {
1215
2047
  if (state.config.readonly) {
1216
2048
  // Readonly mode: use common preview function
@@ -1429,6 +2261,93 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
1429
2261
  }
1430
2262
  }
1431
2263
 
2264
+ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
2265
+ // Use the same logic as renderFilesElement but with minCount/maxCount from element properties
2266
+ const minFiles = element.minCount ?? 0;
2267
+ const maxFiles = element.maxCount ?? Infinity;
2268
+
2269
+ if (state.config.readonly) {
2270
+ // Readonly mode: render as results list
2271
+ const resultsWrapper = document.createElement("div");
2272
+ resultsWrapper.className = "space-y-4";
2273
+
2274
+ const initialFiles = ctx.prefill[element.key] || [];
2275
+
2276
+ if (initialFiles.length > 0) {
2277
+ initialFiles.forEach((resourceId) => {
2278
+ const filePreview = renderFilePreviewReadonly(resourceId);
2279
+ resultsWrapper.appendChild(filePreview);
2280
+ });
2281
+ } else {
2282
+ resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected")}</div></div>`;
2283
+ }
2284
+
2285
+ wrapper.appendChild(resultsWrapper);
2286
+ } else {
2287
+ // Edit mode: multiple file input with min/max validation
2288
+ const filesWrapper = document.createElement("div");
2289
+ filesWrapper.className = "space-y-2";
2290
+
2291
+ const filesPicker = document.createElement("input");
2292
+ filesPicker.type = "file";
2293
+ filesPicker.name = pathKey;
2294
+ filesPicker.multiple = true;
2295
+ filesPicker.style.display = "none"; // Hide default input
2296
+ if (element.accept?.extensions) {
2297
+ filesPicker.accept = element.accept.extensions
2298
+ .map((ext) => `.${ext}`)
2299
+ .join(",");
2300
+ }
2301
+
2302
+ const filesContainer = document.createElement("div");
2303
+ filesContainer.className = "files-list space-y-2";
2304
+
2305
+ filesWrapper.appendChild(filesPicker);
2306
+ filesWrapper.appendChild(filesContainer);
2307
+
2308
+ const initialFiles = Array.isArray(ctx.prefill[element.key])
2309
+ ? [...ctx.prefill[element.key]]
2310
+ : [];
2311
+
2312
+ // Add initial files to resource index
2313
+ addPrefillFilesToIndex(initialFiles);
2314
+
2315
+ const updateFilesDisplay = () => {
2316
+ renderResourcePills(filesContainer, initialFiles, (index) => {
2317
+ initialFiles.splice(index, 1);
2318
+ updateFilesDisplay();
2319
+ });
2320
+
2321
+ // Show count and min/max info
2322
+ const countInfo = document.createElement("div");
2323
+ countInfo.className = "text-xs text-gray-500 mt-2";
2324
+ const countText = `${initialFiles.length} file${initialFiles.length !== 1 ? 's' : ''}`;
2325
+ const minMaxText = minFiles > 0 || maxFiles < Infinity
2326
+ ? ` (${minFiles}-${maxFiles} allowed)`
2327
+ : '';
2328
+ countInfo.textContent = countText + minMaxText;
2329
+
2330
+ // Remove previous count info
2331
+ const existingCount = filesWrapper.querySelector('.file-count-info');
2332
+ if (existingCount) existingCount.remove();
2333
+
2334
+ countInfo.className += ' file-count-info';
2335
+ filesWrapper.appendChild(countInfo);
2336
+ };
2337
+
2338
+ // Set up drag and drop
2339
+ setupFilesDropHandler(filesContainer, initialFiles, updateFilesDisplay);
2340
+
2341
+ // Set up file picker
2342
+ setupFilesPickerHandler(filesPicker, initialFiles, updateFilesDisplay);
2343
+
2344
+ // Initial display
2345
+ updateFilesDisplay();
2346
+
2347
+ wrapper.appendChild(filesWrapper);
2348
+ }
2349
+ }
2350
+
1432
2351
  function addPrefillFilesToIndex(initialFiles) {
1433
2352
  if (initialFiles.length > 0) {
1434
2353
  initialFiles.forEach((resourceId) => {
@@ -1552,13 +2471,20 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
1552
2471
  const item = document.createElement("div");
1553
2472
  item.className =
1554
2473
  "groupItem border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-3 mb-3 bg-blue-50/30 dark:bg-blue-900/10";
2474
+ const itemIndex = countItems();
2475
+ const fullPath = pathJoin(ctx.path, `${element.key}[${itemIndex}]`);
2476
+ // Add data-group-item attribute for validation scoping - use full path
2477
+ item.setAttribute("data-group-item", fullPath);
1555
2478
  const subCtx = {
1556
- path: pathJoin(ctx.path, `${element.key}[${countItems()}]`),
2479
+ path: fullPath,
1557
2480
  prefill: prefillObj || {},
1558
2481
  };
1559
- element.elements.forEach((child) =>
1560
- item.appendChild(renderElement(child, subCtx)),
1561
- );
2482
+ element.elements.forEach((child) => {
2483
+ // Skip rendering hidden child elements
2484
+ if (!child.hidden) {
2485
+ item.appendChild(renderElement(child, subCtx));
2486
+ }
2487
+ });
1562
2488
 
1563
2489
  // Only add remove button in edit mode
1564
2490
  if (!state.config.readonly) {
@@ -1622,13 +2548,224 @@ function renderSingleGroup(element, ctx, itemsWrap, left, groupWrap) {
1622
2548
  path: pathJoin(ctx.path, element.key),
1623
2549
  prefill: ctx.prefill?.[element.key] || {},
1624
2550
  };
1625
- element.elements.forEach((child) =>
1626
- itemsWrap.appendChild(renderElement(child, subCtx)),
1627
- );
2551
+ element.elements.forEach((child) => {
2552
+ // Skip rendering hidden child elements
2553
+ if (!child.hidden) {
2554
+ itemsWrap.appendChild(renderElement(child, subCtx));
2555
+ }
2556
+ });
1628
2557
  groupWrap.appendChild(itemsWrap);
1629
2558
  left.innerHTML = `<span>${element.label || element.key}</span>`;
1630
2559
  }
1631
2560
 
2561
+ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
2562
+ // Same as renderSingleGroup but with updated naming
2563
+ const containerWrap = document.createElement("div");
2564
+ containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
2565
+ containerWrap.setAttribute("data-container", pathKey);
2566
+
2567
+ const header = document.createElement("div");
2568
+ header.className = "flex justify-between items-center mb-4";
2569
+
2570
+ const left = document.createElement("div");
2571
+ left.className = "flex-1";
2572
+
2573
+ const itemsWrap = document.createElement("div");
2574
+ itemsWrap.className = "space-y-4";
2575
+
2576
+ containerWrap.appendChild(header);
2577
+ header.appendChild(left);
2578
+
2579
+ // Single object container
2580
+ const subCtx = {
2581
+ path: pathJoin(ctx.path, element.key),
2582
+ prefill: ctx.prefill?.[element.key] || {},
2583
+ };
2584
+ element.elements.forEach((child) => {
2585
+ // Skip rendering hidden child elements
2586
+ if (!child.hidden) {
2587
+ itemsWrap.appendChild(renderElement(child, subCtx));
2588
+ }
2589
+ });
2590
+ containerWrap.appendChild(itemsWrap);
2591
+ left.innerHTML = `<span>${element.label || element.key}</span>`;
2592
+
2593
+ wrapper.appendChild(containerWrap);
2594
+ }
2595
+
2596
+ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
2597
+ // Same as renderRepeatableGroup but with minCount/maxCount from element properties
2598
+ const containerWrap = document.createElement("div");
2599
+ containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
2600
+
2601
+ const header = document.createElement("div");
2602
+ header.className = "flex justify-between items-center mb-4";
2603
+
2604
+ const left = document.createElement("div");
2605
+ left.className = "flex-1";
2606
+
2607
+ const right = document.createElement("div");
2608
+ right.className = "flex gap-2";
2609
+
2610
+ const itemsWrap = document.createElement("div");
2611
+ itemsWrap.className = "space-y-4";
2612
+
2613
+ containerWrap.appendChild(header);
2614
+ header.appendChild(left);
2615
+ if (!state.config.readonly) {
2616
+ header.appendChild(right);
2617
+ }
2618
+
2619
+ const min = element.minCount ?? 0;
2620
+ const max = element.maxCount ?? Infinity;
2621
+ const pre = Array.isArray(ctx.prefill?.[element.key])
2622
+ ? ctx.prefill[element.key]
2623
+ : null;
2624
+
2625
+ const countItems = () =>
2626
+ itemsWrap.querySelectorAll(":scope > .containerItem").length;
2627
+
2628
+ const createAddButton = () => {
2629
+ const add = document.createElement("button");
2630
+ add.type = "button";
2631
+ add.className =
2632
+ "px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors";
2633
+ add.textContent = t("addElement");
2634
+ add.onclick = () => {
2635
+ if (countItems() < max) {
2636
+ const idx = countItems();
2637
+ const subCtx = {
2638
+ path: pathJoin(ctx.path, `${element.key}[${idx}]`),
2639
+ prefill: {},
2640
+ };
2641
+ const item = document.createElement("div");
2642
+ item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
2643
+ item.setAttribute("data-container-item", `${element.key}[${idx}]`);
2644
+
2645
+ element.elements.forEach((child) => {
2646
+ // Skip rendering hidden child elements
2647
+ if (!child.hidden) {
2648
+ item.appendChild(renderElement(child, subCtx));
2649
+ }
2650
+ });
2651
+
2652
+ // Only add remove button in edit mode
2653
+ if (!state.config.readonly) {
2654
+ const rem = document.createElement("button");
2655
+ rem.type = "button";
2656
+ rem.className =
2657
+ "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
2658
+ rem.textContent = "×";
2659
+ rem.onclick = () => {
2660
+ item.remove();
2661
+ updateAddButton();
2662
+ };
2663
+ item.style.position = "relative";
2664
+ item.appendChild(rem);
2665
+ }
2666
+
2667
+ itemsWrap.appendChild(item);
2668
+ updateAddButton();
2669
+ }
2670
+ };
2671
+ return add;
2672
+ };
2673
+
2674
+ const updateAddButton = () => {
2675
+ const currentCount = countItems();
2676
+ const addBtn = right.querySelector("button");
2677
+ if (addBtn) {
2678
+ addBtn.disabled = currentCount >= max;
2679
+ addBtn.style.opacity = currentCount >= max ? "0.5" : "1";
2680
+ }
2681
+ left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-sm text-gray-500">(${currentCount}/${max === Infinity ? '∞' : max})</span>`;
2682
+ };
2683
+
2684
+ if (!state.config.readonly) {
2685
+ right.appendChild(createAddButton());
2686
+ }
2687
+
2688
+ // Pre-fill initial items
2689
+ if (pre && Array.isArray(pre)) {
2690
+ pre.forEach((prefillObj, idx) => {
2691
+ const subCtx = {
2692
+ path: pathJoin(ctx.path, `${element.key}[${idx}]`),
2693
+ prefill: prefillObj || {},
2694
+ };
2695
+ const item = document.createElement("div");
2696
+ item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
2697
+ item.setAttribute("data-container-item", `${element.key}[${idx}]`);
2698
+
2699
+ element.elements.forEach((child) => {
2700
+ // Skip rendering hidden child elements
2701
+ if (!child.hidden) {
2702
+ item.appendChild(renderElement(child, subCtx));
2703
+ }
2704
+ });
2705
+
2706
+ // Only add remove button in edit mode
2707
+ if (!state.config.readonly) {
2708
+ const rem = document.createElement("button");
2709
+ rem.type = "button";
2710
+ rem.className =
2711
+ "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
2712
+ rem.textContent = "×";
2713
+ rem.onclick = () => {
2714
+ item.remove();
2715
+ updateAddButton();
2716
+ };
2717
+ item.style.position = "relative";
2718
+ item.appendChild(rem);
2719
+ }
2720
+
2721
+ itemsWrap.appendChild(item);
2722
+ });
2723
+ }
2724
+
2725
+ // Ensure minimum items
2726
+ if (!state.config.readonly) {
2727
+ while (countItems() < min) {
2728
+ const idx = countItems();
2729
+ const subCtx = {
2730
+ path: pathJoin(ctx.path, `${element.key}[${idx}]`),
2731
+ prefill: {},
2732
+ };
2733
+ const item = document.createElement("div");
2734
+ item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
2735
+ item.setAttribute("data-container-item", `${element.key}[${idx}]`);
2736
+
2737
+ element.elements.forEach((child) => {
2738
+ // Skip rendering hidden child elements
2739
+ if (!child.hidden) {
2740
+ item.appendChild(renderElement(child, subCtx));
2741
+ }
2742
+ });
2743
+
2744
+ // Remove button - but disabled if we're at minimum
2745
+ const rem = document.createElement("button");
2746
+ rem.type = "button";
2747
+ rem.className =
2748
+ "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
2749
+ rem.textContent = "×";
2750
+ rem.onclick = () => {
2751
+ if (countItems() > min) {
2752
+ item.remove();
2753
+ updateAddButton();
2754
+ }
2755
+ };
2756
+ item.style.position = "relative";
2757
+ item.appendChild(rem);
2758
+
2759
+ itemsWrap.appendChild(item);
2760
+ }
2761
+ }
2762
+
2763
+ containerWrap.appendChild(itemsWrap);
2764
+ updateAddButton();
2765
+
2766
+ wrapper.appendChild(containerWrap);
2767
+ }
2768
+
1632
2769
  // Common file preview rendering function for readonly mode
1633
2770
  function renderFilePreviewReadonly(resourceId, fileName) {
1634
2771
  const meta = state.resourceIndex.get(resourceId);
@@ -1817,6 +2954,10 @@ function setThumbnailHandler(thumbnailFn) {
1817
2954
  state.config.getThumbnail = thumbnailFn;
1818
2955
  }
1819
2956
 
2957
+ function setActionHandler(actionFn) {
2958
+ state.config.actionHandler = actionFn;
2959
+ }
2960
+
1820
2961
  function setMode(mode) {
1821
2962
  state.config.readonly = mode === "readonly";
1822
2963
  }
@@ -1879,6 +3020,7 @@ const formBuilderAPI = {
1879
3020
  setUploadHandler,
1880
3021
  setDownloadHandler,
1881
3022
  setThumbnailHandler,
3023
+ setActionHandler,
1882
3024
  setMode,
1883
3025
  setLocale,
1884
3026
  getFormData,
@@ -1903,6 +3045,7 @@ export {
1903
3045
  setUploadHandler,
1904
3046
  setDownloadHandler,
1905
3047
  setThumbnailHandler,
3048
+ setActionHandler,
1906
3049
  setMode,
1907
3050
  setLocale,
1908
3051
  getFormData,