@dmitryvim/form-builder 0.2.34 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/index.js CHANGED
@@ -399,6 +399,113 @@ function deepEqual(a, b) {
399
399
  }
400
400
 
401
401
  // src/utils/styles.ts
402
+ function clearFieldError(input) {
403
+ const name = input.getAttribute("name");
404
+ if (!name) return;
405
+ const doc = input.ownerDocument || document;
406
+ const errorNode = doc.getElementById(`error-${name}`);
407
+ if (errorNode) errorNode.remove();
408
+ }
409
+ var BIN_ICON_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
410
+ function ensureThemingHooks(doc) {
411
+ if (doc.head.querySelector("[data-fb-theming-hooks]")) return;
412
+ const style = doc.createElement("style");
413
+ style.setAttribute("data-fb-theming-hooks", "");
414
+ style.textContent = `
415
+ [data-fb-slide-card] {
416
+ background: var(--fb-slide-card-bg);
417
+ box-shadow: var(--fb-slide-card-shadow);
418
+ border-radius: var(--fb-slide-card-radius);
419
+ min-height: var(--fb-slide-card-min-height);
420
+ padding: var(--fb-slide-card-padding);
421
+ }
422
+ [data-fb-label-row] > label {
423
+ font-size: var(--fb-label-section-font-size);
424
+ letter-spacing: var(--fb-label-section-letter-spacing);
425
+ text-transform: var(--fb-label-section-text-transform);
426
+ }
427
+ /* Per-item remove (trash) button used by multi-container items. Shares the
428
+ same faint-on-rest, error-on-hover palette as .fb-chip-remove. */
429
+ .fb-item-remove {
430
+ color: var(--fb-text-faint-color, #94a3b8);
431
+ background-color: transparent;
432
+ transition: color var(--fb-transition-duration), background-color var(--fb-transition-duration);
433
+ }
434
+ .fb-item-remove:hover {
435
+ color: var(--fb-error-color);
436
+ background-color: var(--fb-background-hover-color);
437
+ }
438
+ /* Prefill-suggestion pills rendered by createPrefillHints. Outline pill at rest,
439
+ soft-fill on hover, solid-fill when selected. All colors flow from the active
440
+ theme \u2014 consumers don't need to ship their own CSS. */
441
+ .fb-prefill-hint {
442
+ padding: 0.25rem 0.625rem;
443
+ border: var(--fb-border-width) solid var(--fb-primary-color);
444
+ border-radius: 9999px;
445
+ background: var(--fb-background-color);
446
+ color: var(--fb-primary-color);
447
+ font-size: var(--fb-font-size-small);
448
+ font-weight: var(--fb-font-weight-medium);
449
+ font-family: var(--fb-font-family);
450
+ cursor: pointer;
451
+ transition: background-color var(--fb-transition-duration), border-color var(--fb-transition-duration), color var(--fb-transition-duration);
452
+ }
453
+ .fb-prefill-hint:hover {
454
+ background: var(--fb-primary-soft-color);
455
+ border-color: var(--fb-primary-hover-color);
456
+ color: var(--fb-primary-hover-color);
457
+ }
458
+ .fb-prefill-hint:focus-visible {
459
+ outline: var(--fb-focus-ring-width) solid var(--fb-focus-ring-color);
460
+ outline-offset: 2px;
461
+ }
462
+ .fb-prefill-hint[aria-pressed="true"],
463
+ .fb-prefill-hint.active {
464
+ background: var(--fb-primary-color);
465
+ color: #ffffff;
466
+ border-color: var(--fb-primary-color);
467
+ }
468
+ `;
469
+ doc.head.appendChild(style);
470
+ }
471
+ function applyAutoExpand(textarea) {
472
+ textarea.style.overflow = "hidden";
473
+ textarea.style.resize = "none";
474
+ const lineCount = (textarea.value.match(/\n/g) || []).length + 1;
475
+ textarea.rows = Math.max(1, lineCount);
476
+ const resize = () => {
477
+ if (!textarea.isConnected) return;
478
+ textarea.style.height = "0";
479
+ const cs = getComputedStyle(textarea);
480
+ const borderY = parseFloat(cs.borderTopWidth || "0") + parseFloat(cs.borderBottomWidth || "0");
481
+ textarea.style.height = `${textarea.scrollHeight + borderY}px`;
482
+ };
483
+ textarea.addEventListener("input", resize);
484
+ setTimeout(() => {
485
+ if (textarea.isConnected) resize();
486
+ }, 0);
487
+ }
488
+ function applySingleLineMode(textarea) {
489
+ textarea.addEventListener("keydown", (e) => {
490
+ if (e.key === "Enter") {
491
+ e.preventDefault();
492
+ }
493
+ });
494
+ textarea.addEventListener("paste", (e) => {
495
+ const pasted = e.clipboardData?.getData("text") ?? "";
496
+ if (!/[\r\n]/.test(pasted)) return;
497
+ e.preventDefault();
498
+ const cleaned = pasted.replace(/[\r\n]+/g, " ");
499
+ const start = textarea.selectionStart ?? textarea.value.length;
500
+ const end = textarea.selectionEnd ?? textarea.value.length;
501
+ const before = textarea.value.slice(0, start);
502
+ const after = textarea.value.slice(end);
503
+ textarea.value = before + cleaned + after;
504
+ const pos = start + cleaned.length;
505
+ textarea.setSelectionRange(pos, pos);
506
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
507
+ });
508
+ }
402
509
  function mountCounterInLabel(wrapper, counter) {
403
510
  const labelRow = wrapper.querySelector(
404
511
  ":scope > [data-fb-label-row]"
@@ -560,46 +667,94 @@ function applyActionButtonStyles(button, isFormLevel = false) {
560
667
  }
561
668
 
562
669
  // src/components/text.ts
563
- function createCharCounter(element, input, isTextarea = false) {
670
+ function ensureChipStyles(doc) {
671
+ if (doc.head.querySelector("[data-fb-chip-styles]")) return;
672
+ const style = doc.createElement("style");
673
+ style.setAttribute("data-fb-chip-styles", "");
674
+ style.textContent = `
675
+ .fb-chip-list { display: flex; flex-direction: column; gap: 4px; }
676
+ .fb-chip {
677
+ display: flex;
678
+ align-items: center;
679
+ gap: 8px;
680
+ padding: 5px 6px 5px 10px;
681
+ background: var(--fb-chip-bg, var(--fb-background-color, #fff));
682
+ border: 1px solid var(--fb-chip-border, var(--fb-border-color, #e2e8f0));
683
+ border-radius: 6px;
684
+ position: relative;
685
+ transition: border-color var(--fb-transition-duration, 0.15s);
686
+ }
687
+ .fb-chip:hover { border-color: var(--fb-border-hover-color, var(--fb-border-color, #cbd5e1)); }
688
+ .fb-chip:focus-within { border-color: var(--fb-border-focus-color, var(--fb-primary-color, #2f5bea)); }
689
+ .fb-chip-dot {
690
+ flex: 0 0 6px;
691
+ width: 6px;
692
+ height: 6px;
693
+ border-radius: 50%;
694
+ background: var(--fb-chip-dot, var(--fb-primary-color, #2f5bea));
695
+ }
696
+ .fb-chip-input {
697
+ flex: 1;
698
+ min-width: 0;
699
+ padding: 2px 0;
700
+ border: 0;
701
+ outline: none;
702
+ background: transparent;
703
+ color: var(--fb-chip-text, var(--fb-text-color, inherit));
704
+ font-size: var(--fb-font-size, 14px);
705
+ font-family: var(--fb-font-family, inherit);
706
+ line-height: 1.4;
707
+ }
708
+ .fb-chip-input::placeholder { color: var(--fb-text-placeholder-color, #94a3b8); }
709
+ .fb-chip-input:read-only { color: var(--fb-text-secondary-color, #475569); }
710
+ .fb-chip-remove {
711
+ flex: 0 0 auto;
712
+ width: 22px;
713
+ height: 22px;
714
+ display: inline-flex;
715
+ align-items: center;
716
+ justify-content: center;
717
+ padding: 0;
718
+ border: 0;
719
+ border-radius: 4px;
720
+ background: transparent;
721
+ color: var(--fb-text-faint-color, #94a3b8);
722
+ cursor: pointer;
723
+ opacity: 0;
724
+ transition: opacity 0.12s, color 0.12s, background-color 0.12s;
725
+ }
726
+ .fb-chip:hover .fb-chip-remove,
727
+ .fb-chip-remove:focus-visible { opacity: 1; }
728
+ .fb-chip-remove:hover {
729
+ color: var(--fb-error-color, #dc2626);
730
+ background: var(--fb-background-hover-color, #f1f5f9);
731
+ }
732
+ .fb-chip-remove:disabled { opacity: 0 !important; pointer-events: none; }
733
+ `;
734
+ doc.head.appendChild(style);
735
+ }
736
+ function createCharCounter(element, input) {
564
737
  const counter = document.createElement("span");
565
738
  counter.className = "char-counter";
566
739
  counter.style.cssText = `
567
- position: absolute;
568
- ${isTextarea ? "bottom: 8px" : "top: 50%; transform: translateY(-50%)"};
569
- right: 10px;
740
+ margin-top: 4px;
741
+ padding-right: 12px;
742
+ text-align: right;
570
743
  font-size: var(--fb-font-size-small);
571
- color: var(--fb-text-secondary-color);
744
+ line-height: 1;
745
+ color: var(--fb-error-color);
572
746
  pointer-events: none;
573
- background: var(--fb-background-color);
574
- padding: 0 4px;
747
+ display: none;
575
748
  `;
576
749
  const updateCounter = () => {
577
750
  const len = input.value.length;
578
- const min = element.minLength;
579
751
  const max = element.maxLength;
580
- if (min == null && max == null) {
581
- counter.textContent = "";
582
- return;
583
- }
584
- if (len === 0 || min != null && len < min) {
585
- if (min != null && max != null) {
586
- counter.textContent = `${min}-${max}`;
587
- } else if (max != null) {
588
- counter.textContent = `\u2264${max}`;
589
- } else if (min != null) {
590
- counter.textContent = `\u2265${min}`;
591
- }
592
- counter.style.color = "var(--fb-text-secondary-color)";
593
- } else if (max != null && len > max) {
752
+ if (max != null && len > max) {
594
753
  counter.textContent = `${len}/${max}`;
595
- counter.style.color = "var(--fb-error-color)";
754
+ counter.style.display = "block";
596
755
  } else {
597
- if (max != null) {
598
- counter.textContent = `${len}/${max}`;
599
- } else {
600
- counter.textContent = `${len}`;
601
- }
602
- counter.style.color = "var(--fb-text-secondary-color)";
756
+ counter.textContent = "";
757
+ counter.style.display = "none";
603
758
  }
604
759
  };
605
760
  input.addEventListener("input", updateCounter);
@@ -612,26 +767,32 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
612
767
  const inputWrapper = document.createElement("div");
613
768
  inputWrapper.style.cssText = "position: relative;";
614
769
  const hasCharCounter = !readonly && (element.minLength != null || element.maxLength != null);
615
- const textInput = document.createElement("input");
616
- textInput.type = "text";
770
+ const textInput = document.createElement("textarea");
771
+ textInput.rows = 1;
617
772
  textInput.className = "w-full rounded-lg";
618
773
  textInput.style.cssText = `
619
774
  padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
620
- ${hasCharCounter ? "padding-right: 60px;" : ""}
621
775
  border: var(--fb-border-width) solid var(--fb-border-color);
622
776
  border-radius: var(--fb-border-radius);
623
777
  background-color: ${readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
624
778
  color: var(--fb-text-color);
625
779
  font-size: var(--fb-font-size);
626
780
  font-family: var(--fb-font-family);
781
+ line-height: var(--fb-line-height, 1.5);
627
782
  transition: all var(--fb-transition-duration) ease-in-out;
628
783
  width: 100%;
629
784
  box-sizing: border-box;
785
+ resize: none;
786
+ overflow: hidden;
787
+ word-break: break-word;
788
+ overflow-wrap: anywhere;
630
789
  `;
631
790
  textInput.name = pathKey;
632
791
  textInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
633
792
  textInput.value = ctx.prefill[element.key] || element.default || "";
634
793
  textInput.readOnly = readonly;
794
+ applySingleLineMode(textInput);
795
+ applyAutoExpand(textInput);
635
796
  if (!readonly) {
636
797
  textInput.addEventListener("focus", () => {
637
798
  textInput.style.borderColor = "var(--fb-border-focus-color)";
@@ -663,7 +824,7 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
663
824
  }
664
825
  inputWrapper.appendChild(textInput);
665
826
  if (hasCharCounter) {
666
- const counter = createCharCounter(element, textInput, false);
827
+ const counter = createCharCounter(element, textInput);
667
828
  inputWrapper.appendChild(counter);
668
829
  }
669
830
  wrapper.appendChild(inputWrapper);
@@ -673,132 +834,83 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
673
834
  const readonly = isElementReadonly(element, state, ctx);
674
835
  const prefillValues = ctx.prefill[element.key] || [];
675
836
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
676
- const hasCharCounter = !readonly && (element.minLength != null || element.maxLength != null);
677
837
  const minCount = element.minCount ?? 1;
678
838
  const maxCount = element.maxCount ?? Infinity;
679
839
  while (values.length < minCount) {
680
840
  values.push(element.default || "");
681
841
  }
682
- const container = document.createElement("div");
683
- container.className = "space-y-2";
684
- wrapper.appendChild(container);
842
+ ensureChipStyles(document);
843
+ const list = document.createElement("div");
844
+ list.className = "fb-chip-list";
845
+ wrapper.appendChild(list);
685
846
  function updateIndices() {
686
- const items = container.querySelectorAll(".multiple-text-item");
687
- items.forEach((item, index) => {
688
- const input = item.querySelector("input");
689
- if (input) {
690
- input.name = `${pathKey}[${index}]`;
847
+ const items = list.querySelectorAll(".fb-chip-input");
848
+ items.forEach((input, index) => {
849
+ input.name = `${pathKey}[${index}]`;
850
+ const chip = input.closest(".fb-chip");
851
+ const sib = chip?.nextElementSibling;
852
+ if (sib && sib.classList.contains("error-message")) {
853
+ sib.id = `error-${input.name}`;
691
854
  }
692
855
  });
693
856
  }
694
- function addTextItem(value = "", index = -1) {
695
- const itemWrapper = document.createElement("div");
696
- itemWrapper.className = "multiple-text-item flex items-center gap-2";
697
- const inputContainer = document.createElement("div");
698
- inputContainer.style.cssText = "position: relative; flex: 1;";
699
- const textInput = document.createElement("input");
700
- textInput.type = "text";
701
- textInput.style.cssText = `
702
- padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
703
- ${hasCharCounter ? "padding-right: 60px;" : ""}
704
- border: var(--fb-border-width) solid var(--fb-border-color);
705
- border-radius: var(--fb-border-radius);
706
- background-color: ${readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
707
- color: var(--fb-text-color);
708
- font-size: var(--fb-font-size);
709
- font-family: var(--fb-font-family);
710
- transition: all var(--fb-transition-duration) ease-in-out;
711
- width: 100%;
712
- box-sizing: border-box;
713
- `;
714
- textInput.placeholder = element.placeholder || t("placeholderText", state);
715
- textInput.value = value;
716
- textInput.readOnly = readonly;
717
- if (!readonly) {
718
- textInput.addEventListener("focus", () => {
719
- textInput.style.borderColor = "var(--fb-border-focus-color)";
720
- textInput.style.outline = `var(--fb-focus-ring-width) solid var(--fb-focus-ring-color)`;
721
- textInput.style.outlineOffset = "0";
722
- });
723
- textInput.addEventListener("blur", () => {
724
- textInput.style.borderColor = "var(--fb-border-color)";
725
- textInput.style.outline = "none";
726
- });
727
- textInput.addEventListener("mouseenter", () => {
728
- if (document.activeElement !== textInput) {
729
- textInput.style.borderColor = "var(--fb-border-hover-color)";
730
- }
731
- });
732
- textInput.addEventListener("mouseleave", () => {
733
- if (document.activeElement !== textInput) {
734
- textInput.style.borderColor = "var(--fb-border-color)";
735
- }
736
- });
737
- }
857
+ function addChip(value = "") {
858
+ const chip = document.createElement("div");
859
+ chip.className = "fb-chip";
860
+ const dot = document.createElement("span");
861
+ dot.className = "fb-chip-dot";
862
+ dot.setAttribute("aria-hidden", "true");
863
+ chip.appendChild(dot);
864
+ const input = document.createElement("input");
865
+ input.type = "text";
866
+ input.className = "fb-chip-input";
867
+ input.value = value;
868
+ input.placeholder = element.placeholder || t("placeholderText", state);
869
+ input.readOnly = readonly;
870
+ chip.appendChild(input);
738
871
  if (!readonly && ctx.instance) {
739
872
  const handleChange = () => {
740
- const value2 = textInput.value === "" ? null : textInput.value;
741
- ctx.instance.triggerOnChange(textInput.name, value2);
873
+ ctx.instance.triggerOnChange(
874
+ input.name,
875
+ input.value === "" ? null : input.value
876
+ );
742
877
  };
743
- textInput.addEventListener("blur", handleChange);
744
- textInput.addEventListener("input", handleChange);
745
- }
746
- inputContainer.appendChild(textInput);
747
- if (hasCharCounter) {
748
- const counter = createCharCounter(element, textInput, false);
749
- inputContainer.appendChild(counter);
878
+ input.addEventListener("blur", handleChange);
879
+ input.addEventListener("input", handleChange);
750
880
  }
751
- itemWrapper.appendChild(inputContainer);
752
- if (index === -1) {
753
- container.appendChild(itemWrapper);
754
- } else {
755
- container.insertBefore(itemWrapper, container.children[index]);
881
+ if (!readonly) {
882
+ const rem = document.createElement("button");
883
+ rem.type = "button";
884
+ rem.className = "fb-chip-remove";
885
+ rem.setAttribute("aria-label", t("removeElement", state));
886
+ rem.innerHTML = BIN_ICON_SVG;
887
+ rem.onclick = () => {
888
+ const chips = list.querySelectorAll(".fb-chip");
889
+ const idx = Array.prototype.indexOf.call(chips, chip);
890
+ if (idx < 0) return;
891
+ if (chips.length <= minCount) return;
892
+ values.splice(idx, 1);
893
+ const trailingError = chip.nextElementSibling;
894
+ if (trailingError && trailingError.classList.contains("error-message")) {
895
+ trailingError.remove();
896
+ }
897
+ chip.remove();
898
+ updateIndices();
899
+ updateAddButton();
900
+ updateRemoveButtons();
901
+ };
902
+ chip.appendChild(rem);
756
903
  }
904
+ list.appendChild(chip);
757
905
  updateIndices();
758
- return itemWrapper;
906
+ return chip;
759
907
  }
760
908
  function updateRemoveButtons() {
761
909
  if (readonly) return;
762
- const items = container.querySelectorAll(".multiple-text-item");
763
- const currentCount = items.length;
764
- items.forEach((item) => {
765
- let removeBtn = item.querySelector(
766
- ".remove-item-btn"
767
- );
768
- if (!removeBtn) {
769
- removeBtn = document.createElement("button");
770
- removeBtn.type = "button";
771
- removeBtn.className = "remove-item-btn px-2 py-1 rounded";
772
- removeBtn.style.cssText = `
773
- color: var(--fb-error-color);
774
- background-color: transparent;
775
- transition: background-color var(--fb-transition-duration);
776
- `;
777
- removeBtn.innerHTML = "\u2715";
778
- removeBtn.addEventListener("mouseenter", () => {
779
- removeBtn.style.backgroundColor = "var(--fb-background-hover-color)";
780
- });
781
- removeBtn.addEventListener("mouseleave", () => {
782
- removeBtn.style.backgroundColor = "transparent";
783
- });
784
- removeBtn.onclick = () => {
785
- const currentIndex = Array.from(container.children).indexOf(
786
- item
787
- );
788
- if (container.children.length > minCount) {
789
- values.splice(currentIndex, 1);
790
- item.remove();
791
- updateIndices();
792
- updateAddButton();
793
- updateRemoveButtons();
794
- }
795
- };
796
- item.appendChild(removeBtn);
797
- }
798
- const disabled = currentCount <= minCount;
799
- removeBtn.disabled = disabled;
800
- removeBtn.style.opacity = disabled ? "0.5" : "1";
801
- removeBtn.style.pointerEvents = disabled ? "none" : "auto";
910
+ const chipCount = list.querySelectorAll(".fb-chip").length;
911
+ const disabled = chipCount <= minCount;
912
+ list.querySelectorAll(".fb-chip-remove").forEach((btn) => {
913
+ btn.disabled = disabled;
802
914
  });
803
915
  }
804
916
  let addUpdate = null;
@@ -807,7 +919,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
807
919
  "text",
808
920
  () => {
809
921
  values.push(element.default || "");
810
- addTextItem(element.default || "");
922
+ addChip(element.default || "");
811
923
  updateAddButton();
812
924
  updateRemoveButtons();
813
925
  },
@@ -820,7 +932,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
820
932
  function updateAddButton() {
821
933
  if (addUpdate) addUpdate(values.length, maxCount);
822
934
  }
823
- values.forEach((value) => addTextItem(value));
935
+ values.forEach((value) => addChip(value));
824
936
  updateAddButton();
825
937
  updateRemoveButtons();
826
938
  }
@@ -843,10 +955,12 @@ function validateTextElement(element, key, context) {
843
955
  font-size: var(--fb-font-size-small);
844
956
  margin-top: 0.25rem;
845
957
  `;
846
- if (input.nextSibling) {
847
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
958
+ const chipAncestor = input.closest?.(".fb-chip");
959
+ const anchor = chipAncestor || input;
960
+ if (anchor.nextSibling) {
961
+ anchor.parentNode?.insertBefore(errorElement, anchor.nextSibling);
848
962
  } else {
849
- input.parentNode?.appendChild(errorElement);
963
+ anchor.parentNode?.appendChild(errorElement);
850
964
  }
851
965
  }
852
966
  errorElement.textContent = errorMessage;
@@ -895,7 +1009,7 @@ function validateTextElement(element, key, context) {
895
1009
  }
896
1010
  };
897
1011
  if (element.multiple) {
898
- const inputs = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1012
+ const inputs = scopeRoot.querySelectorAll(`[name^="${key}\\["]`);
899
1013
  const values = [];
900
1014
  const rawValues = [];
901
1015
  inputs.forEach((input, index) => {
@@ -944,12 +1058,14 @@ function updateTextField(element, fieldPath, value, context) {
944
1058
  );
945
1059
  return;
946
1060
  }
947
- const inputs = scopeRoot.querySelectorAll(`[name^="${fieldPath}["]`);
1061
+ const inputs = scopeRoot.querySelectorAll(`[name^="${fieldPath}\\["]`);
948
1062
  inputs.forEach((input, index) => {
949
1063
  if (index < value.length) {
950
1064
  input.value = value[index] != null ? String(value[index]) : "";
951
1065
  input.classList.remove("invalid");
952
1066
  input.title = "";
1067
+ clearFieldError(input);
1068
+ input.dispatchEvent(new Event("input", { bubbles: true }));
953
1069
  }
954
1070
  });
955
1071
  if (value.length !== inputs.length) {
@@ -963,26 +1079,15 @@ function updateTextField(element, fieldPath, value, context) {
963
1079
  input.value = value != null ? String(value) : "";
964
1080
  input.classList.remove("invalid");
965
1081
  input.title = "";
1082
+ clearFieldError(input);
1083
+ if (input instanceof HTMLTextAreaElement) {
1084
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1085
+ }
966
1086
  }
967
1087
  }
968
1088
  }
969
1089
 
970
1090
  // src/components/textarea.ts
971
- function applyAutoExpand(textarea) {
972
- textarea.style.overflow = "hidden";
973
- textarea.style.resize = "none";
974
- const lineCount = (textarea.value.match(/\n/g) || []).length + 1;
975
- textarea.rows = Math.max(1, lineCount);
976
- const resize = () => {
977
- if (!textarea.isConnected) return;
978
- textarea.style.height = "0";
979
- textarea.style.height = `${textarea.scrollHeight}px`;
980
- };
981
- textarea.addEventListener("input", resize);
982
- setTimeout(() => {
983
- if (textarea.isConnected) resize();
984
- }, 0);
985
- }
986
1091
  function renderTextareaElement(element, ctx, wrapper, pathKey) {
987
1092
  const state = ctx.state;
988
1093
  const readonly = isElementReadonly(element, state, ctx);
@@ -1013,7 +1118,7 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1013
1118
  }
1014
1119
  textareaWrapper.appendChild(textareaInput);
1015
1120
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1016
- const counter = createCharCounter(element, textareaInput, true);
1121
+ const counter = createCharCounter(element, textareaInput);
1017
1122
  textareaWrapper.appendChild(counter);
1018
1123
  }
1019
1124
  wrapper.appendChild(textareaWrapper);
@@ -1069,7 +1174,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1069
1174
  }
1070
1175
  textareaContainer.appendChild(textareaInput);
1071
1176
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1072
- const counter = createCharCounter(element, textareaInput, true);
1177
+ const counter = createCharCounter(element, textareaInput);
1073
1178
  textareaContainer.appendChild(counter);
1074
1179
  }
1075
1180
  itemWrapper.appendChild(textareaContainer);
@@ -1163,6 +1268,89 @@ function updateTextareaField(element, fieldPath, value, context) {
1163
1268
  }
1164
1269
 
1165
1270
  // src/components/number.ts
1271
+ function ensureStepperStyles(doc) {
1272
+ const ID = "fb-number-stepper-styles";
1273
+ if (doc.getElementById(ID)) return;
1274
+ const style = doc.createElement("style");
1275
+ style.id = ID;
1276
+ style.textContent = `
1277
+ .fb-stepper-input::-webkit-outer-spin-button,
1278
+ .fb-stepper-input::-webkit-inner-spin-button {
1279
+ -webkit-appearance: none;
1280
+ margin: 0;
1281
+ }
1282
+ .fb-stepper-input { -moz-appearance: textfield; }
1283
+ `;
1284
+ doc.head.appendChild(style);
1285
+ }
1286
+ function buildStepper(input, element, readonly) {
1287
+ ensureStepperStyles(input.ownerDocument);
1288
+ const step = element.step ?? 1;
1289
+ const min = element.min;
1290
+ const max = element.max;
1291
+ const wrap = document.createElement("div");
1292
+ wrap.className = "fb-stepper";
1293
+ wrap.style.cssText = `
1294
+ display: inline-flex;
1295
+ align-items: stretch;
1296
+ border: var(--fb-border-width) solid var(--fb-border-color);
1297
+ border-radius: var(--fb-border-radius);
1298
+ overflow: hidden;
1299
+ background: var(--fb-background-color);
1300
+ `;
1301
+ const makeBtn = (label, delta) => {
1302
+ const b = document.createElement("button");
1303
+ b.type = "button";
1304
+ b.textContent = label;
1305
+ b.tabIndex = -1;
1306
+ b.style.cssText = `
1307
+ width: 32px;
1308
+ border: none;
1309
+ background: transparent;
1310
+ color: var(--fb-text-color);
1311
+ font-size: var(--fb-font-size);
1312
+ font-family: var(--fb-font-family);
1313
+ cursor: ${readonly ? "default" : "pointer"};
1314
+ user-select: none;
1315
+ `;
1316
+ if (readonly) {
1317
+ b.disabled = true;
1318
+ b.style.opacity = "0.5";
1319
+ } else {
1320
+ b.addEventListener("click", (e) => {
1321
+ e.preventDefault();
1322
+ const current = parseFloat(input.value);
1323
+ const base = Number.isFinite(current) ? current : min ?? element.default ?? 0;
1324
+ let next = parseFloat((base + delta * step).toPrecision(12));
1325
+ if (min != null) next = Math.max(min, next);
1326
+ if (max != null) next = Math.min(max, next);
1327
+ input.value = String(next);
1328
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1329
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1330
+ });
1331
+ }
1332
+ return b;
1333
+ };
1334
+ input.classList.add("fb-stepper-input");
1335
+ input.style.cssText = `
1336
+ width: 56px;
1337
+ border: none;
1338
+ border-left: var(--fb-border-width) solid var(--fb-border-color);
1339
+ border-right: var(--fb-border-width) solid var(--fb-border-color);
1340
+ padding: var(--fb-input-padding-y) 0;
1341
+ font-size: var(--fb-font-size);
1342
+ font-family: var(--fb-font-family);
1343
+ text-align: center;
1344
+ background: transparent;
1345
+ color: var(--fb-text-color);
1346
+ -moz-appearance: textfield;
1347
+ box-sizing: border-box;
1348
+ `;
1349
+ wrap.appendChild(makeBtn("\u2212", -1));
1350
+ wrap.appendChild(input);
1351
+ wrap.appendChild(makeBtn("+", 1));
1352
+ return wrap;
1353
+ }
1166
1354
  function createNumberRangeHint(element, input) {
1167
1355
  const hint = document.createElement("span");
1168
1356
  hint.className = "number-range-hint";
@@ -1209,14 +1397,6 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1209
1397
  inputWrapper.style.cssText = "position: relative;";
1210
1398
  const numberInput = document.createElement("input");
1211
1399
  numberInput.type = "number";
1212
- numberInput.className = "w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1213
- numberInput.style.cssText = `
1214
- padding: var(--fb-input-padding-y) 60px var(--fb-input-padding-y) var(--fb-input-padding-x);
1215
- font-size: var(--fb-font-size);
1216
- font-family: var(--fb-font-family);
1217
- width: 100%;
1218
- box-sizing: border-box;
1219
- `;
1220
1400
  numberInput.name = pathKey;
1221
1401
  numberInput.placeholder = element.placeholder || "0";
1222
1402
  if (element.min !== void 0) numberInput.min = element.min.toString();
@@ -1224,6 +1404,16 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1224
1404
  if (element.step !== void 0) numberInput.step = element.step.toString();
1225
1405
  numberInput.value = ctx.prefill[element.key] || element.default || "";
1226
1406
  numberInput.readOnly = readonly;
1407
+ if (!element.stepper) {
1408
+ numberInput.className = "w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1409
+ numberInput.style.cssText = `
1410
+ padding: var(--fb-input-padding-y) 60px var(--fb-input-padding-y) var(--fb-input-padding-x);
1411
+ font-size: var(--fb-font-size);
1412
+ font-family: var(--fb-font-family);
1413
+ width: 100%;
1414
+ box-sizing: border-box;
1415
+ `;
1416
+ }
1227
1417
  if (!readonly && ctx.instance) {
1228
1418
  const handleChange = () => {
1229
1419
  const value = numberInput.value ? parseFloat(numberInput.value) : null;
@@ -1232,10 +1422,14 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1232
1422
  numberInput.addEventListener("blur", handleChange);
1233
1423
  numberInput.addEventListener("input", handleChange);
1234
1424
  }
1235
- inputWrapper.appendChild(numberInput);
1236
- if (!readonly && (element.min != null || element.max != null)) {
1237
- const counter = createNumberRangeHint(element, numberInput);
1238
- inputWrapper.appendChild(counter);
1425
+ if (element.stepper) {
1426
+ inputWrapper.appendChild(buildStepper(numberInput, element, readonly));
1427
+ } else {
1428
+ inputWrapper.appendChild(numberInput);
1429
+ if (!readonly && (element.min != null || element.max != null)) {
1430
+ const counter = createNumberRangeHint(element, numberInput);
1431
+ inputWrapper.appendChild(counter);
1432
+ }
1239
1433
  }
1240
1434
  wrapper.appendChild(inputWrapper);
1241
1435
  }
@@ -1489,13 +1683,14 @@ function updateNumberField(element, fieldPath, value, context) {
1489
1683
  return;
1490
1684
  }
1491
1685
  const inputs = scopeRoot.querySelectorAll(
1492
- `[name^="${fieldPath}["]`
1686
+ `[name^="${fieldPath}\\["]`
1493
1687
  );
1494
1688
  inputs.forEach((input, index) => {
1495
1689
  if (index < value.length) {
1496
1690
  input.value = value[index] != null ? String(value[index]) : "";
1497
1691
  input.classList.remove("invalid");
1498
1692
  input.title = "";
1693
+ clearFieldError(input);
1499
1694
  }
1500
1695
  });
1501
1696
  if (value.length !== inputs.length) {
@@ -1511,6 +1706,7 @@ function updateNumberField(element, fieldPath, value, context) {
1511
1706
  input.value = value != null ? String(value) : "";
1512
1707
  input.classList.remove("invalid");
1513
1708
  input.title = "";
1709
+ clearFieldError(input);
1514
1710
  }
1515
1711
  }
1516
1712
  }
@@ -1722,7 +1918,7 @@ function validateSelectElement(element, key, context) {
1722
1918
  };
1723
1919
  if ("multiple" in element && element.multiple) {
1724
1920
  const inputs = scopeRoot.querySelectorAll(
1725
- `[name^="${key}["]`
1921
+ `[name^="${key}\\["]`
1726
1922
  );
1727
1923
  const values = [];
1728
1924
  inputs.forEach((input) => {
@@ -1758,7 +1954,7 @@ function updateSelectField(element, fieldPath, value, context) {
1758
1954
  return;
1759
1955
  }
1760
1956
  const selects = scopeRoot.querySelectorAll(
1761
- `[name^="${fieldPath}["]`
1957
+ `[name^="${fieldPath}\\["]`
1762
1958
  );
1763
1959
  selects.forEach((select, index) => {
1764
1960
  if (index < value.length) {
@@ -1769,6 +1965,7 @@ function updateSelectField(element, fieldPath, value, context) {
1769
1965
  });
1770
1966
  select.classList.remove("invalid");
1771
1967
  select.title = "";
1968
+ clearFieldError(select);
1772
1969
  }
1773
1970
  });
1774
1971
  if (value.length !== selects.length) {
@@ -1788,72 +1985,147 @@ function updateSelectField(element, fieldPath, value, context) {
1788
1985
  });
1789
1986
  select.classList.remove("invalid");
1790
1987
  select.title = "";
1988
+ clearFieldError(select);
1791
1989
  }
1792
1990
  }
1793
1991
  }
1794
1992
 
1795
1993
  // src/components/switcher.ts
1796
- function applySelectedStyle(btn) {
1797
- btn.style.backgroundColor = "var(--fb-primary-color)";
1798
- btn.style.color = "#ffffff";
1799
- btn.style.borderColor = "var(--fb-primary-color)";
1994
+ function applySelectedStyle(btn, isPreset) {
1995
+ if (isPreset) {
1996
+ btn.style.backgroundColor = "var(--fb-primary-soft-color)";
1997
+ btn.style.color = "var(--fb-primary-color)";
1998
+ btn.style.borderColor = "var(--fb-primary-color)";
1999
+ } else {
2000
+ btn.style.backgroundColor = "var(--fb-primary-color)";
2001
+ btn.style.color = "#ffffff";
2002
+ btn.style.borderColor = "var(--fb-primary-color)";
2003
+ }
1800
2004
  }
1801
- function applyUnselectedStyle(btn) {
1802
- btn.style.backgroundColor = "transparent";
2005
+ function applyUnselectedStyle(btn, isPreset) {
2006
+ btn.style.backgroundColor = isPreset ? "var(--fb-background-color)" : "transparent";
1803
2007
  btn.style.color = "var(--fb-text-color)";
1804
2008
  btn.style.borderColor = "var(--fb-border-color)";
1805
2009
  }
2010
+ function isPresetButton(btn) {
2011
+ return btn.classList.contains("fb-switcher-preset");
2012
+ }
2013
+ function buildPresetCard(option, readonly) {
2014
+ const btn = document.createElement("button");
2015
+ btn.type = "button";
2016
+ btn.className = "fb-switcher-btn fb-switcher-preset";
2017
+ btn.dataset.value = option.value;
2018
+ btn.style.cssText = `
2019
+ display: inline-flex;
2020
+ align-items: center;
2021
+ gap: 8px;
2022
+ padding: 7px 12px 7px 10px;
2023
+ border-width: var(--fb-border-width);
2024
+ border-style: solid;
2025
+ border-radius: 999px;
2026
+ background: var(--fb-background-color);
2027
+ font-size: var(--fb-font-size);
2028
+ font-family: var(--fb-font-family);
2029
+ line-height: 1.25;
2030
+ cursor: ${readonly ? "default" : "pointer"};
2031
+ transition: background-color var(--fb-transition-duration), color var(--fb-transition-duration), border-color var(--fb-transition-duration);
2032
+ outline: none;
2033
+ `;
2034
+ if (option.iconUrl) {
2035
+ const icon = document.createElement("img");
2036
+ icon.className = "fb-switcher-icon";
2037
+ icon.src = option.iconUrl;
2038
+ icon.alt = "";
2039
+ icon.setAttribute("aria-hidden", "true");
2040
+ icon.style.cssText = `
2041
+ display: block;
2042
+ flex: 0 0 auto;
2043
+ width: 20px;
2044
+ height: 20px;
2045
+ object-fit: contain;
2046
+ `;
2047
+ btn.appendChild(icon);
2048
+ }
2049
+ const name = document.createElement("span");
2050
+ name.className = "fb-switcher-name";
2051
+ name.textContent = option.label;
2052
+ name.style.cssText = "font-weight: 600;";
2053
+ btn.appendChild(name);
2054
+ if (option.subtitle) {
2055
+ const sub = document.createElement("span");
2056
+ sub.className = "fb-switcher-subtitle";
2057
+ sub.textContent = option.subtitle;
2058
+ sub.style.cssText = `
2059
+ font-size: var(--fb-font-size-small);
2060
+ opacity: 0.7;
2061
+ font-variant-numeric: tabular-nums;
2062
+ `;
2063
+ btn.appendChild(sub);
2064
+ }
2065
+ return btn;
2066
+ }
1806
2067
  function buildSegmentedGroup(element, currentValue, hiddenInput, readonly, onChange) {
1807
2068
  const options = element.options || [];
2069
+ const isPresetMode = options.some((o) => o.subtitle || o.iconUrl);
1808
2070
  const group = document.createElement("div");
1809
2071
  group.className = "fb-switcher-group";
1810
- group.style.cssText = `
1811
- display: inline-flex;
1812
- flex-direction: row;
1813
- flex-wrap: nowrap;
1814
- `;
2072
+ group.style.cssText = isPresetMode ? `
2073
+ display: flex;
2074
+ flex-direction: row;
2075
+ flex-wrap: wrap;
2076
+ gap: 6px;
2077
+ ` : `
2078
+ display: inline-flex;
2079
+ flex-direction: row;
2080
+ flex-wrap: nowrap;
2081
+ `;
1815
2082
  const buttons = [];
1816
2083
  options.forEach((option, index) => {
1817
- const btn = document.createElement("button");
1818
- btn.type = "button";
1819
- btn.className = "fb-switcher-btn";
1820
- btn.dataset.value = option.value;
1821
- btn.textContent = option.label;
1822
- btn.style.cssText = `
1823
- padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
1824
- font-size: var(--fb-font-size);
1825
- border-width: var(--fb-border-width);
1826
- border-style: solid;
1827
- cursor: ${readonly ? "default" : "pointer"};
1828
- transition: background-color var(--fb-transition-duration), color var(--fb-transition-duration), border-color var(--fb-transition-duration);
1829
- white-space: nowrap;
1830
- line-height: 1.25;
1831
- outline: none;
1832
- `;
1833
- if (options.length === 1) {
1834
- btn.style.borderRadius = "var(--fb-border-radius)";
1835
- } else if (index === 0) {
1836
- btn.style.borderRadius = "var(--fb-border-radius) 0 0 var(--fb-border-radius)";
1837
- btn.style.borderRightWidth = "0";
1838
- } else if (index === options.length - 1) {
1839
- btn.style.borderRadius = "0 var(--fb-border-radius) var(--fb-border-radius) 0";
2084
+ let btn;
2085
+ if (isPresetMode) {
2086
+ btn = buildPresetCard(option, readonly);
1840
2087
  } else {
1841
- btn.style.borderRadius = "0";
1842
- btn.style.borderRightWidth = "0";
2088
+ btn = document.createElement("button");
2089
+ btn.type = "button";
2090
+ btn.className = "fb-switcher-btn";
2091
+ btn.dataset.value = option.value;
2092
+ btn.textContent = option.label;
2093
+ btn.style.cssText = `
2094
+ padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
2095
+ font-size: var(--fb-font-size);
2096
+ border-width: var(--fb-border-width);
2097
+ border-style: solid;
2098
+ cursor: ${readonly ? "default" : "pointer"};
2099
+ transition: background-color var(--fb-transition-duration), color var(--fb-transition-duration), border-color var(--fb-transition-duration);
2100
+ white-space: nowrap;
2101
+ line-height: 1.25;
2102
+ outline: none;
2103
+ `;
2104
+ if (options.length === 1) {
2105
+ btn.style.borderRadius = "var(--fb-border-radius)";
2106
+ } else if (index === 0) {
2107
+ btn.style.borderRadius = "var(--fb-border-radius) 0 0 var(--fb-border-radius)";
2108
+ btn.style.borderRightWidth = "0";
2109
+ } else if (index === options.length - 1) {
2110
+ btn.style.borderRadius = "0 var(--fb-border-radius) var(--fb-border-radius) 0";
2111
+ } else {
2112
+ btn.style.borderRadius = "0";
2113
+ btn.style.borderRightWidth = "0";
2114
+ }
1843
2115
  }
1844
2116
  if (option.value === currentValue) {
1845
- applySelectedStyle(btn);
2117
+ applySelectedStyle(btn, isPresetMode);
1846
2118
  } else {
1847
- applyUnselectedStyle(btn);
2119
+ applyUnselectedStyle(btn, isPresetMode);
1848
2120
  }
1849
2121
  if (!readonly) {
1850
2122
  btn.addEventListener("click", () => {
1851
2123
  hiddenInput.value = option.value;
1852
2124
  buttons.forEach((b) => {
1853
2125
  if (b.dataset.value === option.value) {
1854
- applySelectedStyle(b);
2126
+ applySelectedStyle(b, isPresetMode);
1855
2127
  } else {
1856
- applyUnselectedStyle(b);
2128
+ applyUnselectedStyle(b, isPresetMode);
1857
2129
  }
1858
2130
  });
1859
2131
  if (onChange) {
@@ -1867,7 +2139,7 @@ function buildSegmentedGroup(element, currentValue, hiddenInput, readonly, onCha
1867
2139
  });
1868
2140
  btn.addEventListener("mouseleave", () => {
1869
2141
  if (hiddenInput.value !== option.value) {
1870
- btn.style.backgroundColor = "transparent";
2142
+ btn.style.backgroundColor = isPresetMode ? "var(--fb-background-color)" : "transparent";
1871
2143
  }
1872
2144
  });
1873
2145
  }
@@ -2086,7 +2358,7 @@ function validateSwitcherElement(element, key, context) {
2086
2358
  );
2087
2359
  if ("multiple" in element && element.multiple) {
2088
2360
  const inputs = scopeRoot.querySelectorAll(
2089
- `input[type="hidden"][name^="${key}["]`
2361
+ `input[type="hidden"][name^="${key}\\["]`
2090
2362
  );
2091
2363
  const values = [];
2092
2364
  inputs.forEach((input) => {
@@ -2133,7 +2405,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2133
2405
  return;
2134
2406
  }
2135
2407
  const inputs = scopeRoot.querySelectorAll(
2136
- `input[type="hidden"][name^="${fieldPath}["]`
2408
+ `input[type="hidden"][name^="${fieldPath}\\["]`
2137
2409
  );
2138
2410
  inputs.forEach((input, index) => {
2139
2411
  if (index < value.length) {
@@ -2142,15 +2414,17 @@ function updateSwitcherField(element, fieldPath, value, context) {
2142
2414
  const group = input.parentElement?.querySelector(".fb-switcher-group");
2143
2415
  if (group) {
2144
2416
  group.querySelectorAll(".fb-switcher-btn").forEach((btn) => {
2417
+ const isPreset = isPresetButton(btn);
2145
2418
  if (btn.dataset.value === newVal) {
2146
- applySelectedStyle(btn);
2419
+ applySelectedStyle(btn, isPreset);
2147
2420
  } else {
2148
- applyUnselectedStyle(btn);
2421
+ applyUnselectedStyle(btn, isPreset);
2149
2422
  }
2150
2423
  });
2151
2424
  }
2152
2425
  input.classList.remove("invalid");
2153
2426
  input.title = "";
2427
+ clearFieldError(input);
2154
2428
  }
2155
2429
  });
2156
2430
  if (value.length !== inputs.length) {
@@ -2168,16 +2442,208 @@ function updateSwitcherField(element, fieldPath, value, context) {
2168
2442
  const group = input.parentElement?.querySelector(".fb-switcher-group");
2169
2443
  if (group) {
2170
2444
  group.querySelectorAll(".fb-switcher-btn").forEach((btn) => {
2445
+ const isPreset = isPresetButton(btn);
2171
2446
  if (btn.dataset.value === newVal) {
2172
- applySelectedStyle(btn);
2447
+ applySelectedStyle(btn, isPreset);
2173
2448
  } else {
2174
- applyUnselectedStyle(btn);
2449
+ applyUnselectedStyle(btn, isPreset);
2175
2450
  }
2176
2451
  });
2177
2452
  }
2178
2453
  input.classList.remove("invalid");
2179
2454
  input.title = "";
2455
+ clearFieldError(input);
2456
+ }
2457
+ }
2458
+ }
2459
+
2460
+ // src/components/boolean.ts
2461
+ var TOGGLE_W = 36;
2462
+ var TOGGLE_H = 20;
2463
+ var KNOB = 16;
2464
+ function ensureStyles(doc) {
2465
+ const ID = "fb-boolean-styles";
2466
+ if (doc.getElementById(ID)) return;
2467
+ const style = doc.createElement("style");
2468
+ style.id = ID;
2469
+ style.textContent = `
2470
+ .fb-toggle {
2471
+ position: relative;
2472
+ display: inline-block;
2473
+ width: ${TOGGLE_W}px;
2474
+ height: ${TOGGLE_H}px;
2475
+ border-radius: ${TOGGLE_H}px;
2476
+ background: var(--fb-border-color);
2477
+ transition: background-color var(--fb-transition-duration);
2478
+ flex-shrink: 0;
2479
+ }
2480
+ .fb-toggle::after {
2481
+ content: "";
2482
+ position: absolute;
2483
+ top: ${(TOGGLE_H - KNOB) / 2}px;
2484
+ left: ${(TOGGLE_H - KNOB) / 2}px;
2485
+ width: ${KNOB}px;
2486
+ height: ${KNOB}px;
2487
+ border-radius: 50%;
2488
+ background: #ffffff;
2489
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
2490
+ transition: transform var(--fb-transition-duration);
2491
+ }
2492
+ .fb-toggle.fb-on {
2493
+ background: var(--fb-primary-color);
2494
+ }
2495
+ .fb-toggle.fb-on::after {
2496
+ transform: translateX(${TOGGLE_W - KNOB - (TOGGLE_H - KNOB)}px);
2497
+ }
2498
+ .fb-toggle-row {
2499
+ display: flex;
2500
+ align-items: center;
2501
+ gap: 12px;
2502
+ padding: 12px 14px;
2503
+ background: var(--fb-surface-soft-color);
2504
+ border: var(--fb-border-width) solid var(--fb-border-color);
2505
+ border-radius: var(--fb-border-radius);
2506
+ cursor: pointer;
2507
+ user-select: none;
2180
2508
  }
2509
+ .fb-toggle-row[aria-disabled="true"] {
2510
+ cursor: default;
2511
+ opacity: 0.7;
2512
+ }
2513
+ .fb-toggle-row:focus-visible {
2514
+ outline: var(--fb-focus-ring-width) solid var(--fb-focus-ring-color);
2515
+ outline-offset: var(--fb-focus-ring-offset);
2516
+ }
2517
+ .fb-toggle-text { flex: 1; min-width: 0; }
2518
+ .fb-toggle-title {
2519
+ display: flex;
2520
+ align-items: center;
2521
+ gap: 4px;
2522
+ font-size: var(--fb-font-size);
2523
+ font-weight: 500;
2524
+ color: var(--fb-text-color);
2525
+ line-height: 1.3;
2526
+ }
2527
+ .fb-toggle-subtitle {
2528
+ font-size: var(--fb-font-size-small);
2529
+ color: var(--fb-text-secondary-color);
2530
+ margin-top: 2px;
2531
+ line-height: 1.35;
2532
+ }
2533
+ .fb-toggle-info {
2534
+ flex: 0 0 14px;
2535
+ display: inline-flex;
2536
+ align-items: center;
2537
+ justify-content: center;
2538
+ width: 14px;
2539
+ height: 14px;
2540
+ border-radius: 50%;
2541
+ background: var(--fb-border-color);
2542
+ color: #fff;
2543
+ font-size: 10px;
2544
+ font-weight: 700;
2545
+ font-style: italic;
2546
+ font-family: serif;
2547
+ cursor: help;
2548
+ }
2549
+ `;
2550
+ doc.head.appendChild(style);
2551
+ }
2552
+ function parseBool(v) {
2553
+ if (typeof v === "boolean") return v;
2554
+ if (typeof v === "string") return v === "true" || v === "on" || v === "1";
2555
+ return false;
2556
+ }
2557
+ function renderBooleanElement(element, ctx, wrapper, pathKey) {
2558
+ ensureStyles(document);
2559
+ const state = ctx.state;
2560
+ const readonly = isElementReadonly(element, state, ctx);
2561
+ const prefillRaw = ctx.prefill[element.key];
2562
+ const initial = prefillRaw !== void 0 ? parseBool(prefillRaw) : parseBool(element.default);
2563
+ const hiddenInput = document.createElement("input");
2564
+ hiddenInput.type = "hidden";
2565
+ hiddenInput.name = pathKey;
2566
+ hiddenInput.value = initial ? "true" : "false";
2567
+ const row = document.createElement("div");
2568
+ row.className = "fb-toggle-row";
2569
+ row.setAttribute("role", "switch");
2570
+ row.setAttribute("aria-checked", initial ? "true" : "false");
2571
+ if (readonly) {
2572
+ row.setAttribute("aria-disabled", "true");
2573
+ } else {
2574
+ row.tabIndex = 0;
2575
+ }
2576
+ const pill = document.createElement("span");
2577
+ pill.className = "fb-toggle" + (initial ? " fb-on" : "");
2578
+ pill.setAttribute("aria-hidden", "true");
2579
+ row.appendChild(pill);
2580
+ const textBlock = document.createElement("div");
2581
+ textBlock.className = "fb-toggle-text";
2582
+ const titleEl = document.createElement("div");
2583
+ titleEl.className = "fb-toggle-title";
2584
+ titleEl.appendChild(document.createTextNode(element.label ?? ""));
2585
+ if (element.description) {
2586
+ const info = document.createElement("span");
2587
+ info.className = "fb-toggle-info";
2588
+ info.textContent = "i";
2589
+ info.title = element.description;
2590
+ titleEl.appendChild(info);
2591
+ }
2592
+ textBlock.appendChild(titleEl);
2593
+ if (element.hint) {
2594
+ const subtitle = document.createElement("div");
2595
+ subtitle.className = "fb-toggle-subtitle";
2596
+ subtitle.textContent = element.hint;
2597
+ textBlock.appendChild(subtitle);
2598
+ }
2599
+ row.appendChild(textBlock);
2600
+ if (!readonly) {
2601
+ const toggle = () => {
2602
+ const next = hiddenInput.value !== "true";
2603
+ hiddenInput.value = next ? "true" : "false";
2604
+ pill.classList.toggle("fb-on", next);
2605
+ row.setAttribute("aria-checked", next ? "true" : "false");
2606
+ if (ctx.instance) ctx.instance.triggerOnChange(pathKey, next);
2607
+ };
2608
+ row.addEventListener("click", (e) => {
2609
+ if (e.target?.classList.contains("fb-toggle-info")) {
2610
+ return;
2611
+ }
2612
+ toggle();
2613
+ });
2614
+ row.addEventListener("keydown", (e) => {
2615
+ if (e.key === " " || e.key === "Enter") {
2616
+ e.preventDefault();
2617
+ toggle();
2618
+ }
2619
+ });
2620
+ }
2621
+ wrapper.appendChild(hiddenInput);
2622
+ wrapper.appendChild(row);
2623
+ }
2624
+ function validateBooleanElement(element, key, context) {
2625
+ const { scopeRoot } = context;
2626
+ const input = scopeRoot.querySelector(
2627
+ `input[type="hidden"][name="${key}"]`
2628
+ );
2629
+ const raw = input?.value ?? "";
2630
+ const value = parseBool(raw);
2631
+ const errors = [];
2632
+ return { value, errors };
2633
+ }
2634
+ function updateBooleanField(_element, fieldPath, value, context) {
2635
+ const { scopeRoot } = context;
2636
+ const input = scopeRoot.querySelector(
2637
+ `input[type="hidden"][name="${fieldPath}"]`
2638
+ );
2639
+ if (!input) return;
2640
+ const bool = parseBool(value);
2641
+ input.value = bool ? "true" : "false";
2642
+ const row = input.parentElement?.querySelector(".fb-toggle-row");
2643
+ if (row) {
2644
+ row.setAttribute("aria-checked", bool ? "true" : "false");
2645
+ const pill = row.querySelector(".fb-toggle");
2646
+ if (pill) pill.classList.toggle("fb-on", bool);
2181
2647
  }
2182
2648
  }
2183
2649
 
@@ -2281,14 +2747,20 @@ function ensureFileStyles() {
2281
2747
  }
2282
2748
 
2283
2749
  /* \u2500\u2500\u2500 Wide single-file add tile (empty state) \u2500\u2500\u2500 */
2750
+ /* Flex-wraps: side-by-side when wide enough, stacks upload/library
2751
+ vertically when narrow (e.g. inside a 50/50 container column). */
2284
2752
  .fb-wide-tile {
2285
2753
  width: 100%;
2754
+ box-sizing: border-box;
2286
2755
  border-radius: 0.75rem;
2287
2756
  border: 1px dashed #60a5fa;
2288
2757
  background: rgba(239,246,255,0.5);
2289
2758
  display: flex;
2759
+ flex-wrap: wrap;
2760
+ align-items: stretch;
2761
+ gap: 0;
2290
2762
  overflow: hidden;
2291
- height: 180px;
2763
+ min-height: 180px;
2292
2764
  transition: border-color 150ms, background 150ms, box-shadow 150ms;
2293
2765
  cursor: pointer;
2294
2766
  }
@@ -2302,9 +2774,12 @@ function ensureFileStyles() {
2302
2774
  box-shadow: 0 0 0 4px rgba(191,219,254,0.7);
2303
2775
  }
2304
2776
 
2305
- /* Upload zone inside wide tile */
2777
+ /* Upload zone inside wide tile.
2778
+ flex: 1 1 220px \u2014 wants at least 220px; if the container can't fit
2779
+ upload + library on one row (~220 + 176), library wraps below. */
2306
2780
  .fb-wide-tile-upload {
2307
- flex: 1;
2781
+ flex: 1 1 220px;
2782
+ min-height: 140px;
2308
2783
  display: flex;
2309
2784
  flex-direction: column;
2310
2785
  align-items: center;
@@ -2317,24 +2792,21 @@ function ensureFileStyles() {
2317
2792
  background: transparent;
2318
2793
  border: none;
2319
2794
  font-family: inherit;
2795
+ /* Dashed separator from library: right side when in a row, bottom when
2796
+ wrapped (the line then sits between the two stacked cards). */
2797
+ border-right: 1px dashed rgba(96,165,250,0.5);
2320
2798
  }
2321
2799
  .fb-wide-tile-upload:hover {
2322
2800
  background: rgba(191,219,254,0.25);
2323
2801
  }
2324
-
2325
- /* Vertical dashed divider between upload and library zones */
2326
- .fb-wide-tile-divider {
2327
- width: 1px;
2328
- margin: 16px 0;
2329
- border-left: 1px dashed rgba(96,165,250,0.5);
2330
- background: transparent;
2331
- flex-shrink: 0;
2332
- }
2333
-
2334
- /* Library zone inside wide tile */
2802
+ /* Library zone inside wide tile.
2803
+ flex: 0 0 176px \u2014 fixed 176px, never grows. Upload fills the rest in
2804
+ row layout. When the tile wraps to two rows on narrow containers,
2805
+ library stays 176px wide on its own row (left-aligned), preserving the
2806
+ visual hierarchy "upload > library" in both layouts. */
2335
2807
  .fb-wide-tile-library {
2336
- width: 176px;
2337
- flex-shrink: 0;
2808
+ flex: 0 0 176px;
2809
+ min-height: 120px;
2338
2810
  display: flex;
2339
2811
  flex-direction: column;
2340
2812
  align-items: center;
@@ -2351,6 +2823,10 @@ function ensureFileStyles() {
2351
2823
  .fb-wide-tile-library:hover {
2352
2824
  background: rgba(191,219,254,0.25);
2353
2825
  }
2826
+ /* Narrow-tile mode lives in a separate <style> tag (see below) \u2014 the
2827
+ @container rule is appended only when the runtime actually supports
2828
+ container queries, so jsdom (which doesn't) never sees it and stays
2829
+ quiet in test logs. */
2354
2830
 
2355
2831
  /* \u2500\u2500\u2500 Multi-file outer grid container \u2500\u2500\u2500 */
2356
2832
  .fb-multi-outer {
@@ -2729,6 +3205,39 @@ function ensureFileStyles() {
2729
3205
  }
2730
3206
  `;
2731
3207
  document.head.appendChild(style);
3208
+ if (typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("container-type", "inline-size")) {
3209
+ const cq = document.createElement("style");
3210
+ cq.setAttribute("data-fb-file-styles-cq", "true");
3211
+ cq.textContent = `
3212
+ .fb-wide-tile { container-type: inline-size; }
3213
+ @container (max-width: 408px) {
3214
+ .fb-wide-tile-upload {
3215
+ border-right: none;
3216
+ border-bottom: 1px dashed rgba(96,165,250,0.5);
3217
+ }
3218
+ .fb-wide-tile-library {
3219
+ flex: 1 0 100%;
3220
+ min-height: 0;
3221
+ flex-direction: row;
3222
+ gap: 6px;
3223
+ padding: 8px 12px;
3224
+ font-size: 12px;
3225
+ }
3226
+ .fb-wide-tile-library .fb-wide-tile-library-icon {
3227
+ width: 16px;
3228
+ height: 16px;
3229
+ }
3230
+ .fb-wide-tile-library .fb-wide-tile-library-label {
3231
+ font-size: 12px;
3232
+ font-weight: 500;
3233
+ }
3234
+ .fb-wide-tile-library .fb-wide-tile-library-hint {
3235
+ display: none;
3236
+ }
3237
+ }
3238
+ `;
3239
+ document.head.appendChild(cq);
3240
+ }
2732
3241
  }
2733
3242
 
2734
3243
  // src/components/file/dom.ts
@@ -3738,15 +4247,25 @@ function filterAndSlice(allFiles, currentCount, constraints, state) {
3738
4247
  }
3739
4248
  return { accepted, errorMessage: errorParts.join(" \u2022 ") };
3740
4249
  }
3741
- async function uploadBatch(accepted, resourceIds, listEl, state) {
3742
- if (listEl) {
4250
+ async function uploadBatch(opts) {
4251
+ const {
4252
+ accepted,
4253
+ listEl,
4254
+ state,
4255
+ shouldHideAddTile,
4256
+ buildSuccessTile,
4257
+ prepareForUpload
4258
+ } = opts;
4259
+ if (listEl && shouldHideAddTile) {
3743
4260
  const tilesWrap = ensureTilesWrap(listEl);
3744
4261
  const addTile = tilesWrap.querySelector(".fb-multi-add-tile-js") ?? tilesWrap.querySelector(".fb-tile-add");
3745
4262
  if (addTile) addTile.style.display = "none";
3746
4263
  }
4264
+ prepareForUpload?.();
4265
+ const orderedIds = new Array(accepted.length).fill(null);
3747
4266
  const failures = [];
3748
4267
  await Promise.allSettled(
3749
- accepted.map(async (file) => {
4268
+ accepted.map(async (file, index) => {
3750
4269
  const placeholder = createUploadingTile(file.name, state);
3751
4270
  if (listEl) {
3752
4271
  const tilesWrap = ensureTilesWrap(listEl);
@@ -3759,20 +4278,24 @@ async function uploadBatch(accepted, resourceIds, listEl, state) {
3759
4278
  type: file.type,
3760
4279
  size: file.size,
3761
4280
  uploadedAt: /* @__PURE__ */ new Date(),
3762
- file: void 0
4281
+ file
3763
4282
  });
3764
- resourceIds.push(rid);
4283
+ orderedIds[index] = rid;
4284
+ if (buildSuccessTile && placeholder.parentNode) {
4285
+ placeholder.replaceWith(buildSuccessTile(rid));
4286
+ } else {
4287
+ placeholder.remove();
4288
+ }
3765
4289
  } catch (err) {
3766
4290
  const wrapped = err instanceof Error ? err : new Error(String(err));
3767
4291
  const cause = wrapped.cause;
3768
4292
  const root = cause instanceof Error ? cause : cause !== void 0 ? new Error(String(cause)) : wrapped;
3769
4293
  failures.push({ file, error: root });
3770
- } finally {
3771
4294
  placeholder.remove();
3772
4295
  }
3773
4296
  })
3774
4297
  );
3775
- return { failures };
4298
+ return { failures, orderedIds };
3776
4299
  }
3777
4300
  function buildBatchErrorMessage(filterError, failures, state) {
3778
4301
  if (failures.length === 0) return filterError;
@@ -3784,68 +4307,70 @@ function buildBatchErrorMessage(filterError, failures, state) {
3784
4307
  ).join(" \u2022 ");
3785
4308
  return filterError ? `${filterError} \u2022 ${uploadMsg}` : uploadMsg;
3786
4309
  }
3787
- function setupFilesDropHandler(filesContainer, resourceIds, state, updateCallback, constraints, pathKey, instance) {
4310
+ async function runMultiFileBatch(opts, files, listEl, errorTarget) {
4311
+ const {
4312
+ resourceIds,
4313
+ state,
4314
+ updateCallback,
4315
+ constraints,
4316
+ pathKey,
4317
+ instance,
4318
+ buildSuccessTile,
4319
+ prepareForUpload,
4320
+ coordinator
4321
+ } = opts;
4322
+ const { accepted, errorMessage } = filterAndSlice(
4323
+ files,
4324
+ coordinator.getOccupiedCount(),
4325
+ constraints,
4326
+ state
4327
+ );
4328
+ if (errorTarget) {
4329
+ if (errorMessage) showFileError(errorTarget, errorMessage);
4330
+ else clearFileError(errorTarget);
4331
+ }
4332
+ const handle = coordinator.beginBatch(accepted.length);
4333
+ const shouldHideAddTile = coordinator.getOccupiedCount() >= constraints.maxCount;
4334
+ const { failures, orderedIds } = await uploadBatch({
4335
+ accepted,
4336
+ listEl,
4337
+ state,
4338
+ shouldHideAddTile,
4339
+ buildSuccessTile,
4340
+ prepareForUpload
4341
+ });
4342
+ handle.setResults(orderedIds);
4343
+ if (instance && pathKey && !state.config.readonly) {
4344
+ instance.triggerOnChange(pathKey, resourceIds);
4345
+ }
4346
+ const { wasLast } = handle.end();
4347
+ if (wasLast) updateCallback();
4348
+ if (errorTarget) {
4349
+ const combined = buildBatchErrorMessage(errorMessage, failures, state);
4350
+ if (combined) showFileError(errorTarget, combined);
4351
+ else clearFileError(errorTarget);
4352
+ }
4353
+ }
4354
+ function setupFilesDropHandler(opts) {
4355
+ const { filesContainer } = opts;
3788
4356
  setupDragAndDrop(filesContainer, async (files) => {
3789
- const { accepted, errorMessage } = filterAndSlice(
3790
- Array.from(files),
3791
- resourceIds.length,
3792
- constraints,
3793
- state
3794
- );
3795
- if (errorMessage) {
3796
- showFileError(filesContainer, errorMessage);
3797
- } else {
3798
- clearFileError(filesContainer);
3799
- }
3800
4357
  const list = filesContainer.querySelector(".files-list") ?? filesContainer;
3801
- const { failures } = await uploadBatch(accepted, resourceIds, list, state);
3802
- const combined = buildBatchErrorMessage(errorMessage, failures, state);
3803
- if (combined) {
3804
- showFileError(filesContainer, combined);
3805
- } else {
3806
- clearFileError(filesContainer);
3807
- }
3808
- updateCallback();
3809
- if (instance && pathKey && !state.config.readonly) {
3810
- instance.triggerOnChange(pathKey, resourceIds);
3811
- }
4358
+ await runMultiFileBatch(opts, Array.from(files), list, filesContainer);
3812
4359
  });
3813
4360
  }
3814
- function setupFilesPickerHandler(filesPicker, resourceIds, state, updateCallback, constraints, pathKey, instance) {
4361
+ function setupFilesPickerHandler(opts) {
4362
+ const { filesPicker } = opts;
3815
4363
  filesPicker.onchange = async () => {
3816
4364
  if (!filesPicker.files) return;
3817
- const wrapperEl = filesPicker.closest("[data-files-wrapper]") || filesPicker.parentElement;
3818
- const { accepted, errorMessage } = filterAndSlice(
4365
+ const wrapperEl = filesPicker.closest("[data-files-wrapper]") ?? filesPicker.parentElement;
4366
+ const listEl = wrapperEl?.querySelector(".files-list") ?? null;
4367
+ await runMultiFileBatch(
4368
+ opts,
3819
4369
  Array.from(filesPicker.files),
3820
- resourceIds.length,
3821
- constraints,
3822
- state
4370
+ listEl,
4371
+ wrapperEl
3823
4372
  );
3824
- if (errorMessage && wrapperEl) {
3825
- showFileError(wrapperEl, errorMessage);
3826
- } else if (wrapperEl) {
3827
- clearFileError(wrapperEl);
3828
- }
3829
- const listEl = wrapperEl?.querySelector(".files-list");
3830
- const { failures } = await uploadBatch(
3831
- accepted,
3832
- resourceIds,
3833
- listEl ?? null,
3834
- state
3835
- );
3836
- if (wrapperEl) {
3837
- const combined = buildBatchErrorMessage(errorMessage, failures, state);
3838
- if (combined) {
3839
- showFileError(wrapperEl, combined);
3840
- } else {
3841
- clearFileError(wrapperEl);
3842
- }
3843
- }
3844
- updateCallback();
3845
4373
  filesPicker.value = "";
3846
- if (instance && pathKey && !state.config.readonly) {
3847
- instance.triggerOnChange(pathKey, resourceIds);
3848
- }
3849
4374
  };
3850
4375
  }
3851
4376
 
@@ -3886,16 +4411,6 @@ function validatePickedResource(resource, allowedExtensions, allowedMimes, maxSi
3886
4411
  }
3887
4412
  return null;
3888
4413
  }
3889
- function readCurrentResourceIds(wrapper) {
3890
- const raw = wrapper.dataset.resourceIds;
3891
- if (!raw) return [];
3892
- try {
3893
- const parsed = JSON.parse(raw);
3894
- return Array.isArray(parsed) ? parsed : [];
3895
- } catch {
3896
- return [];
3897
- }
3898
- }
3899
4414
  function registerPickedResource(resource, state) {
3900
4415
  const existing = state.resourceIndex.get(resource.resourceId);
3901
4416
  state.resourceIndex.set(resource.resourceId, {
@@ -3910,13 +4425,27 @@ function extractPickerError(error, state) {
3910
4425
  if (error instanceof Error && error.message) return error.message;
3911
4426
  return t("pickerError", state);
3912
4427
  }
3913
- async function handleLibraryPickMulti(state, element, wrapper, fieldPath, resourceIds, maxCount, updateCallback, instance) {
4428
+ async function handleLibraryPickMulti(opts) {
4429
+ const {
4430
+ state,
4431
+ element,
4432
+ wrapper,
4433
+ fieldPath,
4434
+ resourceIds,
4435
+ maxCount,
4436
+ updateCallback,
4437
+ instance,
4438
+ coordinator,
4439
+ list,
4440
+ buildSuccessTile
4441
+ } = opts;
3914
4442
  if (!state.config.pickExistingFiles) return;
3915
4443
  const allowedExtensions = getAllowedExtensions(element.accept);
3916
4444
  const allowedMimes = getAllowedMimes(element.accept);
3917
4445
  const maxSizeMB = element.maxSize ?? Infinity;
3918
- const currentIds = readCurrentResourceIds(wrapper);
3919
- const remaining = maxCount === Infinity ? Infinity : Math.max(0, maxCount - currentIds.length);
4446
+ const knownRids = coordinator.getAllKnownRids();
4447
+ const existingSet = new Set(knownRids);
4448
+ const remaining = maxCount === Infinity ? Infinity : Math.max(0, maxCount - coordinator.getOccupiedCount());
3920
4449
  let picked;
3921
4450
  try {
3922
4451
  picked = await state.config.pickExistingFiles({
@@ -3925,14 +4454,15 @@ async function handleLibraryPickMulti(state, element, wrapper, fieldPath, resour
3925
4454
  accept: buildAcceptContext(element),
3926
4455
  maxSizeMB: maxSizeMB === Infinity ? void 0 : maxSizeMB,
3927
4456
  remainingSlots: remaining === Infinity ? void 0 : remaining,
3928
- selectedResourceIds: [...currentIds]
4457
+ // Hand the host every rid that's already selected (committed or
4458
+ // staged) so it can grey them out or filter them.
4459
+ selectedResourceIds: knownRids
3929
4460
  });
3930
4461
  } catch (error) {
3931
4462
  showFileError(wrapper, extractPickerError(error, state));
3932
4463
  return;
3933
4464
  }
3934
4465
  if (picked.length === 0) return;
3935
- const existingSet = new Set(currentIds);
3936
4466
  const seen = /* @__PURE__ */ new Set();
3937
4467
  const deduped = picked.filter((r) => {
3938
4468
  if (existingSet.has(r.resourceId)) return false;
@@ -3950,10 +4480,18 @@ async function handleLibraryPickMulti(state, element, wrapper, fieldPath, resour
3950
4480
  );
3951
4481
  return err === null;
3952
4482
  });
3953
- const freshRemaining = maxCount === Infinity ? validItems.length : Math.max(0, maxCount - resourceIds.length);
4483
+ const freshRemaining = maxCount === Infinity ? validItems.length : Math.max(0, maxCount - coordinator.getOccupiedCount());
3954
4484
  const accepted = validItems.slice(0, freshRemaining);
3955
4485
  const skipped = validItems.length - accepted.length;
3956
- if (accepted.length === 0) return;
4486
+ if (accepted.length === 0) {
4487
+ if (skipped > 0) {
4488
+ showFileError(
4489
+ wrapper,
4490
+ t("filesLimitExceeded", state, { skipped, max: maxCount })
4491
+ );
4492
+ }
4493
+ return;
4494
+ }
3957
4495
  clearFileError(wrapper);
3958
4496
  if (skipped > 0) {
3959
4497
  showFileError(
@@ -3963,13 +4501,22 @@ async function handleLibraryPickMulti(state, element, wrapper, fieldPath, resour
3963
4501
  }
3964
4502
  for (const resource of accepted) {
3965
4503
  registerPickedResource(resource, state);
3966
- resourceIds.push(resource.resourceId);
3967
4504
  }
3968
- wrapper.dataset.resourceIds = JSON.stringify(resourceIds);
3969
- updateCallback();
4505
+ const acceptedIds = accepted.map((r) => r.resourceId);
4506
+ const handle = coordinator.beginBatch(accepted.length);
4507
+ handle.setResults(acceptedIds);
3970
4508
  if (!state.config.readonly) {
3971
4509
  instance.triggerOnChange(fieldPath, resourceIds);
3972
4510
  }
4511
+ const { wasLast } = handle.end();
4512
+ if (wasLast) {
4513
+ updateCallback();
4514
+ } else {
4515
+ const tilesWrap = ensureTilesWrap(list);
4516
+ for (const rid of acceptedIds) {
4517
+ tilesWrap.appendChild(buildSuccessTile(rid));
4518
+ }
4519
+ }
3973
4520
  }
3974
4521
  async function handleLibraryPickSingle(state, element, container, fileWrapper, pathKey, fieldPath, renderCallback, instance) {
3975
4522
  if (!state.config.pickExistingFiles) return;
@@ -4074,21 +4621,21 @@ function buildWideTile(state, hasLibrary, onUploadClick, onLibraryClick, isDragO
4074
4621
  };
4075
4622
  outer.appendChild(uploadBtn);
4076
4623
  if (hasLibrary && onLibraryClick) {
4077
- const divider = document.createElement("div");
4078
- divider.className = "fb-wide-tile-divider";
4079
- outer.appendChild(divider);
4080
4624
  const libBtn = document.createElement("button");
4081
4625
  libBtn.type = "button";
4082
4626
  libBtn.className = "fb-wide-tile-library fb-file-library-card";
4083
4627
  const libIcon = document.createElement("span");
4628
+ libIcon.className = "fb-wide-tile-library-icon";
4084
4629
  libIcon.style.cssText = "width:28px;height:28px;display:block;flex-shrink:0;";
4085
4630
  libIcon.innerHTML = ICON_LIBRARY2;
4086
4631
  libBtn.appendChild(libIcon);
4087
4632
  const libLabel = document.createElement("div");
4633
+ libLabel.className = "fb-wide-tile-library-label";
4088
4634
  libLabel.style.cssText = "font-size:13px;font-weight:600;text-align:center;";
4089
4635
  libLabel.textContent = t("fromLibrary", state);
4090
4636
  libBtn.appendChild(libLabel);
4091
4637
  const libHint = document.createElement("div");
4638
+ libHint.className = "fb-wide-tile-library-hint";
4092
4639
  libHint.style.cssText = "font-size:11px;opacity:0.75;text-align:center;";
4093
4640
  libHint.textContent = t("libraryHint", state);
4094
4641
  libBtn.appendChild(libHint);
@@ -4290,6 +4837,14 @@ function buildMetaDot() {
4290
4837
  return dot;
4291
4838
  }
4292
4839
  var gridResizeObservers = /* @__PURE__ */ new WeakMap();
4840
+ function disposePlaceholdersForUpload(container) {
4841
+ const observer = gridResizeObservers.get(container);
4842
+ if (observer) {
4843
+ observer.disconnect();
4844
+ gridResizeObservers.delete(container);
4845
+ }
4846
+ container.querySelectorAll(".fb-multi-placeholder").forEach((p) => p.remove());
4847
+ }
4293
4848
  function renderResourcePills(opts) {
4294
4849
  const {
4295
4850
  container,
@@ -4637,16 +5192,19 @@ function setupMultiFileEditMode(element, ctx, wrapper, pathKey, maxFiles) {
4637
5192
  filesPicker.click();
4638
5193
  };
4639
5194
  const onLibraryPick = state.config.pickExistingFiles && !element.disableLibrary ? () => {
4640
- handleLibraryPickMulti(
5195
+ handleLibraryPickMulti({
4641
5196
  state,
4642
5197
  element,
4643
- filesWrapper,
4644
- pathKey,
4645
- initialFiles,
4646
- maxFiles,
4647
- updateFilesDisplay,
4648
- ctx.instance
4649
- ).catch((err) => {
5198
+ wrapper: filesWrapper,
5199
+ fieldPath: pathKey,
5200
+ resourceIds: initialFiles,
5201
+ maxCount: maxFiles,
5202
+ updateCallback: updateFilesDisplay,
5203
+ instance: ctx.instance,
5204
+ coordinator,
5205
+ list,
5206
+ buildSuccessTile
5207
+ }).catch((err) => {
4650
5208
  console.error("Library pick failed:", err);
4651
5209
  });
4652
5210
  } : null;
@@ -4660,37 +5218,153 @@ function setupMultiFileEditMode(element, ctx, wrapper, pathKey, maxFiles) {
4660
5218
  releaseLocalFileUrl(state.resourceIndex.get(ridToRemove)?.file);
4661
5219
  const index = initialFiles.indexOf(ridToRemove);
4662
5220
  if (index > -1) initialFiles.splice(index, 1);
4663
- updateFilesDisplay();
5221
+ if (coordinator.hasInFlightBatches()) {
5222
+ pendingRemovals.add(ridToRemove);
5223
+ list.querySelector(`[data-resource-id="${ridToRemove}"]`)?.remove();
5224
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
5225
+ } else {
5226
+ updateFilesDisplay();
5227
+ }
5228
+ if (ctx.instance && pathKey && !state.config.readonly) {
5229
+ ctx.instance.triggerOnChange(pathKey, initialFiles);
5230
+ }
4664
5231
  },
4665
5232
  maxCount: maxFiles < Infinity ? maxFiles : void 0,
4666
5233
  isReadonly: currentlyReadonly,
4667
5234
  onLibraryPick: currentlyReadonly ? null : onLibraryPick,
4668
5235
  element,
4669
5236
  onClearAll: currentlyReadonly ? void 0 : () => {
5237
+ for (const rid of initialFiles) {
5238
+ releaseLocalFileUrl(state.resourceIndex.get(rid)?.file);
5239
+ }
4670
5240
  initialFiles.splice(0);
4671
- updateFilesDisplay();
5241
+ if (coordinator.hasInFlightBatches()) {
5242
+ const visibleTiles = list.querySelectorAll("[data-resource-id]");
5243
+ for (const tile of visibleTiles) {
5244
+ const rid = tile.dataset.resourceId;
5245
+ if (rid) {
5246
+ releaseLocalFileUrl(state.resourceIndex.get(rid)?.file);
5247
+ pendingRemovals.add(rid);
5248
+ }
5249
+ tile.remove();
5250
+ }
5251
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
5252
+ } else {
5253
+ updateFilesDisplay();
5254
+ }
5255
+ if (ctx.instance && pathKey && !state.config.readonly) {
5256
+ ctx.instance.triggerOnChange(pathKey, initialFiles);
5257
+ }
4672
5258
  },
4673
5259
  openPicker
4674
5260
  });
4675
5261
  }
4676
- setupFilesDropHandler(
4677
- filesContainer,
4678
- initialFiles,
4679
- state,
4680
- updateFilesDisplay,
4681
- constraints,
4682
- pathKey,
4683
- ctx.instance
4684
- );
4685
- setupFilesPickerHandler(
4686
- filesPicker,
4687
- initialFiles,
5262
+ let inFlightFiles = 0;
5263
+ let activeBatches = 0;
5264
+ let nextBatchOrdinal = 0;
5265
+ let nextCommitOrdinal = 0;
5266
+ const stagedResults = /* @__PURE__ */ new Map();
5267
+ const batchReservations = /* @__PURE__ */ new Map();
5268
+ const pendingRemovals = /* @__PURE__ */ new Set();
5269
+ const drainContiguousStagedResults = () => {
5270
+ while (stagedResults.has(nextCommitOrdinal)) {
5271
+ const ordinal = nextCommitOrdinal;
5272
+ const ids = stagedResults.get(ordinal);
5273
+ stagedResults.delete(ordinal);
5274
+ nextCommitOrdinal += 1;
5275
+ const reservation = batchReservations.get(ordinal) ?? 0;
5276
+ batchReservations.delete(ordinal);
5277
+ inFlightFiles -= reservation;
5278
+ for (const rid of ids) {
5279
+ if (rid === null) continue;
5280
+ if (pendingRemovals.has(rid)) {
5281
+ pendingRemovals.delete(rid);
5282
+ continue;
5283
+ }
5284
+ initialFiles.push(rid);
5285
+ }
5286
+ }
5287
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
5288
+ };
5289
+ const coordinator = {
5290
+ getOccupiedCount: () => initialFiles.length + inFlightFiles,
5291
+ getAllKnownRids: () => {
5292
+ const out = [...initialFiles];
5293
+ for (const ids of stagedResults.values()) {
5294
+ for (const rid of ids) {
5295
+ if (rid !== null) out.push(rid);
5296
+ }
5297
+ }
5298
+ return out;
5299
+ },
5300
+ hasInFlightBatches: () => activeBatches > 0 || batchReservations.size > 0,
5301
+ wasRemovedDuringBatch: (rid) => pendingRemovals.has(rid),
5302
+ beginBatch: (count) => {
5303
+ inFlightFiles += count;
5304
+ activeBatches += 1;
5305
+ const ordinal = nextBatchOrdinal++;
5306
+ batchReservations.set(ordinal, count);
5307
+ return {
5308
+ setResults: (orderedIds) => {
5309
+ stagedResults.set(ordinal, orderedIds);
5310
+ drainContiguousStagedResults();
5311
+ },
5312
+ end: () => {
5313
+ activeBatches -= 1;
5314
+ const wasLast = activeBatches === 0 && batchReservations.size === 0;
5315
+ if (wasLast) {
5316
+ pendingRemovals.clear();
5317
+ }
5318
+ return { wasLast };
5319
+ }
5320
+ };
5321
+ }
5322
+ };
5323
+ const buildSuccessTile = (rid) => {
5324
+ const currentlyReadonly = isElementReadonly(element, state);
5325
+ return buildPreviewTile(
5326
+ rid,
5327
+ state,
5328
+ !currentlyReadonly,
5329
+ currentlyReadonly ? null : () => {
5330
+ releaseLocalFileUrl(state.resourceIndex.get(rid)?.file);
5331
+ const idx = initialFiles.indexOf(rid);
5332
+ if (idx > -1) {
5333
+ initialFiles.splice(idx, 1);
5334
+ if (coordinator.hasInFlightBatches()) {
5335
+ pendingRemovals.add(rid);
5336
+ list.querySelector(`[data-resource-id="${rid}"]`)?.remove();
5337
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
5338
+ } else {
5339
+ updateFilesDisplay();
5340
+ }
5341
+ if (ctx.instance && pathKey && !state.config.readonly) {
5342
+ ctx.instance.triggerOnChange(pathKey, initialFiles);
5343
+ }
5344
+ return;
5345
+ }
5346
+ pendingRemovals.add(rid);
5347
+ list.querySelector(`[data-resource-id="${rid}"]`)?.remove();
5348
+ if (ctx.instance && pathKey && !state.config.readonly) {
5349
+ ctx.instance.triggerOnChange(pathKey, initialFiles);
5350
+ }
5351
+ }
5352
+ );
5353
+ };
5354
+ const prepareForUpload = () => disposePlaceholdersForUpload(list);
5355
+ const sharedHandlerOpts = {
5356
+ resourceIds: initialFiles,
4688
5357
  state,
4689
- updateFilesDisplay,
5358
+ updateCallback: updateFilesDisplay,
4690
5359
  constraints,
4691
5360
  pathKey,
4692
- ctx.instance
4693
- );
5361
+ instance: ctx.instance,
5362
+ buildSuccessTile,
5363
+ prepareForUpload,
5364
+ coordinator
5365
+ };
5366
+ setupFilesDropHandler({ ...sharedHandlerOpts, filesContainer });
5367
+ setupFilesPickerHandler({ ...sharedHandlerOpts, filesPicker });
4694
5368
  updateFilesDisplay();
4695
5369
  wrapper.appendChild(filesWrapper);
4696
5370
  }
@@ -5330,7 +6004,7 @@ function validateColourElement(element, key, context) {
5330
6004
  };
5331
6005
  if (element.multiple) {
5332
6006
  const hexInputs = scopeRoot.querySelectorAll(
5333
- `[name^="${key}["].colour-hex-input`
6007
+ `[name^="${key}\\["].colour-hex-input`
5334
6008
  );
5335
6009
  const values = [];
5336
6010
  hexInputs.forEach((input, index) => {
@@ -5379,7 +6053,7 @@ function updateColourField(element, fieldPath, value, context) {
5379
6053
  return;
5380
6054
  }
5381
6055
  const hexInputs = scopeRoot.querySelectorAll(
5382
- `[name^="${fieldPath}["].colour-hex-input`
6056
+ `[name^="${fieldPath}\\["].colour-hex-input`
5383
6057
  );
5384
6058
  hexInputs.forEach((hexInput, index) => {
5385
6059
  if (index < value.length) {
@@ -5387,6 +6061,7 @@ function updateColourField(element, fieldPath, value, context) {
5387
6061
  hexInput.value = normalized;
5388
6062
  hexInput.classList.remove("invalid");
5389
6063
  hexInput.title = "";
6064
+ clearFieldError(hexInput);
5390
6065
  const wrapper = hexInput.closest(".colour-picker-wrapper");
5391
6066
  if (wrapper) {
5392
6067
  const swatch = wrapper.querySelector(".colour-swatch");
@@ -5416,6 +6091,7 @@ function updateColourField(element, fieldPath, value, context) {
5416
6091
  hexInput.value = normalized;
5417
6092
  hexInput.classList.remove("invalid");
5418
6093
  hexInput.title = "";
6094
+ clearFieldError(hexInput);
5419
6095
  const wrapper = hexInput.closest(".colour-picker-wrapper");
5420
6096
  if (wrapper) {
5421
6097
  const swatch = wrapper.querySelector(".colour-swatch");
@@ -5818,7 +6494,7 @@ function validateSliderElement(element, key, context) {
5818
6494
  };
5819
6495
  if (element.multiple) {
5820
6496
  const sliders = scopeRoot.querySelectorAll(
5821
- `input[type="range"][name^="${key}["]`
6497
+ `input[type="range"][name^="${key}\\["]`
5822
6498
  );
5823
6499
  const values = [];
5824
6500
  sliders.forEach((slider, index) => {
@@ -5869,7 +6545,7 @@ function updateSliderField(element, fieldPath, value, context) {
5869
6545
  return;
5870
6546
  }
5871
6547
  const sliders = scopeRoot.querySelectorAll(
5872
- `input[type="range"][name^="${fieldPath}["]`
6548
+ `input[type="range"][name^="${fieldPath}\\["]`
5873
6549
  );
5874
6550
  sliders.forEach((slider, index) => {
5875
6551
  if (index < value.length && value[index] !== null) {
@@ -5897,6 +6573,7 @@ function updateSliderField(element, fieldPath, value, context) {
5897
6573
  }
5898
6574
  slider.classList.remove("invalid");
5899
6575
  slider.title = "";
6576
+ clearFieldError(slider);
5900
6577
  }
5901
6578
  });
5902
6579
  if (value.length !== sliders.length) {
@@ -5933,6 +6610,7 @@ function updateSliderField(element, fieldPath, value, context) {
5933
6610
  }
5934
6611
  slider.classList.remove("invalid");
5935
6612
  slider.title = "";
6613
+ clearFieldError(slider);
5936
6614
  }
5937
6615
  }
5938
6616
  }
@@ -6055,34 +6733,24 @@ function getChildWrapperClass(isSlides, columns) {
6055
6733
  const cols = columns || 1;
6056
6734
  return cols === 1 ? "space-y-2" : `grid grid-cols-${cols} gap-2`;
6057
6735
  }
6058
- function mountRemoveButton(item, onRemove) {
6736
+ function mountRemoveButton(item, onRemove, state) {
6059
6737
  const rem = document.createElement("button");
6060
6738
  rem.type = "button";
6061
6739
  rem.className = "fb-item-remove";
6740
+ rem.setAttribute("aria-label", t("removeElement", state));
6062
6741
  rem.style.cssText = `
6063
- width: 22px;
6064
- height: 22px;
6742
+ width: 24px;
6743
+ height: 24px;
6065
6744
  display: inline-flex;
6066
6745
  align-items: center;
6067
6746
  justify-content: center;
6068
6747
  padding: 0;
6069
- line-height: 1;
6070
- font-size: 14px;
6071
- color: var(--fb-error-color);
6072
- background-color: transparent;
6073
6748
  border: 0;
6074
6749
  border-radius: 4px;
6075
6750
  cursor: pointer;
6076
6751
  flex-shrink: 0;
6077
- transition: background-color var(--fb-transition-duration);
6078
6752
  `;
6079
- rem.textContent = "\u2715";
6080
- rem.addEventListener("mouseenter", () => {
6081
- rem.style.backgroundColor = "var(--fb-background-hover-color)";
6082
- });
6083
- rem.addEventListener("mouseleave", () => {
6084
- rem.style.backgroundColor = "transparent";
6085
- });
6753
+ rem.innerHTML = BIN_ICON_SVG;
6086
6754
  rem.onclick = onRemove;
6087
6755
  const labelRow = item.querySelector("[data-fb-label-row]");
6088
6756
  if (labelRow) {
@@ -6108,7 +6776,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6108
6776
  itemsWrap.className = "fb-container-slides";
6109
6777
  const slideCols = element.columns;
6110
6778
  const gridTemplateColumns = typeof slideCols === "number" && slideCols > 0 ? `repeat(${slideCols}, 1fr)` : "repeat(auto-fit, minmax(280px, 1fr))";
6111
- itemsWrap.style.cssText = `display:grid;grid-template-columns:${gridTemplateColumns};gap:8px;align-items:start;`;
6779
+ itemsWrap.style.cssText = `display:grid;grid-template-columns:${gridTemplateColumns};gap:var(--fb-slides-gap, 14px);align-items:start;`;
6112
6780
  } else {
6113
6781
  itemsWrap.className = "space-y-2";
6114
6782
  }
@@ -6139,6 +6807,9 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6139
6807
  const item = document.createElement("div");
6140
6808
  item.className = "containerItem border border-gray-300 rounded-lg p-2 bg-white";
6141
6809
  item.setAttribute("data-container-item", `${element.key}[${idx}]`);
6810
+ if (isSlides) {
6811
+ item.setAttribute("data-fb-slide-card", "");
6812
+ }
6142
6813
  const childWrapper = document.createElement("div");
6143
6814
  childWrapper.className = getChildWrapperClass(isSlides, element.columns);
6144
6815
  element.elements.forEach((child) => {
@@ -6155,7 +6826,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6155
6826
  });
6156
6827
  item.appendChild(childWrapper);
6157
6828
  if (!containerIsReadonly) {
6158
- mountRemoveButton(item, () => handleRemoveItem(item));
6829
+ mountRemoveButton(item, () => handleRemoveItem(item), state);
6159
6830
  }
6160
6831
  if (slideAddTile && slideAddTile.parentElement === itemsWrap) {
6161
6832
  itemsWrap.insertBefore(item, slideAddTile);
@@ -6201,6 +6872,9 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6201
6872
  const item = document.createElement("div");
6202
6873
  item.className = "containerItem border border-gray-300 rounded-lg p-2 bg-white";
6203
6874
  item.setAttribute("data-container-item", `${element.key}[${idx}]`);
6875
+ if (isSlides) {
6876
+ item.setAttribute("data-fb-slide-card", "");
6877
+ }
6204
6878
  const childWrapper = document.createElement("div");
6205
6879
  if (isSlides) {
6206
6880
  childWrapper.className = "space-y-2";
@@ -6224,7 +6898,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6224
6898
  });
6225
6899
  item.appendChild(childWrapper);
6226
6900
  if (!containerIsReadonly) {
6227
- mountRemoveButton(item, () => handleRemoveItem(item));
6901
+ mountRemoveButton(item, () => handleRemoveItem(item), ctx.state);
6228
6902
  }
6229
6903
  itemsWrap.appendChild(item);
6230
6904
  });
@@ -6244,6 +6918,9 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6244
6918
  const item = document.createElement("div");
6245
6919
  item.className = "containerItem border border-gray-300 rounded-lg p-2 bg-white";
6246
6920
  item.setAttribute("data-container-item", `${element.key}[${idx}]`);
6921
+ if (isSlides) {
6922
+ item.setAttribute("data-fb-slide-card", "");
6923
+ }
6247
6924
  const childWrapper = document.createElement("div");
6248
6925
  if (isSlides) {
6249
6926
  childWrapper.className = "space-y-2";
@@ -6268,11 +6945,15 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
6268
6945
  }
6269
6946
  });
6270
6947
  item.appendChild(childWrapper);
6271
- mountRemoveButton(item, () => {
6272
- if (countItems() > min) {
6273
- handleRemoveItem(item);
6274
- }
6275
- });
6948
+ mountRemoveButton(
6949
+ item,
6950
+ () => {
6951
+ if (countItems() > min) {
6952
+ handleRemoveItem(item);
6953
+ }
6954
+ },
6955
+ ctx.state
6956
+ );
6276
6957
  itemsWrap.appendChild(item);
6277
6958
  }
6278
6959
  }
@@ -8897,7 +9578,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
8897
9578
  if (element.minLength != null || element.maxLength != null) {
8898
9579
  const counterRow = document.createElement("div");
8899
9580
  counterRow.style.cssText = "position: relative; padding: 2px 10px 4px; text-align: right;";
8900
- const counter = createCharCounter(element, textarea, false);
9581
+ const counter = createCharCounter(element, textarea);
8901
9582
  counter.style.cssText = `
8902
9583
  position: static;
8903
9584
  display: inline-block;
@@ -9422,6 +10103,117 @@ function validateMarkdown(_element, _key, _context) {
9422
10103
  function updateMarkdown(_element, _fieldPath, _value, _context) {
9423
10104
  }
9424
10105
 
10106
+ // src/components/registry.ts
10107
+ function validateHiddenElement(element, key, context) {
10108
+ const { scopeRoot } = context;
10109
+ const input = scopeRoot.querySelector(
10110
+ `input[type="hidden"][data-hidden-field="true"][name="${key}"]`
10111
+ );
10112
+ const raw = input?.value ?? "";
10113
+ if (raw === "") {
10114
+ const defaultVal = "default" in element ? element.default : null;
10115
+ return { value: defaultVal !== void 0 ? defaultVal : null, errors: [] };
10116
+ }
10117
+ return { value: deserializeHiddenValue(raw), errors: [] };
10118
+ }
10119
+ function updateHiddenField(_element, fieldPath, value, context) {
10120
+ const { scopeRoot } = context;
10121
+ const input = scopeRoot.querySelector(
10122
+ `input[type="hidden"][data-hidden-field="true"][name="${fieldPath}"]`
10123
+ );
10124
+ if (!input) return;
10125
+ input.value = serializeHiddenValue(value);
10126
+ }
10127
+ var componentRegistry = {
10128
+ text: {
10129
+ validate: validateTextElement,
10130
+ update: updateTextField
10131
+ },
10132
+ textarea: {
10133
+ validate: validateTextareaElement,
10134
+ update: updateTextareaField
10135
+ },
10136
+ number: {
10137
+ validate: validateNumberElement,
10138
+ update: updateNumberField
10139
+ },
10140
+ select: {
10141
+ validate: validateSelectElement,
10142
+ update: updateSelectField
10143
+ },
10144
+ switcher: {
10145
+ validate: validateSwitcherElement,
10146
+ update: updateSwitcherField
10147
+ },
10148
+ boolean: {
10149
+ validate: validateBooleanElement,
10150
+ update: updateBooleanField,
10151
+ ownsLabel: true
10152
+ },
10153
+ file: {
10154
+ validate: validateFileElement,
10155
+ update: updateFileField
10156
+ },
10157
+ files: {
10158
+ // Legacy type - delegates to file
10159
+ validate: validateFileElement,
10160
+ update: updateFileField
10161
+ },
10162
+ colour: {
10163
+ validate: validateColourElement,
10164
+ update: updateColourField
10165
+ },
10166
+ slider: {
10167
+ validate: validateSliderElement,
10168
+ update: updateSliderField
10169
+ },
10170
+ container: {
10171
+ validate: validateContainerElement,
10172
+ update: updateContainerField
10173
+ },
10174
+ group: {
10175
+ // Deprecated type - delegates to container
10176
+ validate: validateGroupElement,
10177
+ update: updateGroupField
10178
+ },
10179
+ table: {
10180
+ validate: validateTableElement,
10181
+ update: updateTableField
10182
+ },
10183
+ richinput: {
10184
+ validate: validateRichInputElement,
10185
+ update: updateRichInputField
10186
+ },
10187
+ hidden: {
10188
+ // Legacy type: `type: "hidden"` — reads/writes DOM <input type="hidden"> element
10189
+ validate: validateHiddenElement,
10190
+ update: updateHiddenField
10191
+ },
10192
+ markdown: {
10193
+ // Display-only element — no value, no errors, skip from form data
10194
+ validate: validateMarkdown,
10195
+ update: updateMarkdown
10196
+ }
10197
+ };
10198
+ function getComponentOperations(elementType) {
10199
+ return componentRegistry[elementType] || null;
10200
+ }
10201
+ function validateElementWithComponent(element, key, context) {
10202
+ const ops = getComponentOperations(element.type);
10203
+ if (ops && ops.validate) {
10204
+ return ops.validate(element, key, context);
10205
+ }
10206
+ return null;
10207
+ }
10208
+ function updateElementWithComponent(element, fieldPath, value, context) {
10209
+ const ops = getComponentOperations(element.type);
10210
+ if (ops && ops.update) {
10211
+ ops.update(element, fieldPath, value, context);
10212
+ return true;
10213
+ }
10214
+ return false;
10215
+ }
10216
+
9425
10217
  // src/components/index.ts
9426
10218
  function showTooltip(tooltipId, button) {
9427
10219
  const tooltip = document.getElementById(tooltipId);
@@ -9730,6 +10522,9 @@ function dispatchToRenderer(element, ctx, wrapper, pathKey) {
9730
10522
  renderSwitcherElement(element, ctx, wrapper, pathKey);
9731
10523
  }
9732
10524
  break;
10525
+ case "boolean":
10526
+ renderBooleanElement(element, ctx, wrapper, pathKey);
10527
+ break;
9733
10528
  case "file":
9734
10529
  if (isMultiple) {
9735
10530
  renderMultipleFileElement(element, ctx, wrapper, pathKey);
@@ -9808,8 +10603,11 @@ function renderElement2(element, ctx) {
9808
10603
  const wrapper = document.createElement("div");
9809
10604
  wrapper.className = "mb-2 fb-field-wrapper";
9810
10605
  wrapper.setAttribute("data-field-key", element.key);
9811
- const label = createLabelContainer(element);
9812
- wrapper.appendChild(label);
10606
+ const ops = getComponentOperations(element.type);
10607
+ if (!ops?.ownsLabel) {
10608
+ const label = createLabelContainer(element);
10609
+ wrapper.appendChild(label);
10610
+ }
9813
10611
  const pathKey = pathJoin(ctx.path, element.key);
9814
10612
  dispatchToRenderer(element, ctx, wrapper, pathKey);
9815
10613
  if (initiallyDisabled) {
@@ -10042,28 +10840,52 @@ var defaultTheme = {
10042
10840
  // blue-500
10043
10841
  primaryHoverColor: "#2563eb",
10044
10842
  // blue-600
10843
+ primarySoftColor: "#dbeafe",
10844
+ // blue-100
10845
+ primarySoftHoverColor: "#bfdbfe",
10846
+ // blue-200
10045
10847
  errorColor: "#ef4444",
10046
10848
  // red-500
10047
10849
  errorHoverColor: "#dc2626",
10048
10850
  // red-600
10049
10851
  successColor: "#10b981",
10050
10852
  // green-500
10853
+ accentColor: "#f59e0b",
10854
+ // amber-500
10855
+ accentSoftColor: "#fef3c7",
10856
+ // amber-100
10857
+ accentBorderColor: "#fde68a",
10858
+ // amber-200
10859
+ accentTextColor: "#92400e",
10860
+ // amber-800
10051
10861
  borderColor: "#d1d5db",
10052
10862
  // gray-300
10053
10863
  borderHoverColor: "#9ca3af",
10054
10864
  // gray-400
10055
10865
  borderFocusColor: "#3b82f6",
10056
10866
  // blue-500
10867
+ borderStrongColor: "#9ca3af",
10868
+ // gray-400
10057
10869
  backgroundColor: "#ffffff",
10058
10870
  // white
10059
10871
  backgroundHoverColor: "#f9fafb",
10060
10872
  // gray-50
10061
10873
  backgroundReadonlyColor: "#f3f4f6",
10062
10874
  // gray-100
10875
+ pageBackgroundColor: "#f9fafb",
10876
+ // gray-50
10877
+ surfaceSoftColor: "#eff6ff",
10878
+ // blue-50
10879
+ surfaceTintColor: "#f8fafc",
10880
+ // slate-50
10063
10881
  textColor: "#1f2937",
10064
10882
  // gray-800
10065
10883
  textSecondaryColor: "#6b7280",
10066
10884
  // gray-500
10885
+ textMutedColor: "#9ca3af",
10886
+ // gray-400
10887
+ textFaintColor: "#cbd5e1",
10888
+ // slate-300
10067
10889
  textPlaceholderColor: "#9ca3af",
10068
10890
  // gray-400
10069
10891
  textDisabledColor: "#d1d5db",
@@ -10106,6 +10928,12 @@ var defaultTheme = {
10106
10928
  // 4px (compact density v2)
10107
10929
  borderRadius: "0.5rem",
10108
10930
  // rounded-lg (8px)
10931
+ borderRadiusSmall: "0.375rem",
10932
+ // 6px
10933
+ borderRadiusLarge: "0.75rem",
10934
+ // 12px
10935
+ borderRadiusXLarge: "1rem",
10936
+ // 16px
10109
10937
  borderWidth: "1px",
10110
10938
  // Typography
10111
10939
  fontSize: "0.875rem",
@@ -10117,13 +10945,31 @@ var defaultTheme = {
10117
10945
  fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
10118
10946
  fontWeightNormal: "400",
10119
10947
  fontWeightMedium: "500",
10948
+ lineHeight: "1.5",
10949
+ // Shadows
10950
+ shadowCard: "0 1px 2px rgba(15,23,42,.04), 0 1px 0 rgba(15,23,42,.02)",
10951
+ shadowPopover: "0 12px 32px -12px rgba(15,23,42,.18), 0 4px 12px -6px rgba(15,23,42,.08)",
10120
10952
  // Focus ring
10121
10953
  focusRingWidth: "2px",
10122
10954
  focusRingColor: "#3b82f6",
10123
10955
  // blue-500
10124
10956
  focusRingOpacity: "0.5",
10125
10957
  // Transitions
10126
- transitionDuration: "200ms"
10958
+ transitionDuration: "200ms",
10959
+ // Slide-card defaults — flat-white to match every other item card. The
10960
+ // Picaz theme overrides this with a gradient + shadow to lift the slides.
10961
+ slideCardBg: "#ffffff",
10962
+ slideCardShadow: "0 1px 2px rgba(15,23,42,.04), 0 1px 0 rgba(15,23,42,.02)",
10963
+ slideCardRadius: "0.5rem",
10964
+ // matches borderRadius
10965
+ slideCardMinHeight: "0",
10966
+ slideCardPadding: "12px",
10967
+ // Section-label defaults — same look as the regular field label. Themes
10968
+ // that want the "ПРЕИМУЩЕСТВА" caps style override these three vars.
10969
+ labelSectionFontSize: "0.875rem",
10970
+ // matches fontSize
10971
+ labelSectionLetterSpacing: "normal",
10972
+ labelSectionTextTransform: "none"
10127
10973
  };
10128
10974
  function generateCSSVariables(theme) {
10129
10975
  const mergedTheme = { ...defaultTheme, ...theme };
@@ -10135,6 +10981,7 @@ function generateCSSVariables(theme) {
10135
10981
  return cssVars.join("\n");
10136
10982
  }
10137
10983
  function injectThemeVariables(container, theme) {
10984
+ ensureThemingHooks(container.ownerDocument || document);
10138
10985
  const cssVariables = generateCSSVariables(theme);
10139
10986
  let styleTag = container.querySelector(
10140
10987
  "style[data-fb-theme]"
@@ -10197,114 +11044,64 @@ var exampleThemes = {
10197
11044
  fontSize: "16px",
10198
11045
  fontSizeSmall: "14px",
10199
11046
  fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif'
10200
- }
10201
- };
10202
-
10203
- // src/components/registry.ts
10204
- function validateHiddenElement(element, key, context) {
10205
- const { scopeRoot } = context;
10206
- const input = scopeRoot.querySelector(
10207
- `input[type="hidden"][data-hidden-field="true"][name="${key}"]`
10208
- );
10209
- const raw = input?.value ?? "";
10210
- if (raw === "") {
10211
- const defaultVal = "default" in element ? element.default : null;
10212
- return { value: defaultVal !== void 0 ? defaultVal : null, errors: [] };
10213
- }
10214
- return { value: deserializeHiddenValue(raw), errors: [] };
10215
- }
10216
- function updateHiddenField(_element, fieldPath, value, context) {
10217
- const { scopeRoot } = context;
10218
- const input = scopeRoot.querySelector(
10219
- `input[type="hidden"][data-hidden-field="true"][name="${fieldPath}"]`
10220
- );
10221
- if (!input) return;
10222
- input.value = serializeHiddenValue(value);
10223
- }
10224
- var componentRegistry = {
10225
- text: {
10226
- validate: validateTextElement,
10227
- update: updateTextField
10228
- },
10229
- textarea: {
10230
- validate: validateTextareaElement,
10231
- update: updateTextareaField
10232
- },
10233
- number: {
10234
- validate: validateNumberElement,
10235
- update: updateNumberField
10236
- },
10237
- select: {
10238
- validate: validateSelectElement,
10239
- update: updateSelectField
10240
- },
10241
- switcher: {
10242
- validate: validateSwitcherElement,
10243
- update: updateSwitcherField
10244
- },
10245
- file: {
10246
- validate: validateFileElement,
10247
- update: updateFileField
10248
- },
10249
- files: {
10250
- // Legacy type - delegates to file
10251
- validate: validateFileElement,
10252
- update: updateFileField
10253
- },
10254
- colour: {
10255
- validate: validateColourElement,
10256
- update: updateColourField
10257
- },
10258
- slider: {
10259
- validate: validateSliderElement,
10260
- update: updateSliderField
10261
- },
10262
- container: {
10263
- validate: validateContainerElement,
10264
- update: updateContainerField
10265
- },
10266
- group: {
10267
- // Deprecated type - delegates to container
10268
- validate: validateGroupElement,
10269
- update: updateGroupField
10270
- },
10271
- table: {
10272
- validate: validateTableElement,
10273
- update: updateTableField
10274
- },
10275
- richinput: {
10276
- validate: validateRichInputElement,
10277
- update: updateRichInputField
10278
- },
10279
- hidden: {
10280
- // Legacy type: `type: "hidden"` — reads/writes DOM <input type="hidden"> element
10281
- validate: validateHiddenElement,
10282
- update: updateHiddenField
10283
11047
  },
10284
- markdown: {
10285
- // Display-only element no value, no errors, skip from form data
10286
- validate: validateMarkdown,
10287
- update: updateMarkdown
11048
+ // Picaz wizard design tokens — derived from the Picaz Wizard mockups.
11049
+ // Pairs with the host-side .card / .section-num / .lede chrome that wraps the form.
11050
+ // Assumes Inter is loaded by the host (e.g. via Google Fonts in index.html).
11051
+ picaz: {
11052
+ ...defaultTheme,
11053
+ primaryColor: "#2f5bea",
11054
+ primaryHoverColor: "#2349c8",
11055
+ primarySoftColor: "#eaf0ff",
11056
+ primarySoftHoverColor: "#d6e0ff",
11057
+ errorColor: "#ef4444",
11058
+ successColor: "#16a34a",
11059
+ accentColor: "#ffb020",
11060
+ accentSoftColor: "#fff7e6",
11061
+ accentBorderColor: "#fde7b5",
11062
+ accentTextColor: "#92400e",
11063
+ borderColor: "#e3e8f0",
11064
+ borderHoverColor: "#cdd6e3",
11065
+ borderFocusColor: "#2f5bea",
11066
+ borderStrongColor: "#cdd6e3",
11067
+ backgroundColor: "#ffffff",
11068
+ backgroundHoverColor: "#f3f7ff",
11069
+ pageBackgroundColor: "#f6f8fb",
11070
+ surfaceSoftColor: "#eef4ff",
11071
+ surfaceTintColor: "#f3f7ff",
11072
+ textColor: "#0f172a",
11073
+ textSecondaryColor: "#334155",
11074
+ textMutedColor: "#64748b",
11075
+ textFaintColor: "#94a3b8",
11076
+ textPlaceholderColor: "#94a3b8",
11077
+ buttonBgColor: "#2f5bea",
11078
+ buttonHoverBgColor: "#2349c8",
11079
+ fileUploadBgColor: "#fafcff",
11080
+ fileUploadBorderColor: "#cdd6e3",
11081
+ fileUploadHoverBorderColor: "#2f5bea",
11082
+ // Picaz uses roomier inputs (11/14px) than the defaultTheme compact density.
11083
+ inputPaddingX: "14px",
11084
+ inputPaddingY: "11px",
11085
+ borderRadius: "12px",
11086
+ borderRadiusSmall: "8px",
11087
+ borderRadiusLarge: "16px",
11088
+ borderRadiusXLarge: "22px",
11089
+ fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
11090
+ shadowCard: "0 1px 2px rgba(15,23,42,.04), 0 1px 0 rgba(15,23,42,.02)",
11091
+ shadowPopover: "0 12px 32px -12px rgba(15,23,42,.18), 0 4px 12px -6px rgba(15,23,42,.08)",
11092
+ focusRingColor: "#2f5bea",
11093
+ // Slide cards: subtle gradient lift, no rest-state shadow (mockup adds it
11094
+ // only on hover, which form-builder doesn't yet differentiate).
11095
+ slideCardBg: "linear-gradient(180deg, #f7f9fc 0%, #dde3ee 100%)",
11096
+ slideCardShadow: "none",
11097
+ slideCardRadius: "16px",
11098
+ // Tiny uppercase captions above grouped lists ("ПРЕИМУЩЕСТВА" in the mockup).
11099
+ labelSectionFontSize: "0.625rem",
11100
+ // 10px
11101
+ labelSectionLetterSpacing: "0.07em",
11102
+ labelSectionTextTransform: "uppercase"
10288
11103
  }
10289
11104
  };
10290
- function getComponentOperations(elementType) {
10291
- return componentRegistry[elementType] || null;
10292
- }
10293
- function validateElementWithComponent(element, key, context) {
10294
- const ops = getComponentOperations(element.type);
10295
- if (ops && ops.validate) {
10296
- return ops.validate(element, key, context);
10297
- }
10298
- return null;
10299
- }
10300
- function updateElementWithComponent(element, fieldPath, value, context) {
10301
- const ops = getComponentOperations(element.type);
10302
- if (ops && ops.update) {
10303
- ops.update(element, fieldPath, value, context);
10304
- return true;
10305
- }
10306
- return false;
10307
- }
10308
11105
 
10309
11106
  // src/instance/FormBuilderInstance.ts
10310
11107
  var FormBuilderInstance = class {
@@ -10429,26 +11226,37 @@ var FormBuilderInstance = class {
10429
11226
  }
10430
11227
  }
10431
11228
  /**
10432
- * Find the DOM element corresponding to a field path (instance-scoped)
11229
+ * Find the DOM element corresponding to a field path (instance-scoped).
11230
+ *
11231
+ * Strategy:
11232
+ * 1. Try a `[name="…"]` lookup first — works for any field that renders
11233
+ * an input/hidden with the path as its name, in either mode. Some
11234
+ * readonly renderers still emit a hidden input (boolean, switcher),
11235
+ * so this path must run regardless of `state.config.readonly`. A
11236
+ * prior version gated this on edit mode only, which made
11237
+ * `updateField` / `setFormData` silently miss readonly boolean
11238
+ * fields whose component also opts out of the standard label row
11239
+ * (`ownsLabel: true`).
11240
+ * 2. If no input matched, fall back to locating the field wrapper by
11241
+ * its visible label text — needed for readonly previews that don't
11242
+ * emit any `name=` attribute (e.g. file/markdown previews).
10433
11243
  */
10434
11244
  findFormElementByFieldPath(fieldPath) {
10435
11245
  if (!this.state.formRoot) return null;
10436
- if (!this.state.config.readonly) {
10437
- let element = this.state.formRoot.querySelector(
10438
- `[name="${fieldPath}"]`
11246
+ let element = this.state.formRoot.querySelector(
11247
+ `[name="${fieldPath}"]`
11248
+ );
11249
+ if (element) return element;
11250
+ const variations = [
11251
+ fieldPath,
11252
+ fieldPath.replace(/\[(\d+)\]/g, "[$1]"),
11253
+ fieldPath.replace(/\./g, "[") + "]".repeat((fieldPath.match(/\./g) || []).length)
11254
+ ];
11255
+ for (const variation of variations) {
11256
+ element = this.state.formRoot.querySelector(
11257
+ `[name="${variation}"]`
10439
11258
  );
10440
11259
  if (element) return element;
10441
- const variations = [
10442
- fieldPath,
10443
- fieldPath.replace(/\[(\d+)\]/g, "[$1]"),
10444
- fieldPath.replace(/\./g, "[") + "]".repeat((fieldPath.match(/\./g) || []).length)
10445
- ];
10446
- for (const variation of variations) {
10447
- element = this.state.formRoot.querySelector(
10448
- `[name="${variation}"]`
10449
- );
10450
- if (element) return element;
10451
- }
10452
11260
  }
10453
11261
  const schemaElement = this.findSchemaElement(fieldPath);
10454
11262
  if (!schemaElement) return null;
@@ -10657,6 +11465,13 @@ var FormBuilderInstance = class {
10657
11465
  const value = hintValues[fieldKey];
10658
11466
  this.updateField(fullPath, value);
10659
11467
  }
11468
+ const group = target.closest(".fb-prefill-hints");
11469
+ if (group) {
11470
+ group.querySelectorAll(
11471
+ '.fb-prefill-hint[aria-pressed="true"]'
11472
+ ).forEach((el) => el.removeAttribute("aria-pressed"));
11473
+ }
11474
+ target.setAttribute("aria-pressed", "true");
10660
11475
  } catch (error) {
10661
11476
  console.error("Error parsing prefill hint values:", error);
10662
11477
  }