@dmitryvim/form-builder 0.1.31 → 0.1.34

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.
@@ -1,4 +1,56 @@
1
1
  // Form Builder Library - Core API
2
+
3
+ // Object URL management for memory leak prevention
4
+ const objectUrlIndex = new Map();
5
+
6
+ function createObjectURL(file, resourceIdParam) {
7
+ // Revoke previous URL for this resource to prevent memory leaks
8
+ revokeObjectURL(resourceIdParam);
9
+
10
+ const url = URL.createObjectURL(file);
11
+ objectUrlIndex.set(resourceIdParam, url);
12
+ return url;
13
+ }
14
+
15
+ function revokeObjectURL(resourceIdParam) {
16
+ const url = objectUrlIndex.get(resourceIdParam);
17
+ if (url) {
18
+ URL.revokeObjectURL(url);
19
+ objectUrlIndex.delete(resourceIdParam);
20
+ }
21
+ }
22
+
23
+ function revokeAllObjectURLs() {
24
+ for (const [, url] of objectUrlIndex.entries()) {
25
+ URL.revokeObjectURL(url);
26
+ }
27
+ objectUrlIndex.clear();
28
+ }
29
+
30
+ // Helper function to create safe preview elements
31
+ function createPreviewElement(icon, fileName) {
32
+ const wrapper = document.createElement("div");
33
+ wrapper.className =
34
+ "aspect-video flex items-center justify-center text-gray-400";
35
+
36
+ const textCenter = document.createElement("div");
37
+ textCenter.className = "text-center";
38
+
39
+ const iconEl = document.createElement("div");
40
+ iconEl.className = "text-4xl mb-2";
41
+ iconEl.textContent = icon;
42
+
43
+ const nameEl = document.createElement("div");
44
+ nameEl.className = "text-sm";
45
+ nameEl.textContent = fileName || "";
46
+
47
+ textCenter.appendChild(iconEl);
48
+ textCenter.appendChild(nameEl);
49
+ wrapper.appendChild(textCenter);
50
+
51
+ return wrapper;
52
+ }
53
+
2
54
  // State management
3
55
  const state = {
4
56
  schema: null,
@@ -142,12 +194,18 @@ function renderForm(schema, prefill) {
142
194
  return;
143
195
  }
144
196
 
197
+ // Clean up any existing object URLs before clearing form
198
+ revokeAllObjectURLs();
145
199
  clear(state.formRoot);
146
200
 
147
201
  const formEl = document.createElement("div");
148
202
  formEl.className = "space-y-6";
149
203
 
150
204
  schema.elements.forEach((element, _index) => {
205
+ // Skip rendering hidden elements
206
+ if (element.hidden) {
207
+ return;
208
+ }
151
209
  const block = renderElement(element, {
152
210
  path: "",
153
211
  prefill: prefill || {},
@@ -180,8 +238,17 @@ function renderElement(element, ctx) {
180
238
  const infoBtn = document.createElement("button");
181
239
  infoBtn.type = "button";
182
240
  infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
183
- infoBtn.innerHTML =
184
- '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
241
+ // Create SVG icon safely
242
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
243
+ svg.setAttribute("class", "w-4 h-4");
244
+ svg.setAttribute("fill", "currentColor");
245
+ svg.setAttribute("viewBox", "0 0 24 24");
246
+
247
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
248
+ path.setAttribute("d", "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z");
249
+
250
+ svg.appendChild(path);
251
+ infoBtn.appendChild(svg);
185
252
 
186
253
  // Create tooltip
187
254
  const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
@@ -209,23 +276,44 @@ function renderElement(element, ctx) {
209
276
 
210
277
  switch (element.type) {
211
278
  case "text":
212
- renderTextElement(element, ctx, wrapper, pathKey);
279
+ if (element.multiple) {
280
+ renderMultipleTextElement(element, ctx, wrapper, pathKey);
281
+ } else {
282
+ renderTextElement(element, ctx, wrapper, pathKey);
283
+ }
213
284
  break;
214
285
 
215
286
  case "textarea":
216
- renderTextareaElement(element, ctx, wrapper, pathKey);
287
+ if (element.multiple) {
288
+ renderMultipleTextareaElement(element, ctx, wrapper, pathKey);
289
+ } else {
290
+ renderTextareaElement(element, ctx, wrapper, pathKey);
291
+ }
217
292
  break;
218
293
 
219
294
  case "number":
220
- renderNumberElement(element, ctx, wrapper, pathKey);
295
+ if (element.multiple) {
296
+ renderMultipleNumberElement(element, ctx, wrapper, pathKey);
297
+ } else {
298
+ renderNumberElement(element, ctx, wrapper, pathKey);
299
+ }
221
300
  break;
222
301
 
223
302
  case "select":
224
- renderSelectElement(element, ctx, wrapper, pathKey);
303
+ if (element.multiple) {
304
+ renderMultipleSelectElement(element, ctx, wrapper, pathKey);
305
+ } else {
306
+ renderSelectElement(element, ctx, wrapper, pathKey);
307
+ }
225
308
  break;
226
309
 
227
310
  case "file":
228
- renderFileElement(element, ctx, wrapper, pathKey);
311
+ // Handle multiple files with file type using multiple property
312
+ if (element.multiple) {
313
+ renderMultipleFileElement(element, ctx, wrapper, pathKey);
314
+ } else {
315
+ renderFileElement(element, ctx, wrapper, pathKey);
316
+ }
229
317
  break;
230
318
 
231
319
  case "files":
@@ -236,6 +324,15 @@ function renderElement(element, ctx) {
236
324
  renderGroupElement(element, ctx, wrapper, pathKey);
237
325
  break;
238
326
 
327
+ case "container":
328
+ // Handle containers with multiple property like groups
329
+ if (element.multiple) {
330
+ renderMultipleContainerElement(element, ctx, wrapper, pathKey);
331
+ } else {
332
+ renderSingleContainerElement(element, ctx, wrapper, pathKey);
333
+ }
334
+ break;
335
+
239
336
  default: {
240
337
  const unsupported = document.createElement("div");
241
338
  unsupported.className = "text-red-500 text-sm";
@@ -382,8 +479,8 @@ async function renderFilePreview(container, resourceId, options = {}) {
382
479
  reader.readAsDataURL(meta.file);
383
480
  container.appendChild(img);
384
481
  } else if (meta.type && meta.type.startsWith("video/")) {
385
- // Video file - use object URL for preview
386
- const videoUrl = URL.createObjectURL(meta.file);
482
+ // Video file - use managed object URL for preview
483
+ const videoUrl = createObjectURL(meta.file, resourceId);
387
484
 
388
485
  // Remove all conflicting handlers to prevent interference with video controls
389
486
  container.onclick = null;
@@ -393,39 +490,55 @@ async function renderFilePreview(container, resourceId, options = {}) {
393
490
  container.parentNode.replaceChild(newContainer, container);
394
491
  container = newContainer;
395
492
 
396
- container.innerHTML = `
397
- <div class="relative group h-full">
398
- <video class="w-full h-full object-contain" controls preload="auto" muted>
399
- <source src="${videoUrl}" type="${meta.type}">
400
- Your browser does not support the video tag.
401
- </video>
402
- <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1">
403
- <button class="bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn">
404
- ${t("removeElement")}
405
- </button>
406
- <button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
407
- Change
408
- </button>
409
- </div>
410
- </div>
411
- `;
493
+ // Create video container safely
494
+ clear(container);
495
+
496
+ const wrapper = document.createElement("div");
497
+ wrapper.className = "relative group h-full";
498
+
499
+ const video = document.createElement("video");
500
+ video.className = "w-full h-full object-contain";
501
+ video.controls = true;
502
+ video.preload = "auto";
503
+ video.muted = true;
504
+
505
+ const source = document.createElement("source");
506
+ source.src = videoUrl;
507
+ source.type = meta.type;
508
+
509
+ const fallback = document.createTextNode("Your browser does not support the video tag.");
510
+ video.appendChild(source);
511
+ video.appendChild(fallback);
512
+
513
+ const buttonsContainer = document.createElement("div");
514
+ buttonsContainer.className = "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1";
515
+
516
+ const deleteBtn = document.createElement("button");
517
+ deleteBtn.className = "bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn";
518
+ deleteBtn.textContent = t("removeElement");
519
+
520
+ const changeBtn = document.createElement("button");
521
+ changeBtn.className = "bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn";
522
+ changeBtn.textContent = "Change";
523
+
524
+ buttonsContainer.appendChild(deleteBtn);
525
+ buttonsContainer.appendChild(changeBtn);
526
+ wrapper.appendChild(video);
527
+ wrapper.appendChild(buttonsContainer);
528
+ container.appendChild(wrapper);
412
529
 
413
530
  // Add click handlers to the custom buttons
414
- const changeBtn = container.querySelector(".change-file-btn");
415
- if (changeBtn) {
416
- changeBtn.onclick = (e) => {
417
- e.stopPropagation();
418
- if (deps?.picker) {
419
- deps.picker.click();
420
- }
421
- };
422
- }
531
+ changeBtn.onclick = (e) => {
532
+ e.stopPropagation();
533
+ if (deps?.picker) {
534
+ deps.picker.click();
535
+ }
536
+ };
423
537
 
424
- const deleteBtn = container.querySelector(".delete-file-btn");
425
- if (deleteBtn) {
426
- deleteBtn.onclick = (e) => {
538
+ deleteBtn.onclick = (e) => {
427
539
  e.stopPropagation();
428
- // Clear the file
540
+ // Clear the file and revoke object URL
541
+ revokeObjectURL(resourceId);
429
542
  state.resourceIndex.delete(resourceId);
430
543
  // Update hidden input
431
544
  const hiddenInput = container.parentElement.querySelector(
@@ -441,27 +554,55 @@ async function renderFilePreview(container, resourceId, options = {}) {
441
554
  if (deps?.dragHandler) {
442
555
  setupDragAndDrop(container, deps.dragHandler);
443
556
  }
444
- container.innerHTML = `
445
- <div class="flex flex-col items-center justify-center h-full text-gray-400">
446
- <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
447
- <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"/>
448
- </svg>
449
- <div class="text-sm text-center">${t("clickDragText")}</div>
450
- </div>
451
- `;
557
+ // Create placeholder content safely
558
+ clear(container);
559
+
560
+ const wrapper = document.createElement("div");
561
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
562
+
563
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
564
+ svg.setAttribute("class", "w-6 h-6 mb-2");
565
+ svg.setAttribute("fill", "currentColor");
566
+ svg.setAttribute("viewBox", "0 0 24 24");
567
+
568
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
569
+ path.setAttribute("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");
570
+
571
+ svg.appendChild(path);
572
+
573
+ const textDiv = document.createElement("div");
574
+ textDiv.className = "text-sm text-center";
575
+ textDiv.textContent = t("clickDragText");
576
+
577
+ wrapper.appendChild(svg);
578
+ wrapper.appendChild(textDiv);
579
+ container.appendChild(wrapper);
452
580
  };
453
- }
454
581
  } else {
455
- // Non-image, non-video file
456
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">${
457
- fileName
458
- }</div></div>`;
582
+ // Non-image, non-video file - create elements safely
583
+ const wrapper = document.createElement("div");
584
+ wrapper.className =
585
+ "flex flex-col items-center justify-center h-full text-gray-400";
586
+
587
+ const icon = document.createElement("div");
588
+ icon.className = "text-2xl mb-2";
589
+ icon.textContent = "📁";
590
+
591
+ const nameEl = document.createElement("div");
592
+ nameEl.className = "text-sm";
593
+ nameEl.textContent = fileName || "";
594
+
595
+ wrapper.appendChild(icon);
596
+ wrapper.appendChild(nameEl);
597
+ clear(container);
598
+ container.appendChild(wrapper);
459
599
  }
460
600
 
461
601
  // Add delete button for edit mode (except for videos which have custom buttons)
462
602
  if (!isReadonly && !(meta && meta.type && meta.type.startsWith("video/"))) {
463
603
  addDeleteButton(container, () => {
464
- // Clear the file
604
+ // Clear the file and revoke object URL
605
+ revokeObjectURL(resourceId);
465
606
  state.resourceIndex.delete(resourceId);
466
607
  // Update hidden input
467
608
  const hiddenInput = container.parentElement.querySelector(
@@ -470,15 +611,29 @@ async function renderFilePreview(container, resourceId, options = {}) {
470
611
  if (hiddenInput) {
471
612
  hiddenInput.value = "";
472
613
  }
473
- // Clear preview and show placeholder
474
- container.innerHTML = `
475
- <div class="flex flex-col items-center justify-center h-full text-gray-400">
476
- <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
477
- <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"/>
478
- </svg>
479
- <div class="text-sm text-center">${t("clickDragText")}</div>
480
- </div>
481
- `;
614
+ // Clear preview and show placeholder safely
615
+ clear(container);
616
+
617
+ const wrapper = document.createElement("div");
618
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
619
+
620
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
621
+ svg.setAttribute("class", "w-6 h-6 mb-2");
622
+ svg.setAttribute("fill", "currentColor");
623
+ svg.setAttribute("viewBox", "0 0 24 24");
624
+
625
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
626
+ path.setAttribute("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");
627
+
628
+ svg.appendChild(path);
629
+
630
+ const textDiv = document.createElement("div");
631
+ textDiv.className = "text-sm text-center";
632
+ textDiv.textContent = t("clickDragText");
633
+
634
+ wrapper.appendChild(svg);
635
+ wrapper.appendChild(textDiv);
636
+ container.appendChild(wrapper);
482
637
  });
483
638
  }
484
639
  } else if (state.config.getThumbnail) {
@@ -491,21 +646,50 @@ async function renderFilePreview(container, resourceId, options = {}) {
491
646
  container.appendChild(img);
492
647
  } else {
493
648
  // Fallback to file icon
494
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">${
495
- fileName
496
- }</div></div>`;
649
+ // Create elements safely
650
+ const wrapper = document.createElement("div");
651
+ wrapper.className =
652
+ "flex flex-col items-center justify-center h-full text-gray-400";
653
+
654
+ const icon = document.createElement("div");
655
+ icon.className = "text-2xl mb-2";
656
+ icon.textContent = "🖼️";
657
+
658
+ const nameEl = document.createElement("div");
659
+ nameEl.className = "text-sm";
660
+ nameEl.textContent = fileName || "";
661
+
662
+ wrapper.appendChild(icon);
663
+ wrapper.appendChild(nameEl);
664
+ clear(container);
665
+ container.appendChild(wrapper);
497
666
  }
498
667
  } catch (error) {
499
668
  console.warn("Thumbnail loading failed:", error);
500
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">${
501
- fileName
502
- }</div></div>`;
669
+ // Create elements safely
670
+ const wrapper = document.createElement("div");
671
+ wrapper.className =
672
+ "flex flex-col items-center justify-center h-full text-gray-400";
673
+
674
+ const icon = document.createElement("div");
675
+ icon.className = "text-2xl mb-2";
676
+ icon.textContent = "📁";
677
+
678
+ const nameEl = document.createElement("div");
679
+ nameEl.className = "text-sm";
680
+ nameEl.textContent = fileName || "";
681
+
682
+ wrapper.appendChild(icon);
683
+ wrapper.appendChild(nameEl);
684
+ clear(container);
685
+ container.appendChild(wrapper);
503
686
  }
504
687
 
505
688
  // Add delete button for edit mode
506
689
  if (!isReadonly) {
507
690
  addDeleteButton(container, () => {
508
- // Clear the file
691
+ // Clear the file and revoke object URL
692
+ revokeObjectURL(resourceId);
509
693
  state.resourceIndex.delete(resourceId);
510
694
  // Update hidden input
511
695
  const hiddenInput = container.parentElement.querySelector(
@@ -514,22 +698,35 @@ async function renderFilePreview(container, resourceId, options = {}) {
514
698
  if (hiddenInput) {
515
699
  hiddenInput.value = "";
516
700
  }
517
- // Clear preview and show placeholder
518
- container.innerHTML = `
519
- <div class="flex flex-col items-center justify-center h-full text-gray-400">
520
- <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
521
- <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"/>
522
- </svg>
523
- <div class="text-sm text-center">${t("clickDragText")}</div>
524
- </div>
525
- `;
701
+ // Clear preview and show placeholder safely
702
+ clear(container);
703
+
704
+ const wrapper = document.createElement("div");
705
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
706
+
707
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
708
+ svg.setAttribute("class", "w-6 h-6 mb-2");
709
+ svg.setAttribute("fill", "currentColor");
710
+ svg.setAttribute("viewBox", "0 0 24 24");
711
+
712
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
713
+ path.setAttribute("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");
714
+
715
+ svg.appendChild(path);
716
+
717
+ const textDiv = document.createElement("div");
718
+ textDiv.className = "text-sm text-center";
719
+ textDiv.textContent = t("clickDragText");
720
+
721
+ wrapper.appendChild(svg);
722
+ wrapper.appendChild(textDiv);
723
+ container.appendChild(wrapper);
526
724
  });
527
725
  }
528
726
  } else {
529
- // No file and no getThumbnail config - fallback
530
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">${
531
- fileName
532
- }</div></div>`;
727
+ // No file and no getThumbnail config - fallback - create elements safely
728
+ clear(container);
729
+ container.appendChild(createPreviewElement("🖼️", fileName));
533
730
  }
534
731
 
535
732
  // Add click handler for download in readonly mode
@@ -580,8 +777,15 @@ function renderResourcePills(container, rids, onRemove) {
580
777
 
581
778
  // Add click handler to each slot
582
779
  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");
780
+ // Look for file input - check parent containers that have space-y-2 class
781
+ let filesWrapper = container.parentElement;
782
+ while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
783
+ filesWrapper = filesWrapper.parentElement;
784
+ }
785
+ // If no parent with space-y-2, container itself might be the wrapper
786
+ if (!filesWrapper && container.classList.contains("space-y-2")) {
787
+ filesWrapper = container;
788
+ }
585
789
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
586
790
  if (fileInput) fileInput.click();
587
791
  };
@@ -598,8 +802,15 @@ function renderResourcePills(container, rids, onRemove) {
598
802
  uploadLink.textContent = t("uploadText");
599
803
  uploadLink.onclick = (e) => {
600
804
  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");
805
+ // Look for file input - check parent containers that have space-y-2 class
806
+ let filesWrapper = container.parentElement;
807
+ while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
808
+ filesWrapper = filesWrapper.parentElement;
809
+ }
810
+ // If no parent with space-y-2, container itself might be the wrapper
811
+ if (!filesWrapper && container.classList.contains("space-y-2")) {
812
+ filesWrapper = container;
813
+ }
603
814
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
604
815
  if (fileInput) fileInput.click();
605
816
  };
@@ -662,76 +873,179 @@ function renderResourcePills(container, rids, onRemove) {
662
873
  img.src = url;
663
874
  slot.appendChild(img);
664
875
  } else {
665
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
666
- <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
667
- <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"/>
668
- </svg>
669
- </div>`;
876
+ // Create fallback placeholder safely
877
+ const wrapper = document.createElement("div");
878
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
879
+
880
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
881
+ svg.setAttribute("class", "w-12 h-12");
882
+ svg.setAttribute("fill", "currentColor");
883
+ svg.setAttribute("viewBox", "0 0 24 24");
884
+
885
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
886
+ path.setAttribute("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");
887
+
888
+ svg.appendChild(path);
889
+ wrapper.appendChild(svg);
890
+ slot.appendChild(wrapper);
670
891
  }
671
892
  } else {
672
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
673
- <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
674
- <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"/>
675
- </svg>
676
- </div>`;
893
+ // Create fallback placeholder safely
894
+ const wrapper = document.createElement("div");
895
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
896
+
897
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
898
+ svg.setAttribute("class", "w-12 h-12");
899
+ svg.setAttribute("fill", "currentColor");
900
+ svg.setAttribute("viewBox", "0 0 24 24");
901
+
902
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
903
+ path.setAttribute("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");
904
+
905
+ svg.appendChild(path);
906
+ wrapper.appendChild(svg);
907
+ slot.appendChild(wrapper);
677
908
  }
678
909
  } else if (meta && meta.type?.startsWith("video/")) {
679
910
  if (meta.file && meta.file instanceof File) {
680
- // Video file - use object URL for preview in thumbnail format
681
- const videoUrl = URL.createObjectURL(meta.file);
682
- slot.innerHTML = `
683
- <div class="relative group h-full w-full">
684
- <video class="w-full h-full object-contain" preload="metadata" muted>
685
- <source src="${videoUrl}" type="${meta.type}">
686
- </video>
687
- <div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
688
- <div class="bg-white bg-opacity-90 rounded-full p-1">
689
- <svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
690
- <path d="M8 5v14l11-7z"/>
691
- </svg>
692
- </div>
693
- </div>
694
- </div>
695
- `;
911
+ // Video file - use managed object URL for preview in thumbnail format
912
+ const videoUrl = createObjectURL(meta.file, rid);
913
+ // Create video thumbnail safely
914
+ const wrapper = document.createElement("div");
915
+ wrapper.className = "relative group h-full w-full";
916
+
917
+ const video = document.createElement("video");
918
+ video.className = "w-full h-full object-contain";
919
+ video.preload = "metadata";
920
+ video.muted = true;
921
+
922
+ const source = document.createElement("source");
923
+ source.src = videoUrl;
924
+ source.type = meta.type;
925
+
926
+ video.appendChild(source);
927
+
928
+ const overlay = document.createElement("div");
929
+ overlay.className = "absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center";
930
+
931
+ const playButton = document.createElement("div");
932
+ playButton.className = "bg-white bg-opacity-90 rounded-full p-1";
933
+
934
+ const playIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
935
+ playIcon.setAttribute("class", "w-4 h-4 text-gray-800");
936
+ playIcon.setAttribute("fill", "currentColor");
937
+ playIcon.setAttribute("viewBox", "0 0 24 24");
938
+
939
+ const playPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
940
+ playPath.setAttribute("d", "M8 5v14l11-7z");
941
+
942
+ playIcon.appendChild(playPath);
943
+ playButton.appendChild(playIcon);
944
+ overlay.appendChild(playButton);
945
+ wrapper.appendChild(video);
946
+ wrapper.appendChild(overlay);
947
+ slot.appendChild(wrapper);
696
948
  } else if (state.config.getThumbnail) {
697
949
  // Use getThumbnail for uploaded video files
698
950
  const videoUrl = state.config.getThumbnail(rid);
699
951
  if (videoUrl) {
700
- slot.innerHTML = `
701
- <div class="relative group h-full w-full">
702
- <video class="w-full h-full object-contain" preload="metadata" muted>
703
- <source src="${videoUrl}" type="${meta.type}">
704
- </video>
705
- <div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
706
- <div class="bg-white bg-opacity-90 rounded-full p-1">
707
- <svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
708
- <path d="M8 5v14l11-7z"/>
709
- </svg>
710
- </div>
711
- </div>
712
- </div>
713
- `;
952
+ // Create video thumbnail safely
953
+ const wrapper = document.createElement("div");
954
+ wrapper.className = "relative group h-full w-full";
955
+
956
+ const video = document.createElement("video");
957
+ video.className = "w-full h-full object-contain";
958
+ video.preload = "metadata";
959
+ video.muted = true;
960
+
961
+ const source = document.createElement("source");
962
+ source.src = videoUrl;
963
+ source.type = meta.type;
964
+
965
+ video.appendChild(source);
966
+
967
+ const overlay = document.createElement("div");
968
+ overlay.className = "absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center";
969
+
970
+ const playButton = document.createElement("div");
971
+ playButton.className = "bg-white bg-opacity-90 rounded-full p-1";
972
+
973
+ const playIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
974
+ playIcon.setAttribute("class", "w-4 h-4 text-gray-800");
975
+ playIcon.setAttribute("fill", "currentColor");
976
+ playIcon.setAttribute("viewBox", "0 0 24 24");
977
+
978
+ const playPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
979
+ playPath.setAttribute("d", "M8 5v14l11-7z");
980
+
981
+ playIcon.appendChild(playPath);
982
+ playButton.appendChild(playIcon);
983
+ overlay.appendChild(playButton);
984
+ wrapper.appendChild(video);
985
+ wrapper.appendChild(overlay);
986
+ slot.appendChild(wrapper);
714
987
  } else {
715
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
716
- <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
717
- <path d="M8 5v14l11-7z"/>
718
- </svg>
719
- <div class="text-xs mt-1">${meta?.name || "Video"}</div>
720
- </div>`;
988
+ // Create video placeholder safely
989
+ const wrapper = document.createElement("div");
990
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
991
+
992
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
993
+ svg.setAttribute("class", "w-8 h-8");
994
+ svg.setAttribute("fill", "currentColor");
995
+ svg.setAttribute("viewBox", "0 0 24 24");
996
+
997
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
998
+ path.setAttribute("d", "M8 5v14l11-7z");
999
+
1000
+ svg.appendChild(path);
1001
+
1002
+ const nameDiv = document.createElement("div");
1003
+ nameDiv.className = "text-xs mt-1";
1004
+ nameDiv.textContent = meta?.name || "Video";
1005
+
1006
+ wrapper.appendChild(svg);
1007
+ wrapper.appendChild(nameDiv);
1008
+ slot.appendChild(wrapper);
721
1009
  }
722
1010
  } else {
723
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
724
- <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
725
- <path d="M8 5v14l11-7z"/>
726
- </svg>
727
- <div class="text-xs mt-1">${meta?.name || "Video"}</div>
728
- </div>`;
1011
+ // Create video placeholder safely
1012
+ const wrapper = document.createElement("div");
1013
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
1014
+
1015
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1016
+ svg.setAttribute("class", "w-8 h-8");
1017
+ svg.setAttribute("fill", "currentColor");
1018
+ svg.setAttribute("viewBox", "0 0 24 24");
1019
+
1020
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1021
+ path.setAttribute("d", "M8 5v14l11-7z");
1022
+
1023
+ svg.appendChild(path);
1024
+
1025
+ const nameDiv = document.createElement("div");
1026
+ nameDiv.className = "text-xs mt-1";
1027
+ nameDiv.textContent = meta?.name || "Video";
1028
+
1029
+ wrapper.appendChild(svg);
1030
+ wrapper.appendChild(nameDiv);
1031
+ slot.appendChild(wrapper);
729
1032
  }
730
1033
  } else {
731
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
732
- <div class="text-2xl mb-1">📁</div>
733
- <div class="text-xs">${meta?.name || "File"}</div>
734
- </div>`;
1034
+ // Create file placeholder safely
1035
+ const wrapper = document.createElement("div");
1036
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
1037
+
1038
+ const iconDiv = document.createElement("div");
1039
+ iconDiv.className = "text-2xl mb-1";
1040
+ iconDiv.textContent = "📁";
1041
+
1042
+ const nameDiv = document.createElement("div");
1043
+ nameDiv.className = "text-xs";
1044
+ nameDiv.textContent = meta?.name || "File";
1045
+
1046
+ wrapper.appendChild(iconDiv);
1047
+ wrapper.appendChild(nameDiv);
1048
+ slot.appendChild(wrapper);
735
1049
  }
736
1050
 
737
1051
  // Add remove button overlay (similar to file field)
@@ -755,11 +1069,27 @@ function renderResourcePills(container, rids, onRemove) {
755
1069
  // Empty slot placeholder
756
1070
  slot.className =
757
1071
  "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
758
- slot.innerHTML =
759
- '<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>';
1072
+ // Create empty slot SVG safely
1073
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1074
+ svg.setAttribute("class", "w-12 h-12 text-gray-400");
1075
+ svg.setAttribute("fill", "currentColor");
1076
+ svg.setAttribute("viewBox", "0 0 24 24");
1077
+
1078
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1079
+ path.setAttribute("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");
1080
+
1081
+ svg.appendChild(path);
1082
+ slot.appendChild(svg);
760
1083
  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");
1084
+ // Look for file input - check parent containers that have space-y-2 class
1085
+ let filesWrapper = container.parentElement;
1086
+ while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
1087
+ filesWrapper = filesWrapper.parentElement;
1088
+ }
1089
+ // If no parent with space-y-2, container itself might be the wrapper
1090
+ if (!filesWrapper && container.classList.contains("space-y-2")) {
1091
+ filesWrapper = container;
1092
+ }
763
1093
  const fileInput = filesWrapper?.querySelector('input[type="file"]');
764
1094
  if (fileInput) fileInput.click();
765
1095
  };
@@ -811,14 +1141,37 @@ async function handleFileSelect(file, container, fieldName, deps = null) {
811
1141
  });
812
1142
 
813
1143
  // Create hidden input to store the resource ID
814
- let hiddenInput = container.parentElement.querySelector(
1144
+ // Handle case where container might be detached due to video replacement
1145
+ let parentElement = container.parentElement;
1146
+ if (!parentElement) {
1147
+ // Container is detached - find the current container by field name
1148
+ const form = document.querySelector('[data-form-builder]');
1149
+ if (form) {
1150
+ const existingInput = form.querySelector(`input[name="${fieldName}"]`);
1151
+ if (existingInput) {
1152
+ parentElement = existingInput.parentElement;
1153
+ // Update container reference to the current one in DOM
1154
+ const currentContainer = parentElement.querySelector('.file-preview-container');
1155
+ if (currentContainer) {
1156
+ container = currentContainer;
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ if (!parentElement) {
1163
+ console.warn('Could not find parent element for file field:', fieldName);
1164
+ return;
1165
+ }
1166
+
1167
+ let hiddenInput = parentElement.querySelector(
815
1168
  'input[type="hidden"]',
816
1169
  );
817
1170
  if (!hiddenInput) {
818
1171
  hiddenInput = document.createElement("input");
819
1172
  hiddenInput.type = "hidden";
820
1173
  hiddenInput.name = fieldName;
821
- container.parentElement.appendChild(hiddenInput);
1174
+ parentElement.appendChild(hiddenInput);
822
1175
  }
823
1176
  hiddenInput.value = rid;
824
1177
 
@@ -979,94 +1332,287 @@ function validateForm(skipValidation = false) {
979
1332
  switch (element.type) {
980
1333
  case "text":
981
1334
  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}`);
1335
+ if (element.multiple) {
1336
+ // Handle multiple text/textarea fields
1337
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1338
+ const values = [];
1339
+
1340
+ inputs.forEach((input, index) => {
1341
+ const val = input?.value ?? "";
1342
+ values.push(val);
1343
+
1344
+ if (!skipValidation && val) {
1345
+ if (
1346
+ element.minLength !== null &&
1347
+ val.length < element.minLength
1348
+ ) {
1349
+ errors.push(`${key}[${index}]: minLength=${element.minLength}`);
1350
+ markValidity(input, `minLength=${element.minLength}`);
1351
+ }
1352
+ if (
1353
+ element.maxLength !== null &&
1354
+ val.length > element.maxLength
1355
+ ) {
1356
+ errors.push(`${key}[${index}]: maxLength=${element.maxLength}`);
1357
+ markValidity(input, `maxLength=${element.maxLength}`);
1358
+ }
1359
+ if (element.pattern) {
1360
+ try {
1361
+ const re = new RegExp(element.pattern);
1362
+ if (!re.test(val)) {
1363
+ errors.push(`${key}[${index}]: pattern mismatch`);
1364
+ markValidity(input, "pattern mismatch");
1365
+ }
1366
+ } catch {
1367
+ errors.push(`${key}[${index}]: invalid pattern`);
1368
+ markValidity(input, "invalid pattern");
1369
+ }
1370
+ }
1371
+ } else {
1372
+ markValidity(input, null);
1373
+ }
1374
+ });
1375
+
1376
+ // Validate minCount/maxCount constraints
1377
+ if (!skipValidation) {
1378
+ const minCount = element.minCount ?? 1;
1379
+ const maxCount = element.maxCount ?? 10;
1380
+ const nonEmptyValues = values.filter((v) => v.trim() !== "");
1381
+
1382
+ if (element.required && nonEmptyValues.length === 0) {
1383
+ errors.push(`${key}: required`);
1384
+ }
1385
+ if (nonEmptyValues.length < minCount) {
1386
+ errors.push(`${key}: minimum ${minCount} items required`);
1387
+ }
1388
+ if (nonEmptyValues.length > maxCount) {
1389
+ errors.push(`${key}: maximum ${maxCount} items allowed`);
1390
+ }
993
1391
  }
994
- if (element.maxLength !== null && val.length > element.maxLength) {
995
- errors.push(`${key}: maxLength=${element.maxLength}`);
996
- markValidity(input, `maxLength=${element.maxLength}`);
1392
+
1393
+ return values;
1394
+ } else {
1395
+ // Handle single text/textarea field
1396
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1397
+ const val = input?.value ?? "";
1398
+ if (!skipValidation && element.required && val === "") {
1399
+ errors.push(`${key}: required`);
1400
+ markValidity(input, "required");
1401
+ return "";
997
1402
  }
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");
1403
+ if (!skipValidation && val) {
1404
+ if (element.minLength !== null && val.length < element.minLength) {
1405
+ errors.push(`${key}: minLength=${element.minLength}`);
1406
+ markValidity(input, `minLength=${element.minLength}`);
1407
+ }
1408
+ if (element.maxLength !== null && val.length > element.maxLength) {
1409
+ errors.push(`${key}: maxLength=${element.maxLength}`);
1410
+ markValidity(input, `maxLength=${element.maxLength}`);
1411
+ }
1412
+ if (element.pattern) {
1413
+ try {
1414
+ const re = new RegExp(element.pattern);
1415
+ if (!re.test(val)) {
1416
+ errors.push(`${key}: pattern mismatch`);
1417
+ markValidity(input, "pattern mismatch");
1418
+ }
1419
+ } catch {
1420
+ errors.push(`${key}: invalid pattern`);
1421
+ markValidity(input, "invalid pattern");
1004
1422
  }
1005
- } catch {
1006
- errors.push(`${key}: invalid pattern`);
1007
- markValidity(input, "invalid pattern");
1008
1423
  }
1424
+ } else if (skipValidation) {
1425
+ markValidity(input, null);
1426
+ } else {
1427
+ markValidity(input, null);
1009
1428
  }
1010
- } else if (skipValidation) {
1011
- markValidity(input, null);
1012
- } else {
1013
- markValidity(input, null);
1429
+ return val;
1014
1430
  }
1015
- return val;
1016
1431
  }
1017
1432
  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 === "") {
1433
+ if (element.multiple) {
1434
+ // Handle multiple number fields
1435
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1436
+ const values = [];
1437
+
1438
+ inputs.forEach((input, index) => {
1439
+ const raw = input?.value ?? "";
1440
+ if (raw === "") {
1441
+ values.push(null);
1442
+ markValidity(input, null);
1443
+ return;
1444
+ }
1445
+
1446
+ const v = parseFloat(raw);
1447
+ if (!skipValidation && !Number.isFinite(v)) {
1448
+ errors.push(`${key}[${index}]: not a number`);
1449
+ markValidity(input, "not a number");
1450
+ values.push(null);
1451
+ return;
1452
+ }
1453
+
1454
+ if (!skipValidation && element.min !== null && v < element.min) {
1455
+ errors.push(`${key}[${index}]: < min=${element.min}`);
1456
+ markValidity(input, `< min=${element.min}`);
1457
+ }
1458
+ if (!skipValidation && element.max !== null && v > element.max) {
1459
+ errors.push(`${key}[${index}]: > max=${element.max}`);
1460
+ markValidity(input, `> max=${element.max}`);
1461
+ }
1462
+
1463
+ const d = Number.isInteger(element.decimals ?? 0)
1464
+ ? element.decimals
1465
+ : 0;
1466
+ markValidity(input, null);
1467
+ values.push(Number(v.toFixed(d)));
1468
+ });
1469
+
1470
+ // Validate minCount/maxCount constraints
1471
+ if (!skipValidation) {
1472
+ const minCount = element.minCount ?? 1;
1473
+ const maxCount = element.maxCount ?? 10;
1474
+ const nonNullValues = values.filter((v) => v !== null);
1475
+
1476
+ if (element.required && nonNullValues.length === 0) {
1477
+ errors.push(`${key}: required`);
1478
+ }
1479
+ if (nonNullValues.length < minCount) {
1480
+ errors.push(`${key}: minimum ${minCount} items required`);
1481
+ }
1482
+ if (nonNullValues.length > maxCount) {
1483
+ errors.push(`${key}: maximum ${maxCount} items allowed`);
1484
+ }
1485
+ }
1486
+
1487
+ return values;
1488
+ } else {
1489
+ // Handle single number field
1490
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1491
+ const raw = input?.value ?? "";
1492
+ if (!skipValidation && element.required && raw === "") {
1493
+ errors.push(`${key}: required`);
1494
+ markValidity(input, "required");
1495
+ return null;
1496
+ }
1497
+ if (raw === "") {
1498
+ markValidity(input, null);
1499
+ return null;
1500
+ }
1501
+ const v = parseFloat(raw);
1502
+ if (!skipValidation && !Number.isFinite(v)) {
1503
+ errors.push(`${key}: not a number`);
1504
+ markValidity(input, "not a number");
1505
+ return null;
1506
+ }
1507
+ if (!skipValidation && element.min !== null && v < element.min) {
1508
+ errors.push(`${key}: < min=${element.min}`);
1509
+ markValidity(input, `< min=${element.min}`);
1510
+ }
1511
+ if (!skipValidation && element.max !== null && v > element.max) {
1512
+ errors.push(`${key}: > max=${element.max}`);
1513
+ markValidity(input, `> max=${element.max}`);
1514
+ }
1515
+ const d = Number.isInteger(element.decimals ?? 0)
1516
+ ? element.decimals
1517
+ : 0;
1026
1518
  markValidity(input, null);
1027
- return null;
1519
+ return Number(v.toFixed(d));
1028
1520
  }
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}`);
1038
- }
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
1521
  }
1049
1522
  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 "";
1523
+ if (element.multiple) {
1524
+ // Handle multiple select fields
1525
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1526
+ const values = [];
1527
+
1528
+ inputs.forEach((input) => {
1529
+ const val = input?.value ?? "";
1530
+ values.push(val);
1531
+ markValidity(input, null);
1532
+ });
1533
+
1534
+ // Validate minCount/maxCount constraints
1535
+ if (!skipValidation) {
1536
+ const minCount = element.minCount ?? 1;
1537
+ const maxCount = element.maxCount ?? 10;
1538
+ const nonEmptyValues = values.filter((v) => v !== "");
1539
+
1540
+ if (element.required && nonEmptyValues.length === 0) {
1541
+ errors.push(`${key}: required`);
1542
+ }
1543
+ if (nonEmptyValues.length < minCount) {
1544
+ errors.push(`${key}: minimum ${minCount} items required`);
1545
+ }
1546
+ if (nonEmptyValues.length > maxCount) {
1547
+ errors.push(`${key}: maximum ${maxCount} items allowed`);
1548
+ }
1549
+ }
1550
+
1551
+ return values;
1552
+ } else {
1553
+ // Handle single select field
1554
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1555
+ const val = input?.value ?? "";
1556
+ if (!skipValidation && element.required && val === "") {
1557
+ errors.push(`${key}: required`);
1558
+ markValidity(input, "required");
1559
+ return "";
1560
+ }
1561
+ markValidity(input, null);
1562
+ return val;
1056
1563
  }
1057
- markValidity(input, null);
1058
- return val;
1059
1564
  }
1060
1565
  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;
1566
+ if (element.multiple) {
1567
+ // Handle file with multiple property like files type
1568
+ // Find the files list by locating the specific file input for this field
1569
+ const fullKey = pathJoin(ctx.path, key);
1570
+ const pickerInput = scopeRoot.querySelector(
1571
+ `input[type="file"][name="${fullKey}"]`,
1572
+ );
1573
+ const filesWrapper = pickerInput?.closest(".space-y-2");
1574
+ const container = filesWrapper?.querySelector(".files-list") || null;
1575
+
1576
+ const resourceIds = [];
1577
+ if (container) {
1578
+ const pills = container.querySelectorAll(".resource-pill");
1579
+ pills.forEach((pill) => {
1580
+ const resourceId = pill.dataset.resourceId;
1581
+ if (resourceId) {
1582
+ resourceIds.push(resourceId);
1583
+ }
1584
+ });
1585
+ }
1586
+
1587
+ // Validate minCount/maxCount constraints
1588
+ if (!skipValidation) {
1589
+ const minFiles = element.minCount ?? 0;
1590
+ const maxFiles = element.maxCount ?? Infinity;
1591
+
1592
+ if (element.required && resourceIds.length === 0) {
1593
+ errors.push(`${key}: required`);
1594
+ }
1595
+ if (resourceIds.length < minFiles) {
1596
+ errors.push(`${key}: minimum ${minFiles} files required`);
1597
+ }
1598
+ if (resourceIds.length > maxFiles) {
1599
+ errors.push(`${key}: maximum ${maxFiles} files allowed`);
1600
+ }
1601
+ }
1602
+
1603
+ return resourceIds;
1604
+ } else {
1605
+ // Handle single file
1606
+ const input = scopeRoot.querySelector(
1607
+ `input[name$="${key}"][type="hidden"]`,
1608
+ );
1609
+ const rid = input?.value ?? "";
1610
+ if (!skipValidation && element.required && rid === "") {
1611
+ errors.push(`${key}: required`);
1612
+ return null;
1613
+ }
1614
+ return rid || null;
1068
1615
  }
1069
- return rid || null;
1070
1616
  }
1071
1617
  case "files": {
1072
1618
  // For files, we need to collect all resource IDs
@@ -1110,6 +1656,77 @@ function validateForm(skipValidation = false) {
1110
1656
  }
1111
1657
  case "group": {
1112
1658
  if (element.repeat && isPlainObject(element.repeat)) {
1659
+ const items = [];
1660
+ // Use full path for nested group element search
1661
+ const fullKey = pathJoin(ctx.path, key);
1662
+ const itemElements = scopeRoot.querySelectorAll(
1663
+ `[name^="${fullKey}["]`,
1664
+ );
1665
+
1666
+ // Extract actual indices from DOM element names instead of assuming sequential numbering
1667
+ const actualIndices = new Set();
1668
+ itemElements.forEach((el) => {
1669
+ const match = el.name.match(
1670
+ new RegExp(
1671
+ `^${fullKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\[(\\d+)\\]`,
1672
+ ),
1673
+ );
1674
+ if (match) {
1675
+ actualIndices.add(parseInt(match[1]));
1676
+ }
1677
+ });
1678
+
1679
+ const sortedIndices = Array.from(actualIndices).sort((a, b) => a - b);
1680
+
1681
+ sortedIndices.forEach((actualIndex) => {
1682
+ const itemData = {};
1683
+ // Find the specific group item container for scoped queries - use full path
1684
+ const fullItemPath = `${fullKey}[${actualIndex}]`;
1685
+ const itemContainer =
1686
+ scopeRoot.querySelector(`[data-group-item="${fullItemPath}"]`) ||
1687
+ scopeRoot;
1688
+ element.elements.forEach((child) => {
1689
+ if (child.hidden) {
1690
+ // For hidden child elements, use their default value
1691
+ itemData[child.key] =
1692
+ child.default !== undefined ? child.default : "";
1693
+ } else {
1694
+ const childKey = `${fullKey}[${actualIndex}].${child.key}`;
1695
+ itemData[child.key] = validateElement(
1696
+ { ...child, key: childKey },
1697
+ ctx,
1698
+ itemContainer,
1699
+ );
1700
+ }
1701
+ });
1702
+ items.push(itemData);
1703
+ });
1704
+ return items;
1705
+ } else {
1706
+ const groupData = {};
1707
+ // Find the specific group container for scoped queries
1708
+ const groupContainer =
1709
+ scopeRoot.querySelector(`[data-group="${key}"]`) || scopeRoot;
1710
+ element.elements.forEach((child) => {
1711
+ if (child.hidden) {
1712
+ // For hidden child elements, use their default value
1713
+ groupData[child.key] =
1714
+ child.default !== undefined ? child.default : "";
1715
+ } else {
1716
+ const childKey = `${key}.${child.key}`;
1717
+ groupData[child.key] = validateElement(
1718
+ { ...child, key: childKey },
1719
+ ctx,
1720
+ groupContainer,
1721
+ );
1722
+ }
1723
+ });
1724
+ return groupData;
1725
+ }
1726
+ }
1727
+ case "container": {
1728
+ if (element.multiple) {
1729
+ // Handle multiple containers like repeating groups
1113
1730
  const items = [];
1114
1731
  const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1115
1732
  const itemCount = Math.max(
@@ -1119,35 +1736,64 @@ function validateForm(skipValidation = false) {
1119
1736
 
1120
1737
  for (let i = 0; i < itemCount; i++) {
1121
1738
  const itemData = {};
1122
- // Find the specific group item container for scoped queries
1739
+ // Find the specific container item container for scoped queries
1123
1740
  const itemContainer =
1124
- scopeRoot.querySelector(`[data-group-item="${key}[${i}]"]`) ||
1741
+ scopeRoot.querySelector(`[data-container-item="${key}[${i}]"]`) ||
1125
1742
  scopeRoot;
1126
1743
  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
- );
1744
+ if (child.hidden) {
1745
+ // For hidden child elements, use their default value
1746
+ itemData[child.key] =
1747
+ child.default !== undefined ? child.default : "";
1748
+ } else {
1749
+ const childKey = `${key}[${i}].${child.key}`;
1750
+ itemData[child.key] = validateElement(
1751
+ { ...child, key: childKey },
1752
+ ctx,
1753
+ itemContainer,
1754
+ );
1755
+ }
1133
1756
  });
1134
1757
  items.push(itemData);
1135
1758
  }
1759
+
1760
+ // Validate minCount/maxCount constraints
1761
+ if (!skipValidation) {
1762
+ const minItems = element.minCount ?? 0;
1763
+ const maxItems = element.maxCount ?? Infinity;
1764
+
1765
+ if (element.required && items.length === 0) {
1766
+ errors.push(`${key}: required`);
1767
+ }
1768
+ if (items.length < minItems) {
1769
+ errors.push(`${key}: minimum ${minItems} items required`);
1770
+ }
1771
+ if (items.length > maxItems) {
1772
+ errors.push(`${key}: maximum ${maxItems} items allowed`);
1773
+ }
1774
+ }
1775
+
1136
1776
  return items;
1137
1777
  } else {
1138
- const groupData = {};
1139
- // Find the specific group container for scoped queries
1140
- const groupContainer =
1141
- scopeRoot.querySelector(`[data-group="${key}"]`) || scopeRoot;
1778
+ const containerData = {};
1779
+ // Find the specific container container for scoped queries
1780
+ const containerContainer =
1781
+ scopeRoot.querySelector(`[data-container="${key}"]`) || scopeRoot;
1142
1782
  element.elements.forEach((child) => {
1143
- const childKey = `${key}.${child.key}`;
1144
- groupData[child.key] = validateElement(
1145
- { ...child, key: childKey },
1146
- ctx,
1147
- groupContainer,
1148
- );
1783
+ if (child.hidden) {
1784
+ // For hidden child elements, use their default value
1785
+ containerData[child.key] =
1786
+ child.default !== undefined ? child.default : "";
1787
+ } else {
1788
+ const childKey = `${key}.${child.key}`;
1789
+ containerData[child.key] = validateElement(
1790
+ { ...child, key: childKey },
1791
+ ctx,
1792
+ containerContainer,
1793
+ );
1794
+ }
1149
1795
  });
1150
- return groupData;
1796
+ return containerData;
1151
1797
  }
1152
1798
  }
1153
1799
  default:
@@ -1156,7 +1802,12 @@ function validateForm(skipValidation = false) {
1156
1802
  }
1157
1803
 
1158
1804
  state.schema.elements.forEach((element) => {
1159
- data[element.key] = validateElement(element, { path: "" });
1805
+ // Handle hidden elements - use their default value instead of reading from DOM
1806
+ if (element.hidden) {
1807
+ data[element.key] = element.default !== undefined ? element.default : "";
1808
+ } else {
1809
+ data[element.key] = validateElement(element, { path: "" });
1810
+ }
1160
1811
  });
1161
1812
 
1162
1813
  return {
@@ -1185,24 +1836,254 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
1185
1836
  wrapper.appendChild(textHint);
1186
1837
  }
1187
1838
 
1188
- function renderTextareaElement(element, ctx, wrapper, pathKey) {
1189
- const textareaInput = document.createElement("textarea");
1190
- textareaInput.className =
1191
- "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";
1192
- textareaInput.name = pathKey;
1193
- textareaInput.placeholder = element.placeholder || "Введите текст";
1194
- textareaInput.rows = element.rows || 4;
1195
- textareaInput.value = ctx.prefill[element.key] || element.default || "";
1196
- textareaInput.readOnly = state.config.readonly;
1197
- wrapper.appendChild(textareaInput);
1839
+ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1840
+ const prefillValues = ctx.prefill[element.key] || [];
1841
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1198
1842
 
1199
- // Add hint
1200
- const textareaHint = document.createElement("p");
1201
- textareaHint.className = "text-xs text-gray-500 mt-1";
1843
+ // Ensure minimum count
1844
+ const minCount = element.minCount ?? 1;
1845
+ const maxCount = element.maxCount ?? 10;
1846
+
1847
+ while (values.length < minCount) {
1848
+ values.push(element.default || "");
1849
+ }
1850
+
1851
+ const container = document.createElement("div");
1852
+ container.className = "space-y-2";
1853
+ wrapper.appendChild(container);
1854
+
1855
+ function updateIndices() {
1856
+ const items = container.querySelectorAll(".multiple-text-item");
1857
+ items.forEach((item, index) => {
1858
+ const input = item.querySelector("input");
1859
+ if (input) {
1860
+ input.name = `${pathKey}[${index}]`;
1861
+ }
1862
+ });
1863
+ }
1864
+
1865
+ function addTextItem(value = "", index = -1) {
1866
+ const itemWrapper = document.createElement("div");
1867
+ itemWrapper.className = "multiple-text-item flex items-center gap-2";
1868
+
1869
+ const textInput = document.createElement("input");
1870
+ textInput.type = "text";
1871
+ textInput.className =
1872
+ "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1873
+ textInput.placeholder = element.placeholder || "Enter text";
1874
+ textInput.value = value;
1875
+ textInput.readOnly = state.config.readonly;
1876
+
1877
+ itemWrapper.appendChild(textInput);
1878
+
1879
+ // Remove buttons are managed centrally via updateRemoveButtons()
1880
+
1881
+ if (index === -1) {
1882
+ container.appendChild(itemWrapper);
1883
+ } else {
1884
+ container.insertBefore(itemWrapper, container.children[index]);
1885
+ }
1886
+
1887
+ updateIndices();
1888
+ return itemWrapper;
1889
+ }
1890
+
1891
+ function updateRemoveButtons() {
1892
+ if (state.config.readonly) return;
1893
+ const items = container.querySelectorAll(".multiple-text-item");
1894
+ const currentCount = items.length;
1895
+ items.forEach((item) => {
1896
+ let removeBtn = item.querySelector(".remove-item-btn");
1897
+ if (!removeBtn) {
1898
+ removeBtn = document.createElement("button");
1899
+ removeBtn.type = "button";
1900
+ removeBtn.className =
1901
+ "remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded";
1902
+ removeBtn.textContent = "✕";
1903
+ removeBtn.onclick = () => {
1904
+ const currentIndex = Array.from(container.children).indexOf(item);
1905
+ if (container.children.length > minCount) {
1906
+ values.splice(currentIndex, 1);
1907
+ item.remove();
1908
+ updateIndices();
1909
+ updateAddButton();
1910
+ updateRemoveButtons();
1911
+ }
1912
+ };
1913
+ item.appendChild(removeBtn);
1914
+ }
1915
+ const disabled = currentCount <= minCount;
1916
+ removeBtn.disabled = disabled;
1917
+ removeBtn.style.opacity = disabled ? "0.5" : "1";
1918
+ removeBtn.style.pointerEvents = disabled ? "none" : "auto";
1919
+ });
1920
+ }
1921
+
1922
+ function updateAddButton() {
1923
+ const existingAddBtn = wrapper.querySelector(".add-text-btn");
1924
+ if (existingAddBtn) existingAddBtn.remove();
1925
+
1926
+ if (!state.config.readonly && values.length < maxCount) {
1927
+ const addBtn = document.createElement("button");
1928
+ addBtn.type = "button";
1929
+ addBtn.className =
1930
+ "add-text-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
1931
+ addBtn.textContent = `+ Add ${element.label || "Text"}`;
1932
+ addBtn.onclick = () => {
1933
+ values.push(element.default || "");
1934
+ addTextItem(element.default || "");
1935
+ updateAddButton();
1936
+ updateRemoveButtons();
1937
+ };
1938
+ wrapper.appendChild(addBtn);
1939
+ }
1940
+ }
1941
+
1942
+ // Render initial items
1943
+ values.forEach((value) => addTextItem(value));
1944
+ updateAddButton();
1945
+ updateRemoveButtons();
1946
+
1947
+ // Add hint
1948
+ const hint = document.createElement("p");
1949
+ hint.className = "text-xs text-gray-500 mt-1";
1950
+ hint.textContent = makeFieldHint(element);
1951
+ wrapper.appendChild(hint);
1952
+ }
1953
+
1954
+ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1955
+ const textareaInput = document.createElement("textarea");
1956
+ textareaInput.className =
1957
+ "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";
1958
+ textareaInput.name = pathKey;
1959
+ textareaInput.placeholder = element.placeholder || "Введите текст";
1960
+ textareaInput.rows = element.rows || 4;
1961
+ textareaInput.value = ctx.prefill[element.key] || element.default || "";
1962
+ textareaInput.readOnly = state.config.readonly;
1963
+ wrapper.appendChild(textareaInput);
1964
+
1965
+ // Add hint
1966
+ const textareaHint = document.createElement("p");
1967
+ textareaHint.className = "text-xs text-gray-500 mt-1";
1202
1968
  textareaHint.textContent = makeFieldHint(element);
1203
1969
  wrapper.appendChild(textareaHint);
1204
1970
  }
1205
1971
 
1972
+ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1973
+ const prefillValues = ctx.prefill[element.key] || [];
1974
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1975
+
1976
+ // Ensure minimum count
1977
+ const minCount = element.minCount ?? 1;
1978
+ const maxCount = element.maxCount ?? 10;
1979
+
1980
+ while (values.length < minCount) {
1981
+ values.push(element.default || "");
1982
+ }
1983
+
1984
+ const container = document.createElement("div");
1985
+ container.className = "space-y-2";
1986
+ wrapper.appendChild(container);
1987
+
1988
+ function updateIndices() {
1989
+ const items = container.querySelectorAll(".multiple-textarea-item");
1990
+ items.forEach((item, index) => {
1991
+ const textarea = item.querySelector("textarea");
1992
+ if (textarea) {
1993
+ textarea.name = `${pathKey}[${index}]`;
1994
+ }
1995
+ });
1996
+ }
1997
+
1998
+ function addTextareaItem(value = "", index = -1) {
1999
+ const itemWrapper = document.createElement("div");
2000
+ itemWrapper.className = "multiple-textarea-item";
2001
+
2002
+ const textareaInput = document.createElement("textarea");
2003
+ textareaInput.className =
2004
+ "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";
2005
+ textareaInput.placeholder = element.placeholder || "Enter text";
2006
+ textareaInput.rows = element.rows || 4;
2007
+ textareaInput.value = value;
2008
+ textareaInput.readOnly = state.config.readonly;
2009
+
2010
+ itemWrapper.appendChild(textareaInput);
2011
+
2012
+ // Remove buttons are managed centrally via updateRemoveButtons()
2013
+
2014
+ if (index === -1) {
2015
+ container.appendChild(itemWrapper);
2016
+ } else {
2017
+ container.insertBefore(itemWrapper, container.children[index]);
2018
+ }
2019
+
2020
+ updateIndices();
2021
+ return itemWrapper;
2022
+ }
2023
+
2024
+ function updateRemoveButtons() {
2025
+ if (state.config.readonly) return;
2026
+ const items = container.querySelectorAll(".multiple-textarea-item");
2027
+ const currentCount = items.length;
2028
+ items.forEach((item) => {
2029
+ let removeBtn = item.querySelector(".remove-item-btn");
2030
+ if (!removeBtn) {
2031
+ removeBtn = document.createElement("button");
2032
+ removeBtn.type = "button";
2033
+ removeBtn.className =
2034
+ "remove-item-btn mt-1 px-2 py-1 text-red-600 hover:bg-red-50 rounded text-sm";
2035
+ removeBtn.textContent = "✕ Remove";
2036
+ removeBtn.onclick = () => {
2037
+ const currentIndex = Array.from(container.children).indexOf(item);
2038
+ if (container.children.length > minCount) {
2039
+ values.splice(currentIndex, 1);
2040
+ item.remove();
2041
+ updateIndices();
2042
+ updateAddButton();
2043
+ updateRemoveButtons();
2044
+ }
2045
+ };
2046
+ item.appendChild(removeBtn);
2047
+ }
2048
+ const disabled = currentCount <= minCount;
2049
+ removeBtn.disabled = disabled;
2050
+ removeBtn.style.opacity = disabled ? "0.5" : "1";
2051
+ removeBtn.style.pointerEvents = disabled ? "none" : "auto";
2052
+ });
2053
+ }
2054
+
2055
+ function updateAddButton() {
2056
+ const existingAddBtn = wrapper.querySelector(".add-textarea-btn");
2057
+ if (existingAddBtn) existingAddBtn.remove();
2058
+
2059
+ if (!state.config.readonly && values.length < maxCount) {
2060
+ const addBtn = document.createElement("button");
2061
+ addBtn.type = "button";
2062
+ addBtn.className =
2063
+ "add-textarea-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
2064
+ addBtn.textContent = `+ Add ${element.label || "Textarea"}`;
2065
+ addBtn.onclick = () => {
2066
+ values.push(element.default || "");
2067
+ addTextareaItem(element.default || "");
2068
+ updateAddButton();
2069
+ updateRemoveButtons();
2070
+ };
2071
+ wrapper.appendChild(addBtn);
2072
+ }
2073
+ }
2074
+
2075
+ // Render initial items
2076
+ values.forEach((value) => addTextareaItem(value));
2077
+ updateAddButton();
2078
+ updateRemoveButtons();
2079
+
2080
+ // Add hint
2081
+ const hint = document.createElement("p");
2082
+ hint.className = "text-xs text-gray-500 mt-1";
2083
+ hint.textContent = makeFieldHint(element);
2084
+ wrapper.appendChild(hint);
2085
+ }
2086
+
1206
2087
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1207
2088
  const numberInput = document.createElement("input");
1208
2089
  numberInput.type = "number";
@@ -1224,6 +2105,124 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1224
2105
  wrapper.appendChild(numberHint);
1225
2106
  }
1226
2107
 
2108
+ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
2109
+ const prefillValues = ctx.prefill[element.key] || [];
2110
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
2111
+
2112
+ // Ensure minimum count
2113
+ const minCount = element.minCount ?? 1;
2114
+ const maxCount = element.maxCount ?? 10;
2115
+
2116
+ while (values.length < minCount) {
2117
+ values.push(element.default || "");
2118
+ }
2119
+
2120
+ const container = document.createElement("div");
2121
+ container.className = "space-y-2";
2122
+ wrapper.appendChild(container);
2123
+
2124
+ function updateIndices() {
2125
+ const items = container.querySelectorAll(".multiple-number-item");
2126
+ items.forEach((item, index) => {
2127
+ const input = item.querySelector("input");
2128
+ if (input) {
2129
+ input.name = `${pathKey}[${index}]`;
2130
+ }
2131
+ });
2132
+ }
2133
+
2134
+ function addNumberItem(value = "", index = -1) {
2135
+ const itemWrapper = document.createElement("div");
2136
+ itemWrapper.className = "multiple-number-item flex items-center gap-2";
2137
+
2138
+ const numberInput = document.createElement("input");
2139
+ numberInput.type = "number";
2140
+ numberInput.className =
2141
+ "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
2142
+ numberInput.placeholder = element.placeholder || "0";
2143
+ if (element.min !== undefined) numberInput.min = element.min;
2144
+ if (element.max !== undefined) numberInput.max = element.max;
2145
+ if (element.step !== undefined) numberInput.step = element.step;
2146
+ numberInput.value = value;
2147
+ numberInput.readOnly = state.config.readonly;
2148
+
2149
+ itemWrapper.appendChild(numberInput);
2150
+
2151
+ // Remove buttons are managed centrally via updateRemoveButtons()
2152
+
2153
+ if (index === -1) {
2154
+ container.appendChild(itemWrapper);
2155
+ } else {
2156
+ container.insertBefore(itemWrapper, container.children[index]);
2157
+ }
2158
+
2159
+ updateIndices();
2160
+ return itemWrapper;
2161
+ }
2162
+
2163
+ function updateRemoveButtons() {
2164
+ if (state.config.readonly) return;
2165
+ const items = container.querySelectorAll(".multiple-number-item");
2166
+ const currentCount = items.length;
2167
+ items.forEach((item) => {
2168
+ let removeBtn = item.querySelector(".remove-item-btn");
2169
+ if (!removeBtn) {
2170
+ removeBtn = document.createElement("button");
2171
+ removeBtn.type = "button";
2172
+ removeBtn.className =
2173
+ "remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded";
2174
+ removeBtn.textContent = "✕";
2175
+ removeBtn.onclick = () => {
2176
+ const currentIndex = Array.from(container.children).indexOf(item);
2177
+ if (container.children.length > minCount) {
2178
+ values.splice(currentIndex, 1);
2179
+ item.remove();
2180
+ updateIndices();
2181
+ updateAddButton();
2182
+ updateRemoveButtons();
2183
+ }
2184
+ };
2185
+ item.appendChild(removeBtn);
2186
+ }
2187
+ const disabled = currentCount <= minCount;
2188
+ removeBtn.disabled = disabled;
2189
+ removeBtn.style.opacity = disabled ? "0.5" : "1";
2190
+ removeBtn.style.pointerEvents = disabled ? "none" : "auto";
2191
+ });
2192
+ }
2193
+
2194
+ function updateAddButton() {
2195
+ const existingAddBtn = wrapper.querySelector(".add-number-btn");
2196
+ if (existingAddBtn) existingAddBtn.remove();
2197
+
2198
+ if (!state.config.readonly && values.length < maxCount) {
2199
+ const addBtn = document.createElement("button");
2200
+ addBtn.type = "button";
2201
+ addBtn.className =
2202
+ "add-number-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
2203
+ addBtn.textContent = `+ Add ${element.label || "Number"}`;
2204
+ addBtn.onclick = () => {
2205
+ values.push(element.default || "");
2206
+ addNumberItem(element.default || "");
2207
+ updateAddButton();
2208
+ updateRemoveButtons();
2209
+ };
2210
+ wrapper.appendChild(addBtn);
2211
+ }
2212
+ }
2213
+
2214
+ // Render initial items
2215
+ values.forEach((value) => addNumberItem(value));
2216
+ updateAddButton();
2217
+ updateRemoveButtons();
2218
+
2219
+ // Add hint
2220
+ const hint = document.createElement("p");
2221
+ hint.className = "text-xs text-gray-500 mt-1";
2222
+ hint.textContent = makeFieldHint(element);
2223
+ wrapper.appendChild(hint);
2224
+ }
2225
+
1227
2226
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1228
2227
  const selectInput = document.createElement("select");
1229
2228
  selectInput.className =
@@ -1250,6 +2249,131 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1250
2249
  wrapper.appendChild(selectHint);
1251
2250
  }
1252
2251
 
2252
+ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2253
+ const prefillValues = ctx.prefill[element.key] || [];
2254
+ const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
2255
+
2256
+ // Ensure minimum count
2257
+ const minCount = element.minCount ?? 1;
2258
+ const maxCount = element.maxCount ?? 10;
2259
+
2260
+ while (values.length < minCount) {
2261
+ values.push(element.default || element.options?.[0]?.value || "");
2262
+ }
2263
+
2264
+ const container = document.createElement("div");
2265
+ container.className = "space-y-2";
2266
+ wrapper.appendChild(container);
2267
+
2268
+ function updateIndices() {
2269
+ const items = container.querySelectorAll(".multiple-select-item");
2270
+ items.forEach((item, index) => {
2271
+ const select = item.querySelector("select");
2272
+ if (select) {
2273
+ select.name = `${pathKey}[${index}]`;
2274
+ }
2275
+ });
2276
+ }
2277
+
2278
+ function addSelectItem(value = "", index = -1) {
2279
+ const itemWrapper = document.createElement("div");
2280
+ itemWrapper.className = "multiple-select-item flex items-center gap-2";
2281
+
2282
+ const selectInput = document.createElement("select");
2283
+ selectInput.className =
2284
+ "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
2285
+ selectInput.disabled = state.config.readonly;
2286
+
2287
+ // Add options
2288
+ (element.options || []).forEach((option) => {
2289
+ const optionElement = document.createElement("option");
2290
+ optionElement.value = option.value;
2291
+ optionElement.textContent = option.label;
2292
+ if (value === option.value) {
2293
+ optionElement.selected = true;
2294
+ }
2295
+ selectInput.appendChild(optionElement);
2296
+ });
2297
+
2298
+ itemWrapper.appendChild(selectInput);
2299
+
2300
+ // Remove buttons are managed centrally via updateRemoveButtons()
2301
+
2302
+ if (index === -1) {
2303
+ container.appendChild(itemWrapper);
2304
+ } else {
2305
+ container.insertBefore(itemWrapper, container.children[index]);
2306
+ }
2307
+
2308
+ updateIndices();
2309
+ return itemWrapper;
2310
+ }
2311
+
2312
+ function updateRemoveButtons() {
2313
+ if (state.config.readonly) return;
2314
+ const items = container.querySelectorAll(".multiple-select-item");
2315
+ const currentCount = items.length;
2316
+ items.forEach((item) => {
2317
+ let removeBtn = item.querySelector(".remove-item-btn");
2318
+ if (!removeBtn) {
2319
+ removeBtn = document.createElement("button");
2320
+ removeBtn.type = "button";
2321
+ removeBtn.className =
2322
+ "remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded";
2323
+ removeBtn.textContent = "✕";
2324
+ removeBtn.onclick = () => {
2325
+ const currentIndex = Array.from(container.children).indexOf(item);
2326
+ if (container.children.length > minCount) {
2327
+ values.splice(currentIndex, 1);
2328
+ item.remove();
2329
+ updateIndices();
2330
+ updateAddButton();
2331
+ updateRemoveButtons();
2332
+ }
2333
+ };
2334
+ item.appendChild(removeBtn);
2335
+ }
2336
+ const disabled = currentCount <= minCount;
2337
+ removeBtn.disabled = disabled;
2338
+ removeBtn.style.opacity = disabled ? "0.5" : "1";
2339
+ removeBtn.style.pointerEvents = disabled ? "none" : "auto";
2340
+ });
2341
+ }
2342
+
2343
+ function updateAddButton() {
2344
+ const existingAddBtn = wrapper.querySelector(".add-select-btn");
2345
+ if (existingAddBtn) existingAddBtn.remove();
2346
+
2347
+ if (!state.config.readonly && values.length < maxCount) {
2348
+ const addBtn = document.createElement("button");
2349
+ addBtn.type = "button";
2350
+ addBtn.className =
2351
+ "add-select-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
2352
+ addBtn.textContent = `+ Add ${element.label || "Selection"}`;
2353
+ addBtn.onclick = () => {
2354
+ const defaultValue =
2355
+ element.default || element.options?.[0]?.value || "";
2356
+ values.push(defaultValue);
2357
+ addSelectItem(defaultValue);
2358
+ updateAddButton();
2359
+ updateRemoveButtons();
2360
+ };
2361
+ wrapper.appendChild(addBtn);
2362
+ }
2363
+ }
2364
+
2365
+ // Render initial items
2366
+ values.forEach((value) => addSelectItem(value));
2367
+ updateAddButton();
2368
+ updateRemoveButtons();
2369
+
2370
+ // Add hint
2371
+ const hint = document.createElement("p");
2372
+ hint.className = "text-xs text-gray-500 mt-1";
2373
+ hint.textContent = makeFieldHint(element);
2374
+ wrapper.appendChild(hint);
2375
+ }
2376
+
1253
2377
  function renderFileElement(element, ctx, wrapper, pathKey) {
1254
2378
  if (state.config.readonly) {
1255
2379
  // Readonly mode: use common preview function
@@ -1261,7 +2385,11 @@ function renderFileElement(element, ctx, wrapper, pathKey) {
1261
2385
  const emptyState = document.createElement("div");
1262
2386
  emptyState.className =
1263
2387
  "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
1264
- emptyState.innerHTML = `<div class="text-center">${t("noFileSelected")}</div>`;
2388
+ // Create empty state content safely
2389
+ const textDiv = document.createElement("div");
2390
+ textDiv.className = "text-center";
2391
+ textDiv.textContent = t("noFileSelected");
2392
+ emptyState.appendChild(textDiv);
1265
2393
  wrapper.appendChild(emptyState);
1266
2394
  }
1267
2395
  } else {
@@ -1321,8 +2449,14 @@ function renderFileElement(element, ctx, wrapper, pathKey) {
1321
2449
  // Add upload text
1322
2450
  const uploadText = document.createElement("p");
1323
2451
  uploadText.className = "text-xs text-gray-600 mt-2 text-center";
1324
- uploadText.innerHTML = `<span class="underline cursor-pointer">${t("uploadText")}</span> ${t("dragDropTextSingle")}`;
1325
- uploadText.querySelector("span").onclick = () => picker.click();
2452
+ // Create upload text content safely
2453
+ const uploadSpan = document.createElement("span");
2454
+ uploadSpan.className = "underline cursor-pointer";
2455
+ uploadSpan.textContent = t("uploadText");
2456
+ uploadSpan.onclick = () => picker.click();
2457
+
2458
+ uploadText.appendChild(uploadSpan);
2459
+ uploadText.appendChild(document.createTextNode(" " + t("dragDropTextSingle")));
1326
2460
  fileWrapper.appendChild(uploadText);
1327
2461
 
1328
2462
  // Add hint
@@ -1381,14 +2515,29 @@ function handleInitialFileData(
1381
2515
  }
1382
2516
 
1383
2517
  function setEmptyFileContainer(fileContainer) {
1384
- fileContainer.innerHTML = `
1385
- <div class="flex flex-col items-center justify-center h-full text-gray-400">
1386
- <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
1387
- <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"/>
1388
- </svg>
1389
- <div class="text-sm text-center">${t("clickDragText")}</div>
1390
- </div>
1391
- `;
2518
+ // Create empty file container content safely
2519
+ clear(fileContainer);
2520
+
2521
+ const wrapper = document.createElement("div");
2522
+ wrapper.className = "flex flex-col items-center justify-center h-full text-gray-400";
2523
+
2524
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2525
+ svg.setAttribute("class", "w-6 h-6 mb-2");
2526
+ svg.setAttribute("fill", "currentColor");
2527
+ svg.setAttribute("viewBox", "0 0 24 24");
2528
+
2529
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
2530
+ path.setAttribute("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");
2531
+
2532
+ svg.appendChild(path);
2533
+
2534
+ const textDiv = document.createElement("div");
2535
+ textDiv.className = "text-sm text-center";
2536
+ textDiv.textContent = t("clickDragText");
2537
+
2538
+ wrapper.appendChild(svg);
2539
+ wrapper.appendChild(textDiv);
2540
+ fileContainer.appendChild(wrapper);
1392
2541
  }
1393
2542
 
1394
2543
  function renderFilesElement(element, ctx, wrapper, pathKey) {
@@ -1405,7 +2554,16 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
1405
2554
  resultsWrapper.appendChild(filePreview);
1406
2555
  });
1407
2556
  } else {
1408
- 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>`;
2557
+ // Create empty state safely
2558
+ const emptyDiv = document.createElement("div");
2559
+ emptyDiv.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
2560
+
2561
+ const textDiv = document.createElement("div");
2562
+ textDiv.className = "text-center";
2563
+ textDiv.textContent = t("noFilesSelected");
2564
+
2565
+ emptyDiv.appendChild(textDiv);
2566
+ resultsWrapper.appendChild(emptyDiv);
1409
2567
  }
1410
2568
 
1411
2569
  wrapper.appendChild(resultsWrapper);
@@ -1468,6 +2626,103 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
1468
2626
  }
1469
2627
  }
1470
2628
 
2629
+ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
2630
+ // Use the same logic as renderFilesElement but with minCount/maxCount from element properties
2631
+ const minFiles = element.minCount ?? 0;
2632
+ const maxFiles = element.maxCount ?? Infinity;
2633
+
2634
+ if (state.config.readonly) {
2635
+ // Readonly mode: render as results list
2636
+ const resultsWrapper = document.createElement("div");
2637
+ resultsWrapper.className = "space-y-4";
2638
+
2639
+ const initialFiles = ctx.prefill[element.key] || [];
2640
+
2641
+ if (initialFiles.length > 0) {
2642
+ initialFiles.forEach((resourceId) => {
2643
+ const filePreview = renderFilePreviewReadonly(resourceId);
2644
+ resultsWrapper.appendChild(filePreview);
2645
+ });
2646
+ } else {
2647
+ // Create empty state safely
2648
+ const emptyDiv = document.createElement("div");
2649
+ emptyDiv.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
2650
+
2651
+ const textDiv = document.createElement("div");
2652
+ textDiv.className = "text-center";
2653
+ textDiv.textContent = t("noFilesSelected");
2654
+
2655
+ emptyDiv.appendChild(textDiv);
2656
+ resultsWrapper.appendChild(emptyDiv);
2657
+ }
2658
+
2659
+ wrapper.appendChild(resultsWrapper);
2660
+ } else {
2661
+ // Edit mode: multiple file input with min/max validation
2662
+ const filesWrapper = document.createElement("div");
2663
+ filesWrapper.className = "space-y-2";
2664
+
2665
+ const filesPicker = document.createElement("input");
2666
+ filesPicker.type = "file";
2667
+ filesPicker.name = pathKey;
2668
+ filesPicker.multiple = true;
2669
+ filesPicker.style.display = "none"; // Hide default input
2670
+ if (element.accept?.extensions) {
2671
+ filesPicker.accept = element.accept.extensions
2672
+ .map((ext) => `.${ext}`)
2673
+ .join(",");
2674
+ }
2675
+
2676
+ const filesContainer = document.createElement("div");
2677
+ filesContainer.className = "files-list space-y-2";
2678
+
2679
+ filesWrapper.appendChild(filesPicker);
2680
+ filesWrapper.appendChild(filesContainer);
2681
+
2682
+ const initialFiles = Array.isArray(ctx.prefill[element.key])
2683
+ ? [...ctx.prefill[element.key]]
2684
+ : [];
2685
+
2686
+ // Add initial files to resource index
2687
+ addPrefillFilesToIndex(initialFiles);
2688
+
2689
+ const updateFilesDisplay = () => {
2690
+ renderResourcePills(filesContainer, initialFiles, (index) => {
2691
+ initialFiles.splice(index, 1);
2692
+ updateFilesDisplay();
2693
+ });
2694
+
2695
+ // Show count and min/max info
2696
+ const countInfo = document.createElement("div");
2697
+ countInfo.className = "text-xs text-gray-500 mt-2";
2698
+ const countText = `${initialFiles.length} file${initialFiles.length !== 1 ? "s" : ""}`;
2699
+ const minMaxText =
2700
+ minFiles > 0 || maxFiles < Infinity
2701
+ ? ` (${minFiles}-${maxFiles} allowed)`
2702
+ : "";
2703
+ countInfo.textContent = countText + minMaxText;
2704
+
2705
+ // Remove previous count info
2706
+ const existingCount = filesWrapper.querySelector(".file-count-info");
2707
+ if (existingCount) existingCount.remove();
2708
+
2709
+ countInfo.className += " file-count-info";
2710
+ filesWrapper.appendChild(countInfo);
2711
+ };
2712
+
2713
+ // Set up drag and drop
2714
+ setupFilesDropHandler(filesContainer, initialFiles, updateFilesDisplay);
2715
+
2716
+ // Set up file picker
2717
+ setupFilesPickerHandler(filesPicker, initialFiles, updateFilesDisplay);
2718
+
2719
+ // Initial display
2720
+ updateFilesDisplay();
2721
+
2722
+ wrapper.appendChild(filesWrapper);
2723
+ }
2724
+ }
2725
+
1471
2726
  function addPrefillFilesToIndex(initialFiles) {
1472
2727
  if (initialFiles.length > 0) {
1473
2728
  initialFiles.forEach((resourceId) => {
@@ -1591,13 +2846,20 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
1591
2846
  const item = document.createElement("div");
1592
2847
  item.className =
1593
2848
  "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";
2849
+ const itemIndex = countItems();
2850
+ const fullPath = pathJoin(ctx.path, `${element.key}[${itemIndex}]`);
2851
+ // Add data-group-item attribute for validation scoping - use full path
2852
+ item.setAttribute("data-group-item", fullPath);
1594
2853
  const subCtx = {
1595
- path: pathJoin(ctx.path, `${element.key}[${countItems()}]`),
2854
+ path: fullPath,
1596
2855
  prefill: prefillObj || {},
1597
2856
  };
1598
- element.elements.forEach((child) =>
1599
- item.appendChild(renderElement(child, subCtx)),
1600
- );
2857
+ element.elements.forEach((child) => {
2858
+ // Skip rendering hidden child elements
2859
+ if (!child.hidden) {
2860
+ item.appendChild(renderElement(child, subCtx));
2861
+ }
2862
+ });
1601
2863
 
1602
2864
  // Only add remove button in edit mode
1603
2865
  if (!state.config.readonly) {
@@ -1628,7 +2890,18 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
1628
2890
  addBtn.type = "button";
1629
2891
  addBtn.className =
1630
2892
  "w-full py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 flex items-center justify-center mt-3";
1631
- addBtn.innerHTML = `<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>${t("addElement")}`;
2893
+ // Create add button content safely
2894
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2895
+ svg.setAttribute("class", "w-4 h-4 mr-2");
2896
+ svg.setAttribute("fill", "currentColor");
2897
+ svg.setAttribute("viewBox", "0 0 24 24");
2898
+
2899
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
2900
+ path.setAttribute("d", "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z");
2901
+
2902
+ svg.appendChild(path);
2903
+ addBtn.appendChild(svg);
2904
+ addBtn.appendChild(document.createTextNode(t("addElement")));
1632
2905
  groupWrap.appendChild(addBtn);
1633
2906
  }
1634
2907
 
@@ -1636,7 +2909,16 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
1636
2909
  if (state.config.readonly) return;
1637
2910
  const n = countItems();
1638
2911
  if (addBtn) addBtn.disabled = n >= max;
1639
- left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-slate-500 dark:text-slate-400 text-xs">[${n} / ${max === Infinity ? "∞" : max}, min=${min}]</span>`;
2912
+ // Create elements safely
2913
+ clear(left);
2914
+ const labelSpan = document.createElement("span");
2915
+ labelSpan.textContent = element.label || element.key;
2916
+ const countSpan = document.createElement("span");
2917
+ countSpan.className = "text-slate-500 dark:text-slate-400 text-xs";
2918
+ countSpan.textContent = `[${n} / ${max === Infinity ? "∞" : max}, min=${min}]`;
2919
+ left.appendChild(labelSpan);
2920
+ left.appendChild(document.createTextNode(" "));
2921
+ left.appendChild(countSpan);
1640
2922
  };
1641
2923
 
1642
2924
  if (pre && pre.length) {
@@ -1651,7 +2933,10 @@ function renderRepeatableGroup(element, ctx, itemsWrap, left, groupWrap) {
1651
2933
  addBtn.addEventListener("click", () => addItem(null));
1652
2934
  } else {
1653
2935
  // In readonly mode, just show the label without count controls
1654
- left.innerHTML = `<span>${element.label || element.key}</span>`;
2936
+ clear(left);
2937
+ const labelSpan = document.createElement("span");
2938
+ labelSpan.textContent = element.label || element.key;
2939
+ left.appendChild(labelSpan);
1655
2940
  }
1656
2941
  }
1657
2942
 
@@ -1661,11 +2946,240 @@ function renderSingleGroup(element, ctx, itemsWrap, left, groupWrap) {
1661
2946
  path: pathJoin(ctx.path, element.key),
1662
2947
  prefill: ctx.prefill?.[element.key] || {},
1663
2948
  };
1664
- element.elements.forEach((child) =>
1665
- itemsWrap.appendChild(renderElement(child, subCtx)),
1666
- );
2949
+ element.elements.forEach((child) => {
2950
+ // Skip rendering hidden child elements
2951
+ if (!child.hidden) {
2952
+ itemsWrap.appendChild(renderElement(child, subCtx));
2953
+ }
2954
+ });
1667
2955
  groupWrap.appendChild(itemsWrap);
1668
- left.innerHTML = `<span>${element.label || element.key}</span>`;
2956
+ clear(left);
2957
+ const labelSpan = document.createElement("span");
2958
+ labelSpan.textContent = element.label || element.key;
2959
+ left.appendChild(labelSpan);
2960
+ }
2961
+
2962
+ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
2963
+ // Same as renderSingleGroup but with updated naming
2964
+ const containerWrap = document.createElement("div");
2965
+ containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
2966
+ containerWrap.setAttribute("data-container", pathKey);
2967
+
2968
+ const header = document.createElement("div");
2969
+ header.className = "flex justify-between items-center mb-4";
2970
+
2971
+ const left = document.createElement("div");
2972
+ left.className = "flex-1";
2973
+
2974
+ const itemsWrap = document.createElement("div");
2975
+ itemsWrap.className = "space-y-4";
2976
+
2977
+ containerWrap.appendChild(header);
2978
+ header.appendChild(left);
2979
+
2980
+ // Single object container
2981
+ const subCtx = {
2982
+ path: pathJoin(ctx.path, element.key),
2983
+ prefill: ctx.prefill?.[element.key] || {},
2984
+ };
2985
+ element.elements.forEach((child) => {
2986
+ // Skip rendering hidden child elements
2987
+ if (!child.hidden) {
2988
+ itemsWrap.appendChild(renderElement(child, subCtx));
2989
+ }
2990
+ });
2991
+ containerWrap.appendChild(itemsWrap);
2992
+ clear(left);
2993
+ const labelSpan = document.createElement("span");
2994
+ labelSpan.textContent = element.label || element.key;
2995
+ left.appendChild(labelSpan);
2996
+
2997
+ wrapper.appendChild(containerWrap);
2998
+ }
2999
+
3000
+ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
3001
+ // Same as renderRepeatableGroup but with minCount/maxCount from element properties
3002
+ const containerWrap = document.createElement("div");
3003
+ containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
3004
+
3005
+ const header = document.createElement("div");
3006
+ header.className = "flex justify-between items-center mb-4";
3007
+
3008
+ const left = document.createElement("div");
3009
+ left.className = "flex-1";
3010
+
3011
+ const right = document.createElement("div");
3012
+ right.className = "flex gap-2";
3013
+
3014
+ const itemsWrap = document.createElement("div");
3015
+ itemsWrap.className = "space-y-4";
3016
+
3017
+ containerWrap.appendChild(header);
3018
+ header.appendChild(left);
3019
+ if (!state.config.readonly) {
3020
+ header.appendChild(right);
3021
+ }
3022
+
3023
+ const min = element.minCount ?? 0;
3024
+ const max = element.maxCount ?? Infinity;
3025
+ const pre = Array.isArray(ctx.prefill?.[element.key])
3026
+ ? ctx.prefill[element.key]
3027
+ : null;
3028
+
3029
+ const countItems = () =>
3030
+ itemsWrap.querySelectorAll(":scope > .containerItem").length;
3031
+
3032
+ const createAddButton = () => {
3033
+ const add = document.createElement("button");
3034
+ add.type = "button";
3035
+ add.className =
3036
+ "px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors";
3037
+ add.textContent = t("addElement");
3038
+ add.onclick = () => {
3039
+ if (countItems() < max) {
3040
+ const idx = countItems();
3041
+ const subCtx = {
3042
+ path: pathJoin(ctx.path, `${element.key}[${idx}]`),
3043
+ prefill: {},
3044
+ };
3045
+ const item = document.createElement("div");
3046
+ item.className =
3047
+ "containerItem border border-gray-300 rounded-lg p-4 bg-white";
3048
+ item.setAttribute("data-container-item", `${element.key}[${idx}]`);
3049
+
3050
+ element.elements.forEach((child) => {
3051
+ // Skip rendering hidden child elements
3052
+ if (!child.hidden) {
3053
+ item.appendChild(renderElement(child, subCtx));
3054
+ }
3055
+ });
3056
+
3057
+ // Only add remove button in edit mode
3058
+ if (!state.config.readonly) {
3059
+ const rem = document.createElement("button");
3060
+ rem.type = "button";
3061
+ rem.className =
3062
+ "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
3063
+ rem.textContent = "×";
3064
+ rem.onclick = () => {
3065
+ item.remove();
3066
+ updateAddButton();
3067
+ };
3068
+ item.style.position = "relative";
3069
+ item.appendChild(rem);
3070
+ }
3071
+
3072
+ itemsWrap.appendChild(item);
3073
+ updateAddButton();
3074
+ }
3075
+ };
3076
+ return add;
3077
+ };
3078
+
3079
+ const updateAddButton = () => {
3080
+ const currentCount = countItems();
3081
+ const addBtn = right.querySelector("button");
3082
+ if (addBtn) {
3083
+ addBtn.disabled = currentCount >= max;
3084
+ addBtn.style.opacity = currentCount >= max ? "0.5" : "1";
3085
+ }
3086
+ // Create elements safely
3087
+ clear(left);
3088
+ const labelSpan = document.createElement("span");
3089
+ labelSpan.textContent = element.label || element.key;
3090
+ const countSpan = document.createElement("span");
3091
+ countSpan.className = "text-sm text-gray-500";
3092
+ countSpan.textContent = `(${currentCount}/${max === Infinity ? "∞" : max})`;
3093
+ left.appendChild(labelSpan);
3094
+ left.appendChild(document.createTextNode(" "));
3095
+ left.appendChild(countSpan);
3096
+ };
3097
+
3098
+ if (!state.config.readonly) {
3099
+ right.appendChild(createAddButton());
3100
+ }
3101
+
3102
+ // Pre-fill initial items
3103
+ if (pre && Array.isArray(pre)) {
3104
+ pre.forEach((prefillObj, idx) => {
3105
+ const subCtx = {
3106
+ path: pathJoin(ctx.path, `${element.key}[${idx}]`),
3107
+ prefill: prefillObj || {},
3108
+ };
3109
+ const item = document.createElement("div");
3110
+ item.className =
3111
+ "containerItem border border-gray-300 rounded-lg p-4 bg-white";
3112
+ item.setAttribute("data-container-item", `${element.key}[${idx}]`);
3113
+
3114
+ element.elements.forEach((child) => {
3115
+ // Skip rendering hidden child elements
3116
+ if (!child.hidden) {
3117
+ item.appendChild(renderElement(child, subCtx));
3118
+ }
3119
+ });
3120
+
3121
+ // Only add remove button in edit mode
3122
+ if (!state.config.readonly) {
3123
+ const rem = document.createElement("button");
3124
+ rem.type = "button";
3125
+ rem.className =
3126
+ "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
3127
+ rem.textContent = "×";
3128
+ rem.onclick = () => {
3129
+ item.remove();
3130
+ updateAddButton();
3131
+ };
3132
+ item.style.position = "relative";
3133
+ item.appendChild(rem);
3134
+ }
3135
+
3136
+ itemsWrap.appendChild(item);
3137
+ });
3138
+ }
3139
+
3140
+ // Ensure minimum items
3141
+ if (!state.config.readonly) {
3142
+ while (countItems() < min) {
3143
+ const idx = countItems();
3144
+ const subCtx = {
3145
+ path: pathJoin(ctx.path, `${element.key}[${idx}]`),
3146
+ prefill: {},
3147
+ };
3148
+ const item = document.createElement("div");
3149
+ item.className =
3150
+ "containerItem border border-gray-300 rounded-lg p-4 bg-white";
3151
+ item.setAttribute("data-container-item", `${element.key}[${idx}]`);
3152
+
3153
+ element.elements.forEach((child) => {
3154
+ // Skip rendering hidden child elements
3155
+ if (!child.hidden) {
3156
+ item.appendChild(renderElement(child, subCtx));
3157
+ }
3158
+ });
3159
+
3160
+ // Remove button - but disabled if we're at minimum
3161
+ const rem = document.createElement("button");
3162
+ rem.type = "button";
3163
+ rem.className =
3164
+ "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
3165
+ rem.textContent = "×";
3166
+ rem.onclick = () => {
3167
+ if (countItems() > min) {
3168
+ item.remove();
3169
+ updateAddButton();
3170
+ }
3171
+ };
3172
+ item.style.position = "relative";
3173
+ item.appendChild(rem);
3174
+
3175
+ itemsWrap.appendChild(item);
3176
+ }
3177
+ }
3178
+
3179
+ containerWrap.appendChild(itemsWrap);
3180
+ updateAddButton();
3181
+
3182
+ wrapper.appendChild(containerWrap);
1669
3183
  }
1670
3184
 
1671
3185
  // Common file preview rendering function for readonly mode
@@ -1699,17 +3213,29 @@ function renderFilePreviewReadonly(resourceId, fileName) {
1699
3213
  try {
1700
3214
  const thumbnailUrl = state.config.getThumbnail(resourceId);
1701
3215
  if (thumbnailUrl) {
1702
- previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
3216
+ const img = document.createElement("img");
3217
+ img.src = thumbnailUrl;
3218
+ img.alt = actualFileName || "";
3219
+ img.className = "w-full h-auto";
3220
+ clear(previewContainer);
3221
+ previewContainer.appendChild(img);
1703
3222
  } else {
1704
3223
  // Fallback to icon if getThumbnail returns null/undefined
1705
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
3224
+ clear(previewContainer);
3225
+ previewContainer.appendChild(
3226
+ createPreviewElement("🖼️", actualFileName),
3227
+ );
1706
3228
  }
1707
3229
  } catch (error) {
1708
3230
  console.warn("getThumbnail failed for", resourceId, error);
1709
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
3231
+ clear(previewContainer);
3232
+ previewContainer.appendChild(
3233
+ createPreviewElement("🖼️", actualFileName),
3234
+ );
1710
3235
  }
1711
3236
  } else {
1712
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
3237
+ clear(previewContainer);
3238
+ previewContainer.appendChild(createPreviewElement("🖼️", actualFileName));
1713
3239
  }
1714
3240
  } else if (isVideo) {
1715
3241
  // Video preview - try getThumbnail for video URL
@@ -1717,34 +3243,76 @@ function renderFilePreviewReadonly(resourceId, fileName) {
1717
3243
  try {
1718
3244
  const videoUrl = state.config.getThumbnail(resourceId);
1719
3245
  if (videoUrl) {
1720
- previewContainer.innerHTML = `
1721
- <div class="relative group">
1722
- <video class="w-full h-auto" controls preload="auto" muted>
1723
- <source src="${videoUrl}" type="${meta?.type || "video/mp4"}">
1724
- Ваш браузер не поддерживает видео.
1725
- </video>
1726
- <div class="absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
1727
- <div class="bg-white bg-opacity-90 rounded-full p-3">
1728
- <svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
1729
- <path d="M8 5v14l11-7z"/>
1730
- </svg>
1731
- </div>
1732
- </div>
1733
- </div>
1734
- `;
3246
+ // Create video elements safely
3247
+ const wrapper = document.createElement("div");
3248
+ wrapper.className = "relative group";
3249
+
3250
+ const video = document.createElement("video");
3251
+ video.className = "w-full h-auto";
3252
+ video.controls = true;
3253
+ video.preload = "auto";
3254
+ video.muted = true;
3255
+
3256
+ const source = document.createElement("source");
3257
+ source.src = videoUrl;
3258
+ source.type = meta?.type || "video/mp4";
3259
+
3260
+ video.appendChild(source);
3261
+ video.appendChild(
3262
+ document.createTextNode("Ваш браузер не поддерживает видео."),
3263
+ );
3264
+
3265
+ const overlay = document.createElement("div");
3266
+ overlay.className =
3267
+ "absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none";
3268
+
3269
+ const overlayContent = document.createElement("div");
3270
+ overlayContent.className = "bg-white bg-opacity-90 rounded-full p-3";
3271
+
3272
+ const svg = document.createElementNS(
3273
+ "http://www.w3.org/2000/svg",
3274
+ "svg",
3275
+ );
3276
+ svg.setAttribute("class", "w-8 h-8 text-gray-800");
3277
+ svg.setAttribute("fill", "currentColor");
3278
+ svg.setAttribute("viewBox", "0 0 24 24");
3279
+
3280
+ const path = document.createElementNS(
3281
+ "http://www.w3.org/2000/svg",
3282
+ "path",
3283
+ );
3284
+ path.setAttribute("d", "M8 5v14l11-7z");
3285
+
3286
+ svg.appendChild(path);
3287
+ overlayContent.appendChild(svg);
3288
+ overlay.appendChild(overlayContent);
3289
+
3290
+ wrapper.appendChild(video);
3291
+ wrapper.appendChild(overlay);
3292
+
3293
+ clear(previewContainer);
3294
+ previewContainer.appendChild(wrapper);
1735
3295
  } else {
1736
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
3296
+ clear(previewContainer);
3297
+ previewContainer.appendChild(
3298
+ createPreviewElement("🎥", actualFileName),
3299
+ );
1737
3300
  }
1738
3301
  } catch (error) {
1739
3302
  console.warn("getThumbnail failed for video", resourceId, error);
1740
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
3303
+ clear(previewContainer);
3304
+ previewContainer.appendChild(
3305
+ createPreviewElement("🎥", actualFileName),
3306
+ );
1741
3307
  }
1742
3308
  } else {
1743
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
3309
+ clear(previewContainer);
3310
+ previewContainer.appendChild(createPreviewElement("🎥", actualFileName));
1744
3311
  }
1745
3312
  } else {
1746
3313
  // Other file types
1747
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">📁</div><div class="text-sm">${actualFileName}</div></div></div>`;
3314
+ clear(previewContainer);
3315
+ previewContainer.appendChild(createPreviewElement("📁", actualFileName));
1748
3316
  }
1749
3317
 
1750
3318
  // File name
@@ -1910,6 +3478,8 @@ function saveDraft() {
1910
3478
 
1911
3479
  function clearForm() {
1912
3480
  if (state.formRoot) {
3481
+ // Clean up any existing object URLs before clearing form
3482
+ revokeAllObjectURLs();
1913
3483
  clear(state.formRoot);
1914
3484
  }
1915
3485
  }