@dmitryvim/form-builder 0.1.31 → 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.
@@ -148,6 +148,10 @@ function renderForm(schema, prefill) {
148
148
  formEl.className = "space-y-6";
149
149
 
150
150
  schema.elements.forEach((element, _index) => {
151
+ // Skip rendering hidden elements
152
+ if (element.hidden) {
153
+ return;
154
+ }
151
155
  const block = renderElement(element, {
152
156
  path: "",
153
157
  prefill: prefill || {},
@@ -209,23 +213,44 @@ function renderElement(element, ctx) {
209
213
 
210
214
  switch (element.type) {
211
215
  case "text":
212
- renderTextElement(element, ctx, wrapper, pathKey);
216
+ if (element.multiple) {
217
+ renderMultipleTextElement(element, ctx, wrapper, pathKey);
218
+ } else {
219
+ renderTextElement(element, ctx, wrapper, pathKey);
220
+ }
213
221
  break;
214
222
 
215
223
  case "textarea":
216
- renderTextareaElement(element, ctx, wrapper, pathKey);
224
+ if (element.multiple) {
225
+ renderMultipleTextareaElement(element, ctx, wrapper, pathKey);
226
+ } else {
227
+ renderTextareaElement(element, ctx, wrapper, pathKey);
228
+ }
217
229
  break;
218
230
 
219
231
  case "number":
220
- renderNumberElement(element, ctx, wrapper, pathKey);
232
+ if (element.multiple) {
233
+ renderMultipleNumberElement(element, ctx, wrapper, pathKey);
234
+ } else {
235
+ renderNumberElement(element, ctx, wrapper, pathKey);
236
+ }
221
237
  break;
222
238
 
223
239
  case "select":
224
- renderSelectElement(element, ctx, wrapper, pathKey);
240
+ if (element.multiple) {
241
+ renderMultipleSelectElement(element, ctx, wrapper, pathKey);
242
+ } else {
243
+ renderSelectElement(element, ctx, wrapper, pathKey);
244
+ }
225
245
  break;
226
246
 
227
247
  case "file":
228
- 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
+ }
229
254
  break;
230
255
 
231
256
  case "files":
@@ -236,6 +261,15 @@ function renderElement(element, ctx) {
236
261
  renderGroupElement(element, ctx, wrapper, pathKey);
237
262
  break;
238
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
+
239
273
  default: {
240
274
  const unsupported = document.createElement("div");
241
275
  unsupported.className = "text-red-500 text-sm";
@@ -580,8 +614,15 @@ function renderResourcePills(container, rids, onRemove) {
580
614
 
581
615
  // Add click handler to each slot
582
616
  slot.onclick = () => {
583
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
584
- 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
+ }
585
626
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
586
627
  if (fileInput) fileInput.click();
587
628
  };
@@ -598,8 +639,15 @@ function renderResourcePills(container, rids, onRemove) {
598
639
  uploadLink.textContent = t("uploadText");
599
640
  uploadLink.onclick = (e) => {
600
641
  e.stopPropagation();
601
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
602
- 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
+ }
603
651
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
604
652
  if (fileInput) fileInput.click();
605
653
  };
@@ -758,8 +806,15 @@ function renderResourcePills(container, rids, onRemove) {
758
806
  slot.innerHTML =
759
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>';
760
808
  slot.onclick = () => {
761
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
762
- 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
+ }
763
818
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
764
819
  if (fileInput) fileInput.click();
765
820
  };
@@ -979,94 +1034,277 @@ function validateForm(skipValidation = false) {
979
1034
  switch (element.type) {
980
1035
  case "text":
981
1036
  case "textarea": {
982
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
983
- const val = input?.value ?? "";
984
- if (!skipValidation && element.required && val === "") {
985
- errors.push(`${key}: required`);
986
- markValidity(input, "required");
987
- return "";
988
- }
989
- if (!skipValidation && val) {
990
- if (element.minLength !== null && val.length < element.minLength) {
991
- errors.push(`${key}: minLength=${element.minLength}`);
992
- 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
+ }
993
1087
  }
994
- if (element.maxLength !== null && val.length > element.maxLength) {
995
- errors.push(`${key}: maxLength=${element.maxLength}`);
996
- 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 "";
997
1098
  }
998
- if (element.pattern) {
999
- try {
1000
- const re = new RegExp(element.pattern);
1001
- if (!re.test(val)) {
1002
- errors.push(`${key}: pattern mismatch`);
1003
- 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");
1004
1118
  }
1005
- } catch {
1006
- errors.push(`${key}: invalid pattern`);
1007
- markValidity(input, "invalid pattern");
1008
1119
  }
1120
+ } else if (skipValidation) {
1121
+ markValidity(input, null);
1122
+ } else {
1123
+ markValidity(input, null);
1009
1124
  }
1010
- } else if (skipValidation) {
1011
- markValidity(input, null);
1012
- } else {
1013
- markValidity(input, null);
1125
+ return val;
1014
1126
  }
1015
- return val;
1016
1127
  }
1017
1128
  case "number": {
1018
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1019
- const raw = input?.value ?? "";
1020
- if (!skipValidation && element.required && raw === "") {
1021
- errors.push(`${key}: required`);
1022
- markValidity(input, "required");
1023
- return null;
1024
- }
1025
- 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;
1026
1212
  markValidity(input, null);
1027
- return null;
1028
- }
1029
- const v = parseFloat(raw);
1030
- if (!skipValidation && !Number.isFinite(v)) {
1031
- errors.push(`${key}: not a number`);
1032
- markValidity(input, "not a number");
1033
- return null;
1034
- }
1035
- if (!skipValidation && element.min !== null && v < element.min) {
1036
- errors.push(`${key}: < min=${element.min}`);
1037
- markValidity(input, `< min=${element.min}`);
1213
+ return Number(v.toFixed(d));
1038
1214
  }
1039
- if (!skipValidation && element.max !== null && v > element.max) {
1040
- errors.push(`${key}: > max=${element.max}`);
1041
- markValidity(input, `> max=${element.max}`);
1042
- }
1043
- const d = Number.isInteger(element.decimals ?? 0)
1044
- ? element.decimals
1045
- : 0;
1046
- markValidity(input, null);
1047
- return Number(v.toFixed(d));
1048
1215
  }
1049
1216
  case "select": {
1050
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1051
- const val = input?.value ?? "";
1052
- if (!skipValidation && element.required && val === "") {
1053
- errors.push(`${key}: required`);
1054
- markValidity(input, "required");
1055
- 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;
1056
1257
  }
1057
- markValidity(input, null);
1058
- return val;
1059
1258
  }
1060
1259
  case "file": {
1061
- const input = scopeRoot.querySelector(
1062
- `input[name$="${key}"][type="hidden"]`,
1063
- );
1064
- const rid = input?.value ?? "";
1065
- if (!skipValidation && element.required && rid === "") {
1066
- errors.push(`${key}: required`);
1067
- 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;
1068
1307
  }
1069
- return rid || null;
1070
1308
  }
1071
1309
  case "files": {
1072
1310
  // For files, we need to collect all resource IDs
@@ -1110,6 +1348,69 @@ function validateForm(skipValidation = false) {
1110
1348
  }
1111
1349
  case "group": {
1112
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
1113
1414
  const items = [];
1114
1415
  const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1115
1416
  const itemCount = Math.max(
@@ -1119,35 +1420,62 @@ function validateForm(skipValidation = false) {
1119
1420
 
1120
1421
  for (let i = 0; i < itemCount; i++) {
1121
1422
  const itemData = {};
1122
- // Find the specific group item container for scoped queries
1423
+ // Find the specific container item container for scoped queries
1123
1424
  const itemContainer =
1124
- scopeRoot.querySelector(`[data-group-item="${key}[${i}]"]`) ||
1425
+ scopeRoot.querySelector(`[data-container-item="${key}[${i}]"]`) ||
1125
1426
  scopeRoot;
1126
1427
  element.elements.forEach((child) => {
1127
- const childKey = `${key}[${i}].${child.key}`;
1128
- itemData[child.key] = validateElement(
1129
- { ...child, key: childKey },
1130
- ctx,
1131
- itemContainer,
1132
- );
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
+ }
1133
1439
  });
1134
1440
  items.push(itemData);
1135
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
+
1136
1459
  return items;
1137
1460
  } else {
1138
- const groupData = {};
1139
- // Find the specific group container for scoped queries
1140
- const groupContainer =
1141
- 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;
1142
1465
  element.elements.forEach((child) => {
1143
- const childKey = `${key}.${child.key}`;
1144
- groupData[child.key] = validateElement(
1145
- { ...child, key: childKey },
1146
- ctx,
1147
- groupContainer,
1148
- );
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
+ }
1149
1477
  });
1150
- return groupData;
1478
+ return containerData;
1151
1479
  }
1152
1480
  }
1153
1481
  default:
@@ -1156,7 +1484,12 @@ function validateForm(skipValidation = false) {
1156
1484
  }
1157
1485
 
1158
1486
  state.schema.elements.forEach((element) => {
1159
- 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
+ }
1160
1493
  });
1161
1494
 
1162
1495
  return {
@@ -1185,6 +1518,118 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
1185
1518
  wrapper.appendChild(textHint);
1186
1519
  }
1187
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
+
1188
1633
  function renderTextareaElement(element, ctx, wrapper, pathKey) {
1189
1634
  const textareaInput = document.createElement("textarea");
1190
1635
  textareaInput.className =
@@ -1203,6 +1648,118 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1203
1648
  wrapper.appendChild(textareaHint);
1204
1649
  }
1205
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
+
1206
1763
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1207
1764
  const numberInput = document.createElement("input");
1208
1765
  numberInput.type = "number";
@@ -1224,6 +1781,121 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1224
1781
  wrapper.appendChild(numberHint);
1225
1782
  }
1226
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
+
1227
1899
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1228
1900
  const selectInput = document.createElement("select");
1229
1901
  selectInput.className =
@@ -1250,6 +1922,127 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1250
1922
  wrapper.appendChild(selectHint);
1251
1923
  }
1252
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
+
1253
2046
  function renderFileElement(element, ctx, wrapper, pathKey) {
1254
2047
  if (state.config.readonly) {
1255
2048
  // Readonly mode: use common preview function
@@ -1468,6 +2261,93 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
1468
2261
  }
1469
2262
  }
1470
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
+
1471
2351
  function addPrefillFilesToIndex(initialFiles) {
1472
2352
  if (initialFiles.length > 0) {
1473
2353
  initialFiles.forEach((resourceId) => {
@@ -1591,13 +2471,20 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
1591
2471
  const item = document.createElement("div");
1592
2472
  item.className =
1593
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);
1594
2478
  const subCtx = {
1595
- path: pathJoin(ctx.path, `${element.key}[${countItems()}]`),
2479
+ path: fullPath,
1596
2480
  prefill: prefillObj || {},
1597
2481
  };
1598
- element.elements.forEach((child) =>
1599
- item.appendChild(renderElement(child, subCtx)),
1600
- );
2482
+ element.elements.forEach((child) => {
2483
+ // Skip rendering hidden child elements
2484
+ if (!child.hidden) {
2485
+ item.appendChild(renderElement(child, subCtx));
2486
+ }
2487
+ });
1601
2488
 
1602
2489
  // Only add remove button in edit mode
1603
2490
  if (!state.config.readonly) {
@@ -1661,13 +2548,224 @@ function renderSingleGroup(element, ctx, itemsWrap, left, groupWrap) {
1661
2548
  path: pathJoin(ctx.path, element.key),
1662
2549
  prefill: ctx.prefill?.[element.key] || {},
1663
2550
  };
1664
- element.elements.forEach((child) =>
1665
- itemsWrap.appendChild(renderElement(child, subCtx)),
1666
- );
2551
+ element.elements.forEach((child) => {
2552
+ // Skip rendering hidden child elements
2553
+ if (!child.hidden) {
2554
+ itemsWrap.appendChild(renderElement(child, subCtx));
2555
+ }
2556
+ });
1667
2557
  groupWrap.appendChild(itemsWrap);
1668
2558
  left.innerHTML = `<span>${element.label || element.key}</span>`;
1669
2559
  }
1670
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
+
1671
2769
  // Common file preview rendering function for readonly mode
1672
2770
  function renderFilePreviewReadonly(resourceId, fileName) {
1673
2771
  const meta = state.resourceIndex.get(resourceId);