@dmitryvim/form-builder 0.2.11 → 0.2.12

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
@@ -1,53 +1,83 @@
1
+ // src/utils/translation.ts
2
+ function t(key, state, params) {
3
+ const locale = state.config.locale || "en";
4
+ const localeTranslations = state.config.translations[locale];
5
+ const fallbackTranslations = state.config.translations.en;
6
+ let text = localeTranslations?.[key] || fallbackTranslations?.[key] || key;
7
+ if (params) {
8
+ for (const [paramKey, paramValue] of Object.entries(params)) {
9
+ text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), String(paramValue));
10
+ }
11
+ }
12
+ return text;
13
+ }
14
+
1
15
  // src/utils/validation.ts
2
- function addLengthHint(element, parts) {
3
- if (element.minLength !== null || element.maxLength !== null) {
4
- if (element.minLength !== null && element.maxLength !== null) {
5
- parts.push(`length=${element.minLength}-${element.maxLength} characters`);
6
- } else if (element.maxLength !== null) {
7
- parts.push(`max=${element.maxLength} characters`);
8
- } else if (element.minLength !== null) {
9
- parts.push(`min=${element.minLength} characters`);
16
+ function addLengthHint(element, parts, state) {
17
+ if (element.minLength != null || element.maxLength != null) {
18
+ if (element.minLength != null && element.maxLength != null) {
19
+ parts.push(
20
+ t("hintLengthRange", state, {
21
+ min: element.minLength,
22
+ max: element.maxLength
23
+ })
24
+ );
25
+ } else if (element.maxLength != null) {
26
+ parts.push(t("hintMaxLength", state, { max: element.maxLength }));
27
+ } else if (element.minLength != null) {
28
+ parts.push(t("hintMinLength", state, { min: element.minLength }));
10
29
  }
11
30
  }
12
31
  }
13
- function addRangeHint(element, parts) {
14
- if (element.min !== null || element.max !== null) {
15
- if (element.min !== null && element.max !== null) {
16
- parts.push(`range=${element.min}-${element.max}`);
17
- } else if (element.max !== null) {
18
- parts.push(`max=${element.max}`);
19
- } else if (element.min !== null) {
20
- parts.push(`min=${element.min}`);
32
+ function addRangeHint(element, parts, state) {
33
+ if (element.min != null || element.max != null) {
34
+ if (element.min != null && element.max != null) {
35
+ parts.push(
36
+ t("hintValueRange", state, { min: element.min, max: element.max })
37
+ );
38
+ } else if (element.max != null) {
39
+ parts.push(t("hintMaxValue", state, { max: element.max }));
40
+ } else if (element.min != null) {
41
+ parts.push(t("hintMinValue", state, { min: element.min }));
21
42
  }
22
43
  }
23
44
  }
24
- function addFileSizeHint(element, parts) {
45
+ function addFileSizeHint(element, parts, state) {
25
46
  if (element.maxSizeMB) {
26
- parts.push(`max_size=${element.maxSizeMB}MB`);
47
+ parts.push(t("hintMaxSize", state, { size: element.maxSizeMB }));
27
48
  }
28
49
  }
29
- function addFormatHint(element, parts) {
50
+ function addFormatHint(element, parts, state) {
30
51
  if (element.accept?.extensions) {
31
52
  parts.push(
32
- `formats=${element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")}`
53
+ t("hintFormats", state, {
54
+ formats: element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")
55
+ })
33
56
  );
34
57
  }
35
58
  }
36
- function addPatternHint(element, parts) {
37
- if (element.pattern && !element.pattern.includes("\u0410-\u042F")) {
38
- parts.push("plain text only");
39
- } else if (element.pattern?.includes("\u0410-\u042F")) {
40
- parts.push("text with punctuation");
59
+ function addRequiredHint(element, parts, state) {
60
+ if (element.required) {
61
+ parts.push(t("hintRequired", state));
62
+ } else {
63
+ parts.push(t("hintOptional", state));
64
+ }
65
+ }
66
+ function addPatternHint(element, parts, state) {
67
+ if (element.pattern) {
68
+ parts.push(t("hintPattern", state, { pattern: element.pattern }));
41
69
  }
42
70
  }
43
- function makeFieldHint(element) {
71
+ function makeFieldHint(element, state) {
44
72
  const parts = [];
45
- parts.push(element.required ? "required" : "optional");
46
- addLengthHint(element, parts);
47
- addRangeHint(element, parts);
48
- addFileSizeHint(element, parts);
49
- addFormatHint(element, parts);
50
- addPatternHint(element, parts);
73
+ addRequiredHint(element, parts, state);
74
+ addLengthHint(element, parts, state);
75
+ if (element.type !== "slider") {
76
+ addRangeHint(element, parts, state);
77
+ }
78
+ addFileSizeHint(element, parts, state);
79
+ addFormatHint(element, parts, state);
80
+ addPatternHint(element, parts, state);
51
81
  return parts.join(" \u2022 ");
52
82
  }
53
83
  function validateSchema(schema) {
@@ -185,6 +215,11 @@ function validateSchema(schema) {
185
215
  function isPlainObject(obj) {
186
216
  return obj && typeof obj === "object" && obj.constructor === Object;
187
217
  }
218
+ function escapeHtml(text) {
219
+ const div = document.createElement("div");
220
+ div.textContent = text;
221
+ return div.innerHTML;
222
+ }
188
223
  function pathJoin(base, key) {
189
224
  return base ? `${base}.${key}` : key;
190
225
  }
@@ -262,13 +297,62 @@ function deepEqual(a, b) {
262
297
  }
263
298
 
264
299
  // src/components/text.ts
300
+ function createCharCounter(element, input, isTextarea = false) {
301
+ const counter = document.createElement("span");
302
+ counter.className = "char-counter";
303
+ counter.style.cssText = `
304
+ position: absolute;
305
+ ${isTextarea ? "bottom: 8px" : "top: 50%; transform: translateY(-50%)"};
306
+ right: 10px;
307
+ font-size: var(--fb-font-size-small);
308
+ color: var(--fb-text-secondary-color);
309
+ pointer-events: none;
310
+ background: var(--fb-background-color);
311
+ padding: 0 4px;
312
+ `;
313
+ const updateCounter = () => {
314
+ const len = input.value.length;
315
+ const min = element.minLength;
316
+ const max = element.maxLength;
317
+ if (min == null && max == null) {
318
+ counter.textContent = "";
319
+ return;
320
+ }
321
+ if (len === 0 || min != null && len < min) {
322
+ if (min != null && max != null) {
323
+ counter.textContent = `${min}-${max}`;
324
+ } else if (max != null) {
325
+ counter.textContent = `\u2264${max}`;
326
+ } else if (min != null) {
327
+ counter.textContent = `\u2265${min}`;
328
+ }
329
+ counter.style.color = "var(--fb-text-secondary-color)";
330
+ } else if (max != null && len > max) {
331
+ counter.textContent = `${len}/${max}`;
332
+ counter.style.color = "var(--fb-error-color)";
333
+ } else {
334
+ if (max != null) {
335
+ counter.textContent = `${len}/${max}`;
336
+ } else {
337
+ counter.textContent = `${len}`;
338
+ }
339
+ counter.style.color = "var(--fb-text-secondary-color)";
340
+ }
341
+ };
342
+ input.addEventListener("input", updateCounter);
343
+ updateCounter();
344
+ return counter;
345
+ }
265
346
  function renderTextElement(element, ctx, wrapper, pathKey) {
266
347
  const state = ctx.state;
348
+ const inputWrapper = document.createElement("div");
349
+ inputWrapper.style.cssText = "position: relative;";
267
350
  const textInput = document.createElement("input");
268
351
  textInput.type = "text";
269
352
  textInput.className = "w-full rounded-lg";
270
353
  textInput.style.cssText = `
271
354
  padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
355
+ padding-right: 60px;
272
356
  border: var(--fb-border-width) solid var(--fb-border-color);
273
357
  border-radius: var(--fb-border-radius);
274
358
  background-color: ${state.config.readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
@@ -276,6 +360,8 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
276
360
  font-size: var(--fb-font-size);
277
361
  font-family: var(--fb-font-family);
278
362
  transition: all var(--fb-transition-duration) ease-in-out;
363
+ width: 100%;
364
+ box-sizing: border-box;
279
365
  `;
280
366
  textInput.name = pathKey;
281
367
  textInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
@@ -310,15 +396,12 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
310
396
  textInput.addEventListener("blur", handleChange);
311
397
  textInput.addEventListener("input", handleChange);
312
398
  }
313
- wrapper.appendChild(textInput);
314
- const textHint = document.createElement("p");
315
- textHint.className = "mt-1";
316
- textHint.style.cssText = `
317
- font-size: var(--fb-font-size-small);
318
- color: var(--fb-text-secondary-color);
319
- `;
320
- textHint.textContent = makeFieldHint(element);
321
- wrapper.appendChild(textHint);
399
+ inputWrapper.appendChild(textInput);
400
+ if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
401
+ const counter = createCharCounter(element, textInput, false);
402
+ inputWrapper.appendChild(counter);
403
+ }
404
+ wrapper.appendChild(inputWrapper);
322
405
  }
323
406
  function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
324
407
  const state = ctx.state;
@@ -344,11 +427,13 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
344
427
  function addTextItem(value = "", index = -1) {
345
428
  const itemWrapper = document.createElement("div");
346
429
  itemWrapper.className = "multiple-text-item flex items-center gap-2";
430
+ const inputContainer = document.createElement("div");
431
+ inputContainer.style.cssText = "position: relative; flex: 1;";
347
432
  const textInput = document.createElement("input");
348
433
  textInput.type = "text";
349
- textInput.className = "flex-1";
350
434
  textInput.style.cssText = `
351
435
  padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
436
+ padding-right: 60px;
352
437
  border: var(--fb-border-width) solid var(--fb-border-color);
353
438
  border-radius: var(--fb-border-radius);
354
439
  background-color: ${state.config.readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
@@ -356,8 +441,10 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
356
441
  font-size: var(--fb-font-size);
357
442
  font-family: var(--fb-font-family);
358
443
  transition: all var(--fb-transition-duration) ease-in-out;
444
+ width: 100%;
445
+ box-sizing: border-box;
359
446
  `;
360
- textInput.placeholder = element.placeholder || "Enter text";
447
+ textInput.placeholder = element.placeholder || t("placeholderText", state);
361
448
  textInput.value = value;
362
449
  textInput.readOnly = state.config.readonly;
363
450
  if (!state.config.readonly) {
@@ -389,7 +476,12 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
389
476
  textInput.addEventListener("blur", handleChange);
390
477
  textInput.addEventListener("input", handleChange);
391
478
  }
392
- itemWrapper.appendChild(textInput);
479
+ inputContainer.appendChild(textInput);
480
+ if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
481
+ const counter = createCharCounter(element, textInput, false);
482
+ inputContainer.appendChild(counter);
483
+ }
484
+ itemWrapper.appendChild(inputContainer);
393
485
  if (index === -1) {
394
486
  container.appendChild(itemWrapper);
395
487
  } else {
@@ -442,47 +534,54 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
442
534
  removeBtn.style.pointerEvents = disabled ? "none" : "auto";
443
535
  });
444
536
  }
537
+ let addRow = null;
538
+ let countDisplay = null;
539
+ if (!state.config.readonly) {
540
+ addRow = document.createElement("div");
541
+ addRow.className = "flex items-center gap-3 mt-2";
542
+ const addBtn = document.createElement("button");
543
+ addBtn.type = "button";
544
+ addBtn.className = "add-text-btn px-3 py-1 rounded";
545
+ addBtn.style.cssText = `
546
+ color: var(--fb-primary-color);
547
+ border: var(--fb-border-width) solid var(--fb-primary-color);
548
+ background-color: transparent;
549
+ font-size: var(--fb-font-size);
550
+ transition: all var(--fb-transition-duration);
551
+ `;
552
+ addBtn.textContent = "+";
553
+ addBtn.addEventListener("mouseenter", () => {
554
+ addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
555
+ });
556
+ addBtn.addEventListener("mouseleave", () => {
557
+ addBtn.style.backgroundColor = "transparent";
558
+ });
559
+ addBtn.onclick = () => {
560
+ values.push(element.default || "");
561
+ addTextItem(element.default || "");
562
+ updateAddButton();
563
+ updateRemoveButtons();
564
+ };
565
+ countDisplay = document.createElement("span");
566
+ countDisplay.className = "text-sm text-gray-500";
567
+ addRow.appendChild(addBtn);
568
+ addRow.appendChild(countDisplay);
569
+ wrapper.appendChild(addRow);
570
+ }
445
571
  function updateAddButton() {
446
- const existingAddBtn = wrapper.querySelector(".add-text-btn");
447
- if (existingAddBtn) existingAddBtn.remove();
448
- if (!state.config.readonly && values.length < maxCount) {
449
- const addBtn = document.createElement("button");
450
- addBtn.type = "button";
451
- addBtn.className = "add-text-btn mt-2 px-3 py-1 rounded";
452
- addBtn.style.cssText = `
453
- color: var(--fb-primary-color);
454
- border: var(--fb-border-width) solid var(--fb-primary-color);
455
- background-color: transparent;
456
- font-size: var(--fb-font-size);
457
- transition: all var(--fb-transition-duration);
458
- `;
459
- addBtn.textContent = "+";
460
- addBtn.addEventListener("mouseenter", () => {
461
- addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
462
- });
463
- addBtn.addEventListener("mouseleave", () => {
464
- addBtn.style.backgroundColor = "transparent";
465
- });
466
- addBtn.onclick = () => {
467
- values.push(element.default || "");
468
- addTextItem(element.default || "");
469
- updateAddButton();
470
- updateRemoveButtons();
471
- };
472
- wrapper.appendChild(addBtn);
572
+ if (!addRow || !countDisplay) return;
573
+ const addBtn = addRow.querySelector(".add-text-btn");
574
+ if (addBtn) {
575
+ const disabled = values.length >= maxCount;
576
+ addBtn.disabled = disabled;
577
+ addBtn.style.opacity = disabled ? "0.5" : "1";
578
+ addBtn.style.pointerEvents = disabled ? "none" : "auto";
473
579
  }
580
+ countDisplay.textContent = `${values.length}/${maxCount === Infinity ? "\u221E" : maxCount}`;
474
581
  }
475
582
  values.forEach((value) => addTextItem(value));
476
583
  updateAddButton();
477
584
  updateRemoveButtons();
478
- const hint = document.createElement("p");
479
- hint.className = "mt-1";
480
- hint.style.cssText = `
481
- font-size: var(--fb-font-size-small);
482
- color: var(--fb-text-secondary-color);
483
- `;
484
- hint.textContent = makeFieldHint(element);
485
- wrapper.appendChild(hint);
486
585
  }
487
586
  function validateTextElement(element, key, context) {
488
587
  const errors = [];
@@ -521,26 +620,31 @@ function validateTextElement(element, key, context) {
521
620
  };
522
621
  const validateTextInput = (input, val, fieldKey) => {
523
622
  let hasError = false;
623
+ const { state } = context;
524
624
  if (!skipValidation && val) {
525
625
  if (element.minLength !== void 0 && element.minLength !== null && val.length < element.minLength) {
526
- errors.push(`${fieldKey}: minLength=${element.minLength}`);
527
- markValidity(input, `minLength=${element.minLength}`);
626
+ const msg = t("minLength", state, { min: element.minLength });
627
+ errors.push(`${fieldKey}: ${msg}`);
628
+ markValidity(input, msg);
528
629
  hasError = true;
529
630
  } else if (element.maxLength !== void 0 && element.maxLength !== null && val.length > element.maxLength) {
530
- errors.push(`${fieldKey}: maxLength=${element.maxLength}`);
531
- markValidity(input, `maxLength=${element.maxLength}`);
631
+ const msg = t("maxLength", state, { max: element.maxLength });
632
+ errors.push(`${fieldKey}: ${msg}`);
633
+ markValidity(input, msg);
532
634
  hasError = true;
533
635
  } else if (element.pattern) {
534
636
  try {
535
637
  const re = new RegExp(element.pattern);
536
638
  if (!re.test(val)) {
537
- errors.push(`${fieldKey}: pattern mismatch`);
538
- markValidity(input, "pattern mismatch");
639
+ const msg = t("patternMismatch", state);
640
+ errors.push(`${fieldKey}: ${msg}`);
641
+ markValidity(input, msg);
539
642
  hasError = true;
540
643
  }
541
644
  } catch {
542
- errors.push(`${fieldKey}: invalid pattern`);
543
- markValidity(input, "invalid pattern");
645
+ const msg = t("invalidPattern", state);
646
+ errors.push(`${fieldKey}: ${msg}`);
647
+ markValidity(input, msg);
544
648
  hasError = true;
545
649
  }
546
650
  }
@@ -560,17 +664,18 @@ function validateTextElement(element, key, context) {
560
664
  validateTextInput(input, val, `${key}[${index}]`);
561
665
  });
562
666
  if (!skipValidation) {
667
+ const { state } = context;
563
668
  const minCount = element.minCount ?? 1;
564
669
  const maxCount = element.maxCount ?? Infinity;
565
670
  const filteredValues = rawValues.filter((v) => v.trim() !== "");
566
671
  if (element.required && filteredValues.length === 0) {
567
- errors.push(`${key}: required`);
672
+ errors.push(`${key}: ${t("required", state)}`);
568
673
  }
569
674
  if (filteredValues.length < minCount) {
570
- errors.push(`${key}: minimum ${minCount} items required`);
675
+ errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
571
676
  }
572
677
  if (filteredValues.length > maxCount) {
573
- errors.push(`${key}: maximum ${maxCount} items allowed`);
678
+ errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
574
679
  }
575
680
  }
576
681
  return { value: values, errors };
@@ -578,8 +683,9 @@ function validateTextElement(element, key, context) {
578
683
  const input = scopeRoot.querySelector(`[name$="${key}"]`);
579
684
  const val = input?.value ?? "";
580
685
  if (!skipValidation && element.required && val === "") {
581
- errors.push(`${key}: required`);
582
- markValidity(input, "required");
686
+ const msg = t("required", context.state);
687
+ errors.push(`${key}: ${msg}`);
688
+ markValidity(input, msg);
583
689
  return { value: null, errors };
584
690
  }
585
691
  if (input) {
@@ -623,8 +729,11 @@ function updateTextField(element, fieldPath, value, context) {
623
729
  // src/components/textarea.ts
624
730
  function renderTextareaElement(element, ctx, wrapper, pathKey) {
625
731
  const state = ctx.state;
732
+ const textareaWrapper = document.createElement("div");
733
+ textareaWrapper.style.cssText = "position: relative;";
626
734
  const textareaInput = document.createElement("textarea");
627
735
  textareaInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
736
+ textareaInput.style.cssText = "padding-bottom: 24px;";
628
737
  textareaInput.name = pathKey;
629
738
  textareaInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
630
739
  textareaInput.rows = element.rows || 4;
@@ -638,11 +747,12 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
638
747
  textareaInput.addEventListener("blur", handleChange);
639
748
  textareaInput.addEventListener("input", handleChange);
640
749
  }
641
- wrapper.appendChild(textareaInput);
642
- const textareaHint = document.createElement("p");
643
- textareaHint.className = "text-xs text-gray-500 mt-1";
644
- textareaHint.textContent = makeFieldHint(element);
645
- wrapper.appendChild(textareaHint);
750
+ textareaWrapper.appendChild(textareaInput);
751
+ if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
752
+ const counter = createCharCounter(element, textareaInput, true);
753
+ textareaWrapper.appendChild(counter);
754
+ }
755
+ wrapper.appendChild(textareaWrapper);
646
756
  }
647
757
  function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
648
758
  const state = ctx.state;
@@ -668,9 +778,12 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
668
778
  function addTextareaItem(value = "", index = -1) {
669
779
  const itemWrapper = document.createElement("div");
670
780
  itemWrapper.className = "multiple-textarea-item";
781
+ const textareaContainer = document.createElement("div");
782
+ textareaContainer.style.cssText = "position: relative;";
671
783
  const textareaInput = document.createElement("textarea");
672
784
  textareaInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
673
- textareaInput.placeholder = element.placeholder || "Enter text";
785
+ textareaInput.style.cssText = "padding-bottom: 24px;";
786
+ textareaInput.placeholder = element.placeholder || t("placeholderText", state);
674
787
  textareaInput.rows = element.rows || 4;
675
788
  textareaInput.value = value;
676
789
  textareaInput.readOnly = state.config.readonly;
@@ -682,7 +795,12 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
682
795
  textareaInput.addEventListener("blur", handleChange);
683
796
  textareaInput.addEventListener("input", handleChange);
684
797
  }
685
- itemWrapper.appendChild(textareaInput);
798
+ textareaContainer.appendChild(textareaInput);
799
+ if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
800
+ const counter = createCharCounter(element, textareaInput, true);
801
+ textareaContainer.appendChild(counter);
802
+ }
803
+ itemWrapper.appendChild(textareaContainer);
686
804
  if (index === -1) {
687
805
  container.appendChild(itemWrapper);
688
806
  } else {
@@ -724,30 +842,54 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
724
842
  removeBtn.style.pointerEvents = disabled ? "none" : "auto";
725
843
  });
726
844
  }
845
+ let addRow = null;
846
+ let countDisplay = null;
847
+ if (!state.config.readonly) {
848
+ addRow = document.createElement("div");
849
+ addRow.className = "flex items-center gap-3 mt-2";
850
+ const addBtn = document.createElement("button");
851
+ addBtn.type = "button";
852
+ addBtn.className = "add-textarea-btn px-3 py-1 rounded";
853
+ addBtn.style.cssText = `
854
+ color: var(--fb-primary-color);
855
+ border: var(--fb-border-width) solid var(--fb-primary-color);
856
+ background-color: transparent;
857
+ font-size: var(--fb-font-size);
858
+ transition: all var(--fb-transition-duration);
859
+ `;
860
+ addBtn.textContent = "+";
861
+ addBtn.addEventListener("mouseenter", () => {
862
+ addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
863
+ });
864
+ addBtn.addEventListener("mouseleave", () => {
865
+ addBtn.style.backgroundColor = "transparent";
866
+ });
867
+ addBtn.onclick = () => {
868
+ values.push(element.default || "");
869
+ addTextareaItem(element.default || "");
870
+ updateAddButton();
871
+ updateRemoveButtons();
872
+ };
873
+ countDisplay = document.createElement("span");
874
+ countDisplay.className = "text-sm text-gray-500";
875
+ addRow.appendChild(addBtn);
876
+ addRow.appendChild(countDisplay);
877
+ wrapper.appendChild(addRow);
878
+ }
727
879
  function updateAddButton() {
728
- const existingAddBtn = wrapper.querySelector(".add-textarea-btn");
729
- if (existingAddBtn) existingAddBtn.remove();
730
- if (!state.config.readonly && values.length < maxCount) {
731
- const addBtn = document.createElement("button");
732
- addBtn.type = "button";
733
- addBtn.className = "add-textarea-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
734
- addBtn.textContent = "+";
735
- addBtn.onclick = () => {
736
- values.push(element.default || "");
737
- addTextareaItem(element.default || "");
738
- updateAddButton();
739
- updateRemoveButtons();
740
- };
741
- wrapper.appendChild(addBtn);
880
+ if (!addRow || !countDisplay) return;
881
+ const addBtn = addRow.querySelector(".add-textarea-btn");
882
+ if (addBtn) {
883
+ const disabled = values.length >= maxCount;
884
+ addBtn.disabled = disabled;
885
+ addBtn.style.opacity = disabled ? "0.5" : "1";
886
+ addBtn.style.pointerEvents = disabled ? "none" : "auto";
742
887
  }
888
+ countDisplay.textContent = `${values.length}/${maxCount === Infinity ? "\u221E" : maxCount}`;
743
889
  }
744
890
  values.forEach((value) => addTextareaItem(value));
745
891
  updateAddButton();
746
892
  updateRemoveButtons();
747
- const hint = document.createElement("p");
748
- hint.className = "text-xs text-gray-500 mt-1";
749
- hint.textContent = makeFieldHint(element);
750
- wrapper.appendChild(hint);
751
893
  }
752
894
  function validateTextareaElement(element, key, context) {
753
895
  return validateTextElement(element, key, context);
@@ -757,11 +899,61 @@ function updateTextareaField(element, fieldPath, value, context) {
757
899
  }
758
900
 
759
901
  // src/components/number.ts
902
+ function createNumberCounter(element, input) {
903
+ const counter = document.createElement("span");
904
+ counter.className = "number-counter";
905
+ counter.style.cssText = `
906
+ position: absolute;
907
+ top: 50%;
908
+ transform: translateY(-50%);
909
+ right: 10px;
910
+ font-size: var(--fb-font-size-small);
911
+ color: var(--fb-text-secondary-color);
912
+ pointer-events: none;
913
+ background: var(--fb-background-color);
914
+ padding: 0 4px;
915
+ `;
916
+ const updateCounter = () => {
917
+ const val = input.value ? parseFloat(input.value) : null;
918
+ const min = element.min;
919
+ const max = element.max;
920
+ if (min == null && max == null) {
921
+ counter.textContent = "";
922
+ return;
923
+ }
924
+ if (val == null || min != null && val < min) {
925
+ if (min != null && max != null) {
926
+ counter.textContent = `${min}-${max}`;
927
+ } else if (max != null) {
928
+ counter.textContent = `\u2264${max}`;
929
+ } else if (min != null) {
930
+ counter.textContent = `\u2265${min}`;
931
+ }
932
+ counter.style.color = "var(--fb-text-secondary-color)";
933
+ } else if (max != null && val > max) {
934
+ counter.textContent = `${val}/${max}`;
935
+ counter.style.color = "var(--fb-error-color)";
936
+ } else {
937
+ if (max != null) {
938
+ counter.textContent = `${val}/${max}`;
939
+ } else {
940
+ counter.textContent = `${val}`;
941
+ }
942
+ counter.style.color = "var(--fb-text-secondary-color)";
943
+ }
944
+ };
945
+ input.addEventListener("input", updateCounter);
946
+ updateCounter();
947
+ return counter;
948
+ }
760
949
  function renderNumberElement(element, ctx, wrapper, pathKey) {
761
950
  const state = ctx.state;
951
+ const inputWrapper = document.createElement("div");
952
+ inputWrapper.style.cssText = "position: relative;";
762
953
  const numberInput = document.createElement("input");
763
954
  numberInput.type = "number";
764
955
  numberInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
956
+ numberInput.style.cssText = "padding-right: 60px; width: 100%; box-sizing: border-box;";
765
957
  numberInput.name = pathKey;
766
958
  numberInput.placeholder = element.placeholder || "0";
767
959
  if (element.min !== void 0) numberInput.min = element.min.toString();
@@ -777,11 +969,12 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
777
969
  numberInput.addEventListener("blur", handleChange);
778
970
  numberInput.addEventListener("input", handleChange);
779
971
  }
780
- wrapper.appendChild(numberInput);
781
- const numberHint = document.createElement("p");
782
- numberHint.className = "text-xs text-gray-500 mt-1";
783
- numberHint.textContent = makeFieldHint(element);
784
- wrapper.appendChild(numberHint);
972
+ inputWrapper.appendChild(numberInput);
973
+ if (!state.config.readonly && (element.min != null || element.max != null)) {
974
+ const counter = createNumberCounter(element, numberInput);
975
+ inputWrapper.appendChild(counter);
976
+ }
977
+ wrapper.appendChild(inputWrapper);
785
978
  }
786
979
  function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
787
980
  const state = ctx.state;
@@ -807,9 +1000,12 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
807
1000
  function addNumberItem(value = "", index = -1) {
808
1001
  const itemWrapper = document.createElement("div");
809
1002
  itemWrapper.className = "multiple-number-item flex items-center gap-2";
1003
+ const inputContainer = document.createElement("div");
1004
+ inputContainer.style.cssText = "position: relative; flex: 1;";
810
1005
  const numberInput = document.createElement("input");
811
1006
  numberInput.type = "number";
812
- numberInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1007
+ numberInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1008
+ numberInput.style.cssText = "padding-right: 60px; width: 100%; box-sizing: border-box;";
813
1009
  numberInput.placeholder = element.placeholder || "0";
814
1010
  if (element.min !== void 0) numberInput.min = element.min.toString();
815
1011
  if (element.max !== void 0) numberInput.max = element.max.toString();
@@ -824,7 +1020,12 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
824
1020
  numberInput.addEventListener("blur", handleChange);
825
1021
  numberInput.addEventListener("input", handleChange);
826
1022
  }
827
- itemWrapper.appendChild(numberInput);
1023
+ inputContainer.appendChild(numberInput);
1024
+ if (!state.config.readonly && (element.min != null || element.max != null)) {
1025
+ const counter = createNumberCounter(element, numberInput);
1026
+ inputContainer.appendChild(counter);
1027
+ }
1028
+ itemWrapper.appendChild(inputContainer);
828
1029
  if (index === -1) {
829
1030
  container.appendChild(itemWrapper);
830
1031
  } else {
@@ -866,30 +1067,54 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
866
1067
  removeBtn.style.pointerEvents = disabled ? "none" : "auto";
867
1068
  });
868
1069
  }
1070
+ let addRow = null;
1071
+ let countDisplay = null;
1072
+ if (!state.config.readonly) {
1073
+ addRow = document.createElement("div");
1074
+ addRow.className = "flex items-center gap-3 mt-2";
1075
+ const addBtn = document.createElement("button");
1076
+ addBtn.type = "button";
1077
+ addBtn.className = "add-number-btn px-3 py-1 rounded";
1078
+ addBtn.style.cssText = `
1079
+ color: var(--fb-primary-color);
1080
+ border: var(--fb-border-width) solid var(--fb-primary-color);
1081
+ background-color: transparent;
1082
+ font-size: var(--fb-font-size);
1083
+ transition: all var(--fb-transition-duration);
1084
+ `;
1085
+ addBtn.textContent = "+";
1086
+ addBtn.addEventListener("mouseenter", () => {
1087
+ addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
1088
+ });
1089
+ addBtn.addEventListener("mouseleave", () => {
1090
+ addBtn.style.backgroundColor = "transparent";
1091
+ });
1092
+ addBtn.onclick = () => {
1093
+ values.push(element.default || "");
1094
+ addNumberItem(element.default || "");
1095
+ updateAddButton();
1096
+ updateRemoveButtons();
1097
+ };
1098
+ countDisplay = document.createElement("span");
1099
+ countDisplay.className = "text-sm text-gray-500";
1100
+ addRow.appendChild(addBtn);
1101
+ addRow.appendChild(countDisplay);
1102
+ wrapper.appendChild(addRow);
1103
+ }
869
1104
  function updateAddButton() {
870
- const existingAddBtn = wrapper.querySelector(".add-number-btn");
871
- if (existingAddBtn) existingAddBtn.remove();
872
- if (!state.config.readonly && values.length < maxCount) {
873
- const addBtn = document.createElement("button");
874
- addBtn.type = "button";
875
- addBtn.className = "add-number-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
876
- addBtn.textContent = "+";
877
- addBtn.onclick = () => {
878
- values.push(element.default || "");
879
- addNumberItem(element.default || "");
880
- updateAddButton();
881
- updateRemoveButtons();
882
- };
883
- wrapper.appendChild(addBtn);
1105
+ if (!addRow || !countDisplay) return;
1106
+ const addBtn = addRow.querySelector(".add-number-btn");
1107
+ if (addBtn) {
1108
+ const disabled = values.length >= maxCount;
1109
+ addBtn.disabled = disabled;
1110
+ addBtn.style.opacity = disabled ? "0.5" : "1";
1111
+ addBtn.style.pointerEvents = disabled ? "none" : "auto";
884
1112
  }
1113
+ countDisplay.textContent = `${values.length}/${maxCount === Infinity ? "\u221E" : maxCount}`;
885
1114
  }
886
1115
  values.forEach((value) => addNumberItem(value));
887
1116
  updateAddButton();
888
1117
  updateRemoveButtons();
889
- const hint = document.createElement("p");
890
- hint.className = "text-xs text-gray-500 mt-1";
891
- hint.textContent = makeFieldHint(element);
892
- wrapper.appendChild(hint);
893
1118
  }
894
1119
  function validateNumberElement(element, key, context) {
895
1120
  const errors = [];
@@ -928,13 +1153,16 @@ function validateNumberElement(element, key, context) {
928
1153
  };
929
1154
  const validateNumberInput = (input, v, fieldKey) => {
930
1155
  let hasError = false;
1156
+ const { state } = context;
931
1157
  if (!skipValidation && element.min !== void 0 && element.min !== null && v < element.min) {
932
- errors.push(`${fieldKey}: < min=${element.min}`);
933
- markValidity(input, `< min=${element.min}`);
1158
+ const msg = t("minValue", state, { min: element.min });
1159
+ errors.push(`${fieldKey}: ${msg}`);
1160
+ markValidity(input, msg);
934
1161
  hasError = true;
935
1162
  } else if (!skipValidation && element.max !== void 0 && element.max !== null && v > element.max) {
936
- errors.push(`${fieldKey}: > max=${element.max}`);
937
- markValidity(input, `> max=${element.max}`);
1163
+ const msg = t("maxValue", state, { max: element.max });
1164
+ errors.push(`${fieldKey}: ${msg}`);
1165
+ markValidity(input, msg);
938
1166
  hasError = true;
939
1167
  }
940
1168
  if (!hasError) {
@@ -955,8 +1183,9 @@ function validateNumberElement(element, key, context) {
955
1183
  }
956
1184
  const v = parseFloat(raw);
957
1185
  if (!skipValidation && !Number.isFinite(v)) {
958
- errors.push(`${key}[${index}]: not a number`);
959
- markValidity(input, "not a number");
1186
+ const msg = t("notANumber", context.state);
1187
+ errors.push(`${key}[${index}]: ${msg}`);
1188
+ markValidity(input, msg);
960
1189
  values.push(null);
961
1190
  return;
962
1191
  }
@@ -965,26 +1194,29 @@ function validateNumberElement(element, key, context) {
965
1194
  values.push(Number(v.toFixed(d)));
966
1195
  });
967
1196
  if (!skipValidation) {
1197
+ const { state } = context;
968
1198
  const minCount = element.minCount ?? 1;
969
1199
  const maxCount = element.maxCount ?? Infinity;
970
1200
  const filteredValues = values.filter((v) => v !== null);
971
1201
  if (element.required && filteredValues.length === 0) {
972
- errors.push(`${key}: required`);
1202
+ errors.push(`${key}: ${t("required", state)}`);
973
1203
  }
974
1204
  if (filteredValues.length < minCount) {
975
- errors.push(`${key}: minimum ${minCount} items required`);
1205
+ errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
976
1206
  }
977
1207
  if (filteredValues.length > maxCount) {
978
- errors.push(`${key}: maximum ${maxCount} items allowed`);
1208
+ errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
979
1209
  }
980
1210
  }
981
1211
  return { value: values, errors };
982
1212
  } else {
983
1213
  const input = scopeRoot.querySelector(`[name$="${key}"]`);
984
1214
  const raw = input?.value ?? "";
1215
+ const { state } = context;
985
1216
  if (!skipValidation && element.required && raw === "") {
986
- errors.push(`${key}: required`);
987
- markValidity(input, "required");
1217
+ const msg = t("required", state);
1218
+ errors.push(`${key}: ${msg}`);
1219
+ markValidity(input, msg);
988
1220
  return { value: null, errors };
989
1221
  }
990
1222
  if (raw === "") {
@@ -993,8 +1225,9 @@ function validateNumberElement(element, key, context) {
993
1225
  }
994
1226
  const v = parseFloat(raw);
995
1227
  if (!skipValidation && !Number.isFinite(v)) {
996
- errors.push(`${key}: not a number`);
997
- markValidity(input, "not a number");
1228
+ const msg = t("notANumber", state);
1229
+ errors.push(`${key}: ${msg}`);
1230
+ markValidity(input, msg);
998
1231
  return { value: null, errors };
999
1232
  }
1000
1233
  validateNumberInput(input, v, key);
@@ -1063,7 +1296,7 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1063
1296
  wrapper.appendChild(selectInput);
1064
1297
  const selectHint = document.createElement("p");
1065
1298
  selectHint.className = "text-xs text-gray-500 mt-1";
1066
- selectHint.textContent = makeFieldHint(element);
1299
+ selectHint.textContent = makeFieldHint(element, state);
1067
1300
  wrapper.appendChild(selectHint);
1068
1301
  }
1069
1302
  function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
@@ -1148,30 +1381,58 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1148
1381
  removeBtn.style.pointerEvents = disabled ? "none" : "auto";
1149
1382
  });
1150
1383
  }
1384
+ let addRow = null;
1385
+ let countDisplay = null;
1386
+ if (!state.config.readonly) {
1387
+ addRow = document.createElement("div");
1388
+ addRow.className = "flex items-center gap-3 mt-2";
1389
+ const addBtn = document.createElement("button");
1390
+ addBtn.type = "button";
1391
+ addBtn.className = "add-select-btn px-3 py-1 rounded";
1392
+ addBtn.style.cssText = `
1393
+ color: var(--fb-primary-color);
1394
+ border: var(--fb-border-width) solid var(--fb-primary-color);
1395
+ background-color: transparent;
1396
+ font-size: var(--fb-font-size);
1397
+ transition: all var(--fb-transition-duration);
1398
+ `;
1399
+ addBtn.textContent = "+";
1400
+ addBtn.addEventListener("mouseenter", () => {
1401
+ addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
1402
+ });
1403
+ addBtn.addEventListener("mouseleave", () => {
1404
+ addBtn.style.backgroundColor = "transparent";
1405
+ });
1406
+ addBtn.onclick = () => {
1407
+ const defaultValue = element.default || element.options?.[0]?.value || "";
1408
+ values.push(defaultValue);
1409
+ addSelectItem(defaultValue);
1410
+ updateAddButton();
1411
+ updateRemoveButtons();
1412
+ };
1413
+ countDisplay = document.createElement("span");
1414
+ countDisplay.className = "text-sm text-gray-500";
1415
+ addRow.appendChild(addBtn);
1416
+ addRow.appendChild(countDisplay);
1417
+ wrapper.appendChild(addRow);
1418
+ }
1151
1419
  function updateAddButton() {
1152
- const existingAddBtn = wrapper.querySelector(".add-select-btn");
1153
- if (existingAddBtn) existingAddBtn.remove();
1154
- if (!state.config.readonly && values.length < maxCount) {
1155
- const addBtn = document.createElement("button");
1156
- addBtn.type = "button";
1157
- addBtn.className = "add-select-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
1158
- addBtn.textContent = "+";
1159
- addBtn.onclick = () => {
1160
- const defaultValue = element.default || element.options?.[0]?.value || "";
1161
- values.push(defaultValue);
1162
- addSelectItem(defaultValue);
1163
- updateAddButton();
1164
- updateRemoveButtons();
1165
- };
1166
- wrapper.appendChild(addBtn);
1420
+ if (!addRow || !countDisplay) return;
1421
+ const addBtn = addRow.querySelector(".add-select-btn");
1422
+ if (addBtn) {
1423
+ const disabled = values.length >= maxCount;
1424
+ addBtn.disabled = disabled;
1425
+ addBtn.style.opacity = disabled ? "0.5" : "1";
1426
+ addBtn.style.pointerEvents = disabled ? "none" : "auto";
1167
1427
  }
1428
+ countDisplay.textContent = `${values.length}/${maxCount === Infinity ? "\u221E" : maxCount}`;
1168
1429
  }
1169
1430
  values.forEach((value) => addSelectItem(value));
1170
1431
  updateAddButton();
1171
1432
  updateRemoveButtons();
1172
1433
  const hint = document.createElement("p");
1173
1434
  hint.className = "text-xs text-gray-500 mt-1";
1174
- hint.textContent = makeFieldHint(element);
1435
+ hint.textContent = makeFieldHint(element, state);
1175
1436
  wrapper.appendChild(hint);
1176
1437
  }
1177
1438
  function validateSelectElement(element, key, context) {
@@ -1211,17 +1472,18 @@ function validateSelectElement(element, key, context) {
1211
1472
  };
1212
1473
  const validateMultipleCount = (key2, values, element2, filterFn) => {
1213
1474
  if (skipValidation) return;
1475
+ const { state } = context;
1214
1476
  const filteredValues = values.filter(filterFn);
1215
1477
  const minCount = "minCount" in element2 ? element2.minCount ?? 1 : 1;
1216
1478
  const maxCount = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
1217
1479
  if (element2.required && filteredValues.length === 0) {
1218
- errors.push(`${key2}: required`);
1480
+ errors.push(`${key2}: ${t("required", state)}`);
1219
1481
  }
1220
1482
  if (filteredValues.length < minCount) {
1221
- errors.push(`${key2}: minimum ${minCount} items required`);
1483
+ errors.push(`${key2}: ${t("minItems", state, { min: minCount })}`);
1222
1484
  }
1223
1485
  if (filteredValues.length > maxCount) {
1224
- errors.push(`${key2}: maximum ${maxCount} items allowed`);
1486
+ errors.push(`${key2}: ${t("maxItems", state, { max: maxCount })}`);
1225
1487
  }
1226
1488
  };
1227
1489
  if ("multiple" in element && element.multiple) {
@@ -1242,8 +1504,9 @@ function validateSelectElement(element, key, context) {
1242
1504
  );
1243
1505
  const val = input?.value ?? "";
1244
1506
  if (!skipValidation && element.required && val === "") {
1245
- errors.push(`${key}: required`);
1246
- markValidity(input, "required");
1507
+ const msg = t("required", context.state);
1508
+ errors.push(`${key}: ${msg}`);
1509
+ markValidity(input, msg);
1247
1510
  return { value: null, errors };
1248
1511
  } else {
1249
1512
  markValidity(input, null);
@@ -1295,18 +1558,11 @@ function updateSelectField(element, fieldPath, value, context) {
1295
1558
  }
1296
1559
  }
1297
1560
 
1298
- // src/utils/translation.ts
1299
- function t(key, state) {
1300
- const locale = state.config.locale || "en";
1301
- const translations = state.config.translations[locale] || state.config.translations.en;
1302
- return translations[key] || key;
1303
- }
1304
-
1305
1561
  // src/components/file.ts
1306
- function renderLocalImagePreview(container, file, fileName) {
1562
+ function renderLocalImagePreview(container, file, fileName, state) {
1307
1563
  const img = document.createElement("img");
1308
1564
  img.className = "w-full h-full object-contain";
1309
- img.alt = fileName || "Preview";
1565
+ img.alt = fileName || t("previewAlt", state);
1310
1566
  const reader = new FileReader();
1311
1567
  reader.onload = (e) => {
1312
1568
  img.src = e.target?.result || "";
@@ -1325,14 +1581,14 @@ function renderLocalVideoPreview(container, file, videoType, resourceId, state,
1325
1581
  <div class="relative group h-full">
1326
1582
  <video class="w-full h-full object-contain" controls preload="auto" muted>
1327
1583
  <source src="${videoUrl}" type="${videoType}">
1328
- Your browser does not support the video tag.
1584
+ ${escapeHtml(t("videoNotSupported", state))}
1329
1585
  </video>
1330
1586
  <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1">
1331
1587
  <button class="bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn">
1332
- ${t("removeElement", state)}
1588
+ ${escapeHtml(t("removeElement", state))}
1333
1589
  </button>
1334
1590
  <button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
1335
- Change
1591
+ ${escapeHtml(t("changeButton", state))}
1336
1592
  </button>
1337
1593
  </div>
1338
1594
  </div>
@@ -1381,11 +1637,11 @@ function handleVideoDelete(container, resourceId, state, deps) {
1381
1637
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
1382
1638
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
1383
1639
  </svg>
1384
- <div class="text-sm text-center">${t("clickDragText", state)}</div>
1640
+ <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
1385
1641
  </div>
1386
1642
  `;
1387
1643
  }
1388
- function renderUploadedVideoPreview(container, thumbnailUrl, videoType) {
1644
+ function renderUploadedVideoPreview(container, thumbnailUrl, videoType, state) {
1389
1645
  const video = document.createElement("video");
1390
1646
  video.className = "w-full h-full object-contain";
1391
1647
  video.controls = true;
@@ -1396,7 +1652,7 @@ function renderUploadedVideoPreview(container, thumbnailUrl, videoType) {
1396
1652
  source.type = videoType;
1397
1653
  video.appendChild(source);
1398
1654
  video.appendChild(
1399
- document.createTextNode("Your browser does not support the video tag.")
1655
+ document.createTextNode(t("videoNotSupported", state))
1400
1656
  );
1401
1657
  container.appendChild(video);
1402
1658
  }
@@ -1414,7 +1670,7 @@ function renderDeleteButton(container, resourceId, state) {
1414
1670
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
1415
1671
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
1416
1672
  </svg>
1417
- <div class="text-sm text-center">${t("clickDragText", state)}</div>
1673
+ <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
1418
1674
  </div>
1419
1675
  `;
1420
1676
  });
@@ -1424,7 +1680,7 @@ async function renderLocalFilePreview(container, meta, fileName, resourceId, isR
1424
1680
  return;
1425
1681
  }
1426
1682
  if (meta.type && meta.type.startsWith("image/")) {
1427
- renderLocalImagePreview(container, meta.file, fileName);
1683
+ renderLocalImagePreview(container, meta.file, fileName, state);
1428
1684
  } else if (meta.type && meta.type.startsWith("video/")) {
1429
1685
  const newContainer = renderLocalVideoPreview(
1430
1686
  container,
@@ -1436,7 +1692,7 @@ async function renderLocalFilePreview(container, meta, fileName, resourceId, isR
1436
1692
  );
1437
1693
  container = newContainer;
1438
1694
  } else {
1439
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">\u{1F4C1}</div><div class="text-sm">${fileName}</div></div>`;
1695
+ container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">\u{1F4C1}</div><div class="text-sm">${escapeHtml(fileName)}</div></div>`;
1440
1696
  }
1441
1697
  if (!isReadonly && !(meta.type && meta.type.startsWith("video/"))) {
1442
1698
  renderDeleteButton(container, resourceId, state);
@@ -1452,11 +1708,11 @@ async function renderUploadedFilePreview(container, resourceId, fileName, meta,
1452
1708
  if (thumbnailUrl) {
1453
1709
  clear(container);
1454
1710
  if (meta && meta.type && meta.type.startsWith("video/")) {
1455
- renderUploadedVideoPreview(container, thumbnailUrl, meta.type);
1711
+ renderUploadedVideoPreview(container, thumbnailUrl, meta.type, state);
1456
1712
  } else {
1457
1713
  const img = document.createElement("img");
1458
1714
  img.className = "w-full h-full object-contain";
1459
- img.alt = fileName || "Preview";
1715
+ img.alt = fileName || t("previewAlt", state);
1460
1716
  img.src = thumbnailUrl;
1461
1717
  container.appendChild(img);
1462
1718
  }
@@ -1470,7 +1726,7 @@ async function renderUploadedFilePreview(container, resourceId, fileName, meta,
1470
1726
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
1471
1727
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
1472
1728
  </svg>
1473
- <div class="text-sm text-center">${fileName || "Preview unavailable"}</div>
1729
+ <div class="text-sm text-center">${escapeHtml(fileName || t("previewUnavailable", state))}</div>
1474
1730
  </div>
1475
1731
  `;
1476
1732
  }
@@ -1526,16 +1782,16 @@ async function renderFilePreviewReadonly(resourceId, state, fileName) {
1526
1782
  try {
1527
1783
  const thumbnailUrl = await state.config.getThumbnail(resourceId);
1528
1784
  if (thumbnailUrl) {
1529
- previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
1785
+ previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${escapeHtml(actualFileName)}" class="w-full h-auto">`;
1530
1786
  } else {
1531
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${actualFileName}</div></div></div>`;
1787
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
1532
1788
  }
1533
1789
  } catch (error) {
1534
1790
  console.warn("getThumbnail failed for", resourceId, error);
1535
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${actualFileName}</div></div></div>`;
1791
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
1536
1792
  }
1537
1793
  } else {
1538
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${actualFileName}</div></div></div>`;
1794
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
1539
1795
  }
1540
1796
  } else if (isVideo) {
1541
1797
  if (state.config.getThumbnail) {
@@ -1546,7 +1802,7 @@ async function renderFilePreviewReadonly(resourceId, state, fileName) {
1546
1802
  <div class="relative group">
1547
1803
  <video class="w-full h-auto" controls preload="auto" muted>
1548
1804
  <source src="${videoUrl}" type="${meta?.type || "video/mp4"}">
1549
- \u0412\u0430\u0448 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0434\u0435\u043E.
1805
+ ${escapeHtml(t("videoNotSupported", state))}
1550
1806
  </video>
1551
1807
  <div class="absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
1552
1808
  <div class="bg-white bg-opacity-90 rounded-full p-3">
@@ -1558,14 +1814,14 @@ async function renderFilePreviewReadonly(resourceId, state, fileName) {
1558
1814
  </div>
1559
1815
  `;
1560
1816
  } else {
1561
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${actualFileName}</div></div></div>`;
1817
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
1562
1818
  }
1563
1819
  } catch (error) {
1564
1820
  console.warn("getThumbnail failed for video", resourceId, error);
1565
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${actualFileName}</div></div></div>`;
1821
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
1566
1822
  }
1567
1823
  } else {
1568
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${actualFileName}</div></div></div>`;
1824
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
1569
1825
  }
1570
1826
  } else {
1571
1827
  const fileIcon = isPSD ? "\u{1F3A8}" : "\u{1F4C1}";
@@ -1575,13 +1831,13 @@ async function renderFilePreviewReadonly(resourceId, state, fileName) {
1575
1831
  <div class="flex items-center space-x-3">
1576
1832
  <div class="text-3xl text-gray-400">${fileIcon}</div>
1577
1833
  <div class="flex-1 min-w-0">
1578
- <div class="text-sm font-medium text-gray-900 truncate">${actualFileName}</div>
1834
+ <div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(actualFileName)}</div>
1579
1835
  <div class="text-xs text-gray-500">${fileDescription}</div>
1580
1836
  </div>
1581
1837
  </div>
1582
1838
  `;
1583
1839
  } else {
1584
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">${fileIcon}</div><div class="text-sm">${actualFileName}</div><div class="text-xs text-gray-500 mt-1">${fileDescription}</div></div></div>`;
1840
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">${fileIcon}</div><div class="text-sm">${escapeHtml(actualFileName)}</div><div class="text-xs text-gray-500 mt-1">${fileDescription}</div></div></div>`;
1585
1841
  }
1586
1842
  }
1587
1843
  const fileNameElement = document.createElement("p");
@@ -1604,12 +1860,18 @@ async function renderFilePreviewReadonly(resourceId, state, fileName) {
1604
1860
  fileResult.appendChild(downloadButton);
1605
1861
  return fileResult;
1606
1862
  }
1607
- function renderResourcePills(container, rids, state, onRemove) {
1863
+ function renderResourcePills(container, rids, state, onRemove, hint, countInfo) {
1608
1864
  clear(container);
1865
+ const buildHintLine = () => {
1866
+ const parts = [t("clickDragTextMultiple", state)];
1867
+ if (hint) parts.push(hint);
1868
+ if (countInfo) parts.push(countInfo);
1869
+ return parts.join(" \u2022 ");
1870
+ };
1609
1871
  const isInitialRender = !container.classList.contains("grid");
1610
1872
  if ((!rids || rids.length === 0) && isInitialRender) {
1611
- const gridContainer = document.createElement("div");
1612
- gridContainer.className = "grid grid-cols-4 gap-3 mb-3";
1873
+ const gridContainer2 = document.createElement("div");
1874
+ gridContainer2.className = "grid grid-cols-4 gap-3 mb-3";
1613
1875
  for (let i = 0; i < 4; i++) {
1614
1876
  const slot = document.createElement("div");
1615
1877
  slot.className = "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
@@ -1640,36 +1902,17 @@ function renderResourcePills(container, rids, state, onRemove) {
1640
1902
  );
1641
1903
  if (fileInput) fileInput.click();
1642
1904
  };
1643
- gridContainer.appendChild(slot);
1644
- }
1645
- const textContainer = document.createElement("div");
1646
- textContainer.className = "text-center text-xs text-gray-600";
1647
- const uploadLink = document.createElement("span");
1648
- uploadLink.className = "underline cursor-pointer";
1649
- uploadLink.textContent = t("uploadText", state);
1650
- uploadLink.onclick = (e) => {
1651
- e.stopPropagation();
1652
- let filesWrapper = container.parentElement;
1653
- while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
1654
- filesWrapper = filesWrapper.parentElement;
1655
- }
1656
- if (!filesWrapper && container.classList.contains("space-y-2")) {
1657
- filesWrapper = container;
1658
- }
1659
- const fileInput = filesWrapper?.querySelector(
1660
- 'input[type="file"]'
1661
- );
1662
- if (fileInput) fileInput.click();
1663
- };
1664
- textContainer.appendChild(uploadLink);
1665
- textContainer.appendChild(
1666
- document.createTextNode(` ${t("dragDropText", state)}`)
1667
- );
1668
- container.appendChild(gridContainer);
1669
- container.appendChild(textContainer);
1905
+ gridContainer2.appendChild(slot);
1906
+ }
1907
+ const hintText2 = document.createElement("div");
1908
+ hintText2.className = "text-center text-xs text-gray-500 mt-2";
1909
+ hintText2.textContent = buildHintLine();
1910
+ container.appendChild(gridContainer2);
1911
+ container.appendChild(hintText2);
1670
1912
  return;
1671
1913
  }
1672
- container.className = "files-list grid grid-cols-4 gap-3 mt-2";
1914
+ const gridContainer = document.createElement("div");
1915
+ gridContainer.className = "files-list grid grid-cols-4 gap-3";
1673
1916
  const currentImagesCount = rids ? rids.length : 0;
1674
1917
  const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
1675
1918
  const slotsNeeded = rowsNeeded * 4;
@@ -1684,7 +1927,7 @@ function renderResourcePills(container, rids, state, onRemove) {
1684
1927
  console.error("Failed to render thumbnail:", err);
1685
1928
  slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
1686
1929
  <div class="text-2xl mb-1">\u{1F4C1}</div>
1687
- <div class="text-xs">Preview error</div>
1930
+ <div class="text-xs">${escapeHtml(t("previewError", state))}</div>
1688
1931
  </div>`;
1689
1932
  });
1690
1933
  if (onRemove) {
@@ -1717,15 +1960,20 @@ function renderResourcePills(container, rids, state, onRemove) {
1717
1960
  if (fileInput) fileInput.click();
1718
1961
  };
1719
1962
  }
1720
- container.appendChild(slot);
1963
+ gridContainer.appendChild(slot);
1721
1964
  }
1965
+ container.appendChild(gridContainer);
1966
+ const hintText = document.createElement("div");
1967
+ hintText.className = "text-center text-xs text-gray-500 mt-2";
1968
+ hintText.textContent = buildHintLine();
1969
+ container.appendChild(hintText);
1722
1970
  }
1723
- function renderThumbnailError(slot, iconSize = "w-12 h-12") {
1971
+ function renderThumbnailError(slot, state, iconSize = "w-12 h-12") {
1724
1972
  slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
1725
- <svg class="${iconSize} text-red-400" fill="currentColor" viewBox="0 0 24 24">
1973
+ <svg class="${escapeHtml(iconSize)} text-red-400" fill="currentColor" viewBox="0 0 24 24">
1726
1974
  <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
1727
1975
  </svg>
1728
- <div class="text-xs mt-1 text-red-600">Preview error</div>
1976
+ <div class="text-xs mt-1 text-red-600">${escapeHtml(t("previewError", state))}</div>
1729
1977
  </div>`;
1730
1978
  }
1731
1979
  async function renderThumbnailForResource(slot, rid, meta, state) {
@@ -1761,7 +2009,7 @@ async function renderThumbnailForResource(slot, rid, meta, state) {
1761
2009
  if (state.config.onThumbnailError) {
1762
2010
  state.config.onThumbnailError(err, rid);
1763
2011
  }
1764
- renderThumbnailError(slot);
2012
+ renderThumbnailError(slot, state);
1765
2013
  }
1766
2014
  } else {
1767
2015
  slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
@@ -1810,7 +2058,7 @@ async function renderThumbnailForResource(slot, rid, meta, state) {
1810
2058
  <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
1811
2059
  <path d="M8 5v14l11-7z"/>
1812
2060
  </svg>
1813
- <div class="text-xs mt-1">${meta?.name || "Video"}</div>
2061
+ <div class="text-xs mt-1">${escapeHtml(meta?.name || "Video")}</div>
1814
2062
  </div>`;
1815
2063
  }
1816
2064
  } catch (error) {
@@ -1818,30 +2066,32 @@ async function renderThumbnailForResource(slot, rid, meta, state) {
1818
2066
  if (state.config.onThumbnailError) {
1819
2067
  state.config.onThumbnailError(err, rid);
1820
2068
  }
1821
- renderThumbnailError(slot, "w-8 h-8");
2069
+ renderThumbnailError(slot, state, "w-8 h-8");
1822
2070
  }
1823
2071
  } else {
1824
2072
  slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
1825
2073
  <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
1826
2074
  <path d="M8 5v14l11-7z"/>
1827
2075
  </svg>
1828
- <div class="text-xs mt-1">${meta?.name || "Video"}</div>
2076
+ <div class="text-xs mt-1">${escapeHtml(meta?.name || "Video")}</div>
1829
2077
  </div>`;
1830
2078
  }
1831
2079
  } else {
1832
2080
  slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
1833
2081
  <div class="text-2xl mb-1">\u{1F4C1}</div>
1834
- <div class="text-xs">${meta?.name || "File"}</div>
2082
+ <div class="text-xs">${escapeHtml(meta?.name || "File")}</div>
1835
2083
  </div>`;
1836
2084
  }
1837
2085
  }
1838
- function setEmptyFileContainer(fileContainer, state) {
2086
+ function setEmptyFileContainer(fileContainer, state, hint) {
2087
+ const hintHtml = hint ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(hint)}</div>` : "";
1839
2088
  fileContainer.innerHTML = `
1840
2089
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
1841
2090
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
1842
2091
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
1843
2092
  </svg>
1844
- <div class="text-sm text-center">${t("clickDragText", state)}</div>
2093
+ <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2094
+ ${hintHtml}
1845
2095
  </div>
1846
2096
  `;
1847
2097
  }
@@ -2101,13 +2351,13 @@ function renderFileElement(element, ctx, wrapper, pathKey) {
2101
2351
  console.error("Failed to render file preview:", err);
2102
2352
  const emptyState = document.createElement("div");
2103
2353
  emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
2104
- emptyState.innerHTML = `<div class="text-center">Preview unavailable</div>`;
2354
+ emptyState.innerHTML = `<div class="text-center">${escapeHtml(t("previewUnavailable", state))}</div>`;
2105
2355
  wrapper.appendChild(emptyState);
2106
2356
  });
2107
2357
  } else {
2108
2358
  const emptyState = document.createElement("div");
2109
2359
  emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
2110
- emptyState.innerHTML = `<div class="text-center">${t("noFileSelected", state)}</div>`;
2360
+ emptyState.innerHTML = `<div class="text-center">${escapeHtml(t("noFileSelected", state))}</div>`;
2111
2361
  wrapper.appendChild(emptyState);
2112
2362
  }
2113
2363
  } else {
@@ -2151,7 +2401,8 @@ function renderFileElement(element, ctx, wrapper, pathKey) {
2151
2401
  }
2152
2402
  );
2153
2403
  } else {
2154
- setEmptyFileContainer(fileContainer, state);
2404
+ const hint = makeFieldHint(element, state);
2405
+ setEmptyFileContainer(fileContainer, state, hint);
2155
2406
  }
2156
2407
  fileContainer.onclick = fileUploadHandler;
2157
2408
  setupDragAndDrop(fileContainer, dragHandler);
@@ -2170,18 +2421,6 @@ function renderFileElement(element, ctx, wrapper, pathKey) {
2170
2421
  };
2171
2422
  fileWrapper.appendChild(fileContainer);
2172
2423
  fileWrapper.appendChild(picker);
2173
- const uploadText = document.createElement("p");
2174
- uploadText.className = "text-xs text-gray-600 mt-2 text-center";
2175
- uploadText.innerHTML = `<span class="underline cursor-pointer">${t("uploadText", state)}</span> ${t("dragDropTextSingle", state)}`;
2176
- const uploadSpan = uploadText.querySelector("span");
2177
- if (uploadSpan) {
2178
- uploadSpan.onclick = () => picker.click();
2179
- }
2180
- fileWrapper.appendChild(uploadText);
2181
- const fileHint = document.createElement("p");
2182
- fileHint.className = "text-xs text-gray-500 mt-1 text-center";
2183
- fileHint.textContent = makeFieldHint(element);
2184
- fileWrapper.appendChild(fileHint);
2185
2424
  wrapper.appendChild(fileWrapper);
2186
2425
  }
2187
2426
  }
@@ -2200,18 +2439,24 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
2200
2439
  });
2201
2440
  });
2202
2441
  } else {
2203
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected", state)}</div></div>`;
2442
+ resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${escapeHtml(t("noFilesSelected", state))}</div></div>`;
2204
2443
  }
2205
2444
  wrapper.appendChild(resultsWrapper);
2206
2445
  } else {
2207
2446
  let updateFilesList2 = function() {
2208
- renderResourcePills(list, initialFiles, state, (ridToRemove) => {
2209
- const index = initialFiles.indexOf(ridToRemove);
2210
- if (index > -1) {
2211
- initialFiles.splice(index, 1);
2212
- }
2213
- updateFilesList2();
2214
- });
2447
+ renderResourcePills(
2448
+ list,
2449
+ initialFiles,
2450
+ state,
2451
+ (ridToRemove) => {
2452
+ const index = initialFiles.indexOf(ridToRemove);
2453
+ if (index > -1) {
2454
+ initialFiles.splice(index, 1);
2455
+ }
2456
+ updateFilesList2();
2457
+ },
2458
+ filesFieldHint
2459
+ );
2215
2460
  };
2216
2461
  const filesWrapper = document.createElement("div");
2217
2462
  filesWrapper.className = "space-y-2";
@@ -2229,6 +2474,7 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
2229
2474
  list.className = "files-list";
2230
2475
  const initialFiles = ctx.prefill[element.key] || [];
2231
2476
  addPrefillFilesToIndex(initialFiles, state);
2477
+ const filesFieldHint = makeFieldHint(element, state);
2232
2478
  updateFilesList2();
2233
2479
  setupFilesDropHandler(
2234
2480
  filesContainer,
@@ -2249,10 +2495,6 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
2249
2495
  filesContainer.appendChild(list);
2250
2496
  filesWrapper.appendChild(filesContainer);
2251
2497
  filesWrapper.appendChild(filesPicker);
2252
- const filesHint = document.createElement("p");
2253
- filesHint.className = "text-xs text-gray-500 mt-1 text-center";
2254
- filesHint.textContent = makeFieldHint(element);
2255
- filesWrapper.appendChild(filesHint);
2256
2498
  wrapper.appendChild(filesWrapper);
2257
2499
  }
2258
2500
  }
@@ -2273,7 +2515,7 @@ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
2273
2515
  });
2274
2516
  });
2275
2517
  } else {
2276
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected", state)}</div></div>`;
2518
+ resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${escapeHtml(t("noFilesSelected", state))}</div></div>`;
2277
2519
  }
2278
2520
  wrapper.appendChild(resultsWrapper);
2279
2521
  } else {
@@ -2293,19 +2535,24 @@ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
2293
2535
  filesWrapper.appendChild(filesContainer);
2294
2536
  const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
2295
2537
  addPrefillFilesToIndex(initialFiles, state);
2538
+ const multipleFilesHint = makeFieldHint(element, state);
2539
+ const buildCountInfo = () => {
2540
+ const countText = initialFiles.length === 1 ? t("fileCountSingle", state, { count: initialFiles.length }) : t("fileCountPlural", state, { count: initialFiles.length });
2541
+ const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
2542
+ return countText + minMaxText;
2543
+ };
2296
2544
  const updateFilesDisplay = () => {
2297
- renderResourcePills(filesContainer, initialFiles, state, (index) => {
2298
- initialFiles.splice(initialFiles.indexOf(index), 1);
2299
- updateFilesDisplay();
2300
- });
2301
- const countInfo = document.createElement("div");
2302
- countInfo.className = "text-xs text-gray-500 mt-2 file-count-info";
2303
- const countText = `${initialFiles.length} file${initialFiles.length !== 1 ? "s" : ""}`;
2304
- const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` (${minFiles}-${maxFiles} allowed)` : "";
2305
- countInfo.textContent = countText + minMaxText;
2306
- const existingCount = filesWrapper.querySelector(".file-count-info");
2307
- if (existingCount) existingCount.remove();
2308
- filesWrapper.appendChild(countInfo);
2545
+ renderResourcePills(
2546
+ filesContainer,
2547
+ initialFiles,
2548
+ state,
2549
+ (index) => {
2550
+ initialFiles.splice(initialFiles.indexOf(index), 1);
2551
+ updateFilesDisplay();
2552
+ },
2553
+ multipleFilesHint,
2554
+ buildCountInfo()
2555
+ );
2309
2556
  };
2310
2557
  setupFilesDropHandler(
2311
2558
  filesContainer,
@@ -2333,16 +2580,17 @@ function validateFileElement(element, key, context) {
2333
2580
  const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
2334
2581
  const validateFileCount = (key2, resourceIds, element2) => {
2335
2582
  if (skipValidation) return;
2583
+ const { state } = context;
2336
2584
  const minFiles = "minCount" in element2 ? element2.minCount ?? 0 : 0;
2337
2585
  const maxFiles = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
2338
2586
  if (element2.required && resourceIds.length === 0) {
2339
- errors.push(`${key2}: required`);
2587
+ errors.push(`${key2}: ${t("required", state)}`);
2340
2588
  }
2341
2589
  if (resourceIds.length < minFiles) {
2342
- errors.push(`${key2}: minimum ${minFiles} files required`);
2590
+ errors.push(`${key2}: ${t("minFiles", state, { min: minFiles })}`);
2343
2591
  }
2344
2592
  if (resourceIds.length > maxFiles) {
2345
- errors.push(`${key2}: maximum ${maxFiles} files allowed`);
2593
+ errors.push(`${key2}: ${t("maxFiles", state, { max: maxFiles })}`);
2346
2594
  }
2347
2595
  };
2348
2596
  if (isMultipleField) {
@@ -2370,7 +2618,7 @@ function validateFileElement(element, key, context) {
2370
2618
  );
2371
2619
  const rid = input?.value ?? "";
2372
2620
  if (!skipValidation && element.required && rid === "") {
2373
- errors.push(`${key}: required`);
2621
+ errors.push(`${key}: ${t("required", context.state)}`);
2374
2622
  return { value: null, errors };
2375
2623
  }
2376
2624
  return { value: rid || null, errors };
@@ -2617,7 +2865,7 @@ function renderColourElement(element, ctx, wrapper, pathKey) {
2617
2865
  font-size: var(--fb-font-size-small);
2618
2866
  color: var(--fb-text-secondary-color);
2619
2867
  `;
2620
- colourHint.textContent = makeFieldHint(element);
2868
+ colourHint.textContent = makeFieldHint(element, state);
2621
2869
  wrapper.appendChild(colourHint);
2622
2870
  }
2623
2871
  function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
@@ -2707,36 +2955,51 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
2707
2955
  removeBtn.style.pointerEvents = disabled ? "none" : "auto";
2708
2956
  });
2709
2957
  }
2958
+ let addRow = null;
2959
+ let countDisplay = null;
2960
+ if (!state.config.readonly) {
2961
+ addRow = document.createElement("div");
2962
+ addRow.className = "flex items-center gap-3 mt-2";
2963
+ const addBtn = document.createElement("button");
2964
+ addBtn.type = "button";
2965
+ addBtn.className = "add-colour-btn px-3 py-1 rounded";
2966
+ addBtn.style.cssText = `
2967
+ color: var(--fb-primary-color);
2968
+ border: var(--fb-border-width) solid var(--fb-primary-color);
2969
+ background-color: transparent;
2970
+ font-size: var(--fb-font-size);
2971
+ transition: all var(--fb-transition-duration);
2972
+ `;
2973
+ addBtn.textContent = "+";
2974
+ addBtn.addEventListener("mouseenter", () => {
2975
+ addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
2976
+ });
2977
+ addBtn.addEventListener("mouseleave", () => {
2978
+ addBtn.style.backgroundColor = "transparent";
2979
+ });
2980
+ addBtn.onclick = () => {
2981
+ const defaultColour = element.default || "#000000";
2982
+ values.push(defaultColour);
2983
+ addColourItem(defaultColour);
2984
+ updateAddButton();
2985
+ updateRemoveButtons();
2986
+ };
2987
+ countDisplay = document.createElement("span");
2988
+ countDisplay.className = "text-sm text-gray-500";
2989
+ addRow.appendChild(addBtn);
2990
+ addRow.appendChild(countDisplay);
2991
+ wrapper.appendChild(addRow);
2992
+ }
2710
2993
  function updateAddButton() {
2711
- const existingAddBtn = wrapper.querySelector(".add-colour-btn");
2712
- if (existingAddBtn) existingAddBtn.remove();
2713
- if (!state.config.readonly && values.length < maxCount) {
2714
- const addBtn = document.createElement("button");
2715
- addBtn.type = "button";
2716
- addBtn.className = "add-colour-btn mt-2 px-3 py-1 rounded";
2717
- addBtn.style.cssText = `
2718
- color: var(--fb-primary-color);
2719
- border: var(--fb-border-width) solid var(--fb-primary-color);
2720
- background-color: transparent;
2721
- font-size: var(--fb-font-size);
2722
- transition: all var(--fb-transition-duration);
2723
- `;
2724
- addBtn.textContent = "+";
2725
- addBtn.addEventListener("mouseenter", () => {
2726
- addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
2727
- });
2728
- addBtn.addEventListener("mouseleave", () => {
2729
- addBtn.style.backgroundColor = "transparent";
2730
- });
2731
- addBtn.onclick = () => {
2732
- const defaultColour = element.default || "#000000";
2733
- values.push(defaultColour);
2734
- addColourItem(defaultColour);
2735
- updateAddButton();
2736
- updateRemoveButtons();
2737
- };
2738
- wrapper.appendChild(addBtn);
2994
+ if (!addRow || !countDisplay) return;
2995
+ const addBtn = addRow.querySelector(".add-colour-btn");
2996
+ if (addBtn) {
2997
+ const disabled = values.length >= maxCount;
2998
+ addBtn.disabled = disabled;
2999
+ addBtn.style.opacity = disabled ? "0.5" : "1";
3000
+ addBtn.style.pointerEvents = disabled ? "none" : "auto";
2739
3001
  }
3002
+ countDisplay.textContent = `${values.length}/${maxCount === Infinity ? "\u221E" : maxCount}`;
2740
3003
  }
2741
3004
  values.forEach((value) => addColourItem(value));
2742
3005
  updateAddButton();
@@ -2747,7 +3010,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
2747
3010
  font-size: var(--fb-font-size-small);
2748
3011
  color: var(--fb-text-secondary-color);
2749
3012
  `;
2750
- hint.textContent = makeFieldHint(element);
3013
+ hint.textContent = makeFieldHint(element, state);
2751
3014
  wrapper.appendChild(hint);
2752
3015
  }
2753
3016
  function validateColourElement(element, key, context) {
@@ -2786,10 +3049,12 @@ function validateColourElement(element, key, context) {
2786
3049
  }
2787
3050
  };
2788
3051
  const validateColourValue = (input, val, fieldKey) => {
3052
+ const { state } = context;
2789
3053
  if (!val) {
2790
3054
  if (!skipValidation && element.required) {
2791
- errors.push(`${fieldKey}: required`);
2792
- markValidity(input, "required");
3055
+ const msg = t("required", state);
3056
+ errors.push(`${fieldKey}: ${msg}`);
3057
+ markValidity(input, msg);
2793
3058
  return "";
2794
3059
  }
2795
3060
  markValidity(input, null);
@@ -2797,8 +3062,9 @@ function validateColourElement(element, key, context) {
2797
3062
  }
2798
3063
  const normalized = normalizeColourValue(val);
2799
3064
  if (!skipValidation && !isValidHexColour(normalized)) {
2800
- errors.push(`${fieldKey}: invalid hex colour format`);
2801
- markValidity(input, "invalid hex colour format");
3065
+ const msg = t("invalidHexColour", state);
3066
+ errors.push(`${fieldKey}: ${msg}`);
3067
+ markValidity(input, msg);
2802
3068
  return val;
2803
3069
  }
2804
3070
  markValidity(input, null);
@@ -2813,17 +3079,18 @@ function validateColourElement(element, key, context) {
2813
3079
  values.push(validated);
2814
3080
  });
2815
3081
  if (!skipValidation) {
3082
+ const { state } = context;
2816
3083
  const minCount = element.minCount ?? 1;
2817
3084
  const maxCount = element.maxCount ?? Infinity;
2818
3085
  const filteredValues = values.filter((v) => v !== "");
2819
3086
  if (element.required && filteredValues.length === 0) {
2820
- errors.push(`${key}: required`);
3087
+ errors.push(`${key}: ${t("required", state)}`);
2821
3088
  }
2822
3089
  if (filteredValues.length < minCount) {
2823
- errors.push(`${key}: minimum ${minCount} items required`);
3090
+ errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
2824
3091
  }
2825
3092
  if (filteredValues.length > maxCount) {
2826
- errors.push(`${key}: maximum ${maxCount} items allowed`);
3093
+ errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
2827
3094
  }
2828
3095
  }
2829
3096
  return { value: values, errors };
@@ -2833,8 +3100,9 @@ function validateColourElement(element, key, context) {
2833
3100
  );
2834
3101
  const val = hexInput?.value ?? "";
2835
3102
  if (!skipValidation && element.required && val === "") {
2836
- errors.push(`${key}: required`);
2837
- markValidity(hexInput, "required");
3103
+ const msg = t("required", context.state);
3104
+ errors.push(`${key}: ${msg}`);
3105
+ markValidity(hexInput, msg);
2838
3106
  return { value: "", errors };
2839
3107
  }
2840
3108
  const validated = validateColourValue(hexInput, val, key);
@@ -2926,13 +3194,15 @@ function alignToStep(value, step) {
2926
3194
  }
2927
3195
  function createSliderUI(value, pathKey, element, ctx, readonly) {
2928
3196
  const container = document.createElement("div");
2929
- container.className = "slider-container space-y-2";
2930
- const sliderRow = document.createElement("div");
2931
- sliderRow.className = "flex items-center gap-3";
3197
+ container.className = "slider-container";
3198
+ const mainRow = document.createElement("div");
3199
+ mainRow.className = "flex items-start gap-3";
3200
+ const sliderSection = document.createElement("div");
3201
+ sliderSection.className = "flex-1";
2932
3202
  const slider = document.createElement("input");
2933
3203
  slider.type = "range";
2934
3204
  slider.name = pathKey;
2935
- slider.className = "slider-input flex-1";
3205
+ slider.className = "slider-input w-full";
2936
3206
  slider.disabled = readonly;
2937
3207
  const scale = element.scale || "linear";
2938
3208
  const min = element.min;
@@ -2970,25 +3240,13 @@ function createSliderUI(value, pathKey, element, ctx, readonly) {
2970
3240
  cursor: ${readonly ? "not-allowed" : "pointer"};
2971
3241
  opacity: ${readonly ? "0.6" : "1"};
2972
3242
  `;
2973
- const valueDisplay = document.createElement("span");
2974
- valueDisplay.className = "slider-value";
2975
- valueDisplay.style.cssText = `
2976
- min-width: 60px;
2977
- text-align: right;
2978
- font-size: var(--fb-font-size);
2979
- color: var(--fb-text-color);
2980
- font-family: var(--fb-font-family-mono, monospace);
2981
- font-weight: 500;
2982
- `;
2983
- valueDisplay.textContent = value.toFixed(step < 1 ? 2 : 0);
2984
- sliderRow.appendChild(slider);
2985
- sliderRow.appendChild(valueDisplay);
2986
- container.appendChild(sliderRow);
3243
+ sliderSection.appendChild(slider);
2987
3244
  const labelsRow = document.createElement("div");
2988
3245
  labelsRow.className = "flex justify-between";
2989
3246
  labelsRow.style.cssText = `
2990
3247
  font-size: var(--fb-font-size-small);
2991
3248
  color: var(--fb-text-secondary-color);
3249
+ margin-top: 4px;
2992
3250
  `;
2993
3251
  const minLabel = document.createElement("span");
2994
3252
  minLabel.textContent = min.toString();
@@ -2996,7 +3254,22 @@ function createSliderUI(value, pathKey, element, ctx, readonly) {
2996
3254
  maxLabel.textContent = max.toString();
2997
3255
  labelsRow.appendChild(minLabel);
2998
3256
  labelsRow.appendChild(maxLabel);
2999
- container.appendChild(labelsRow);
3257
+ sliderSection.appendChild(labelsRow);
3258
+ const valueDisplay = document.createElement("span");
3259
+ valueDisplay.className = "slider-value";
3260
+ valueDisplay.style.cssText = `
3261
+ min-width: 60px;
3262
+ text-align: right;
3263
+ font-size: var(--fb-font-size);
3264
+ color: var(--fb-text-color);
3265
+ font-family: var(--fb-font-family-mono, monospace);
3266
+ font-weight: 500;
3267
+ padding-top: 2px;
3268
+ `;
3269
+ valueDisplay.textContent = value.toFixed(step < 1 ? 2 : 0);
3270
+ mainRow.appendChild(sliderSection);
3271
+ mainRow.appendChild(valueDisplay);
3272
+ container.appendChild(mainRow);
3000
3273
  if (!readonly) {
3001
3274
  const updateValue = () => {
3002
3275
  let displayValue;
@@ -3056,7 +3329,7 @@ function renderSliderElement(element, ctx, wrapper, pathKey) {
3056
3329
  font-size: var(--fb-font-size-small);
3057
3330
  color: var(--fb-text-secondary-color);
3058
3331
  `;
3059
- hint.textContent = makeFieldHint(element);
3332
+ hint.textContent = makeFieldHint(element, state);
3060
3333
  wrapper.appendChild(hint);
3061
3334
  }
3062
3335
  function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
@@ -3158,35 +3431,50 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
3158
3431
  removeBtn.style.pointerEvents = disabled ? "none" : "auto";
3159
3432
  });
3160
3433
  }
3434
+ let addRow = null;
3435
+ let countDisplay = null;
3436
+ if (!state.config.readonly) {
3437
+ addRow = document.createElement("div");
3438
+ addRow.className = "flex items-center gap-3 mt-2";
3439
+ const addBtn = document.createElement("button");
3440
+ addBtn.type = "button";
3441
+ addBtn.className = "add-slider-btn px-3 py-1 rounded";
3442
+ addBtn.style.cssText = `
3443
+ color: var(--fb-primary-color);
3444
+ border: var(--fb-border-width) solid var(--fb-primary-color);
3445
+ background-color: transparent;
3446
+ font-size: var(--fb-font-size);
3447
+ transition: all var(--fb-transition-duration);
3448
+ `;
3449
+ addBtn.textContent = "+";
3450
+ addBtn.addEventListener("mouseenter", () => {
3451
+ addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
3452
+ });
3453
+ addBtn.addEventListener("mouseleave", () => {
3454
+ addBtn.style.backgroundColor = "transparent";
3455
+ });
3456
+ addBtn.onclick = () => {
3457
+ values.push(defaultValue);
3458
+ addSliderItem(defaultValue);
3459
+ updateAddButton();
3460
+ updateRemoveButtons();
3461
+ };
3462
+ countDisplay = document.createElement("span");
3463
+ countDisplay.className = "text-sm text-gray-500";
3464
+ addRow.appendChild(addBtn);
3465
+ addRow.appendChild(countDisplay);
3466
+ wrapper.appendChild(addRow);
3467
+ }
3161
3468
  function updateAddButton() {
3162
- const existingAddBtn = wrapper.querySelector(".add-slider-btn");
3163
- if (existingAddBtn) existingAddBtn.remove();
3164
- if (!state.config.readonly && values.length < maxCount) {
3165
- const addBtn = document.createElement("button");
3166
- addBtn.type = "button";
3167
- addBtn.className = "add-slider-btn mt-2 px-3 py-1 rounded";
3168
- addBtn.style.cssText = `
3169
- color: var(--fb-primary-color);
3170
- border: var(--fb-border-width) solid var(--fb-primary-color);
3171
- background-color: transparent;
3172
- font-size: var(--fb-font-size);
3173
- transition: all var(--fb-transition-duration);
3174
- `;
3175
- addBtn.textContent = "+";
3176
- addBtn.addEventListener("mouseenter", () => {
3177
- addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
3178
- });
3179
- addBtn.addEventListener("mouseleave", () => {
3180
- addBtn.style.backgroundColor = "transparent";
3181
- });
3182
- addBtn.onclick = () => {
3183
- values.push(defaultValue);
3184
- addSliderItem(defaultValue);
3185
- updateAddButton();
3186
- updateRemoveButtons();
3187
- };
3188
- wrapper.appendChild(addBtn);
3469
+ if (!addRow || !countDisplay) return;
3470
+ const addBtn = addRow.querySelector(".add-slider-btn");
3471
+ if (addBtn) {
3472
+ const disabled = values.length >= maxCount;
3473
+ addBtn.disabled = disabled;
3474
+ addBtn.style.opacity = disabled ? "0.5" : "1";
3475
+ addBtn.style.pointerEvents = disabled ? "none" : "auto";
3189
3476
  }
3477
+ countDisplay.textContent = `${values.length}/${maxCount === Infinity ? "\u221E" : maxCount}`;
3190
3478
  }
3191
3479
  values.forEach((value) => addSliderItem(value));
3192
3480
  updateAddButton();
@@ -3197,7 +3485,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
3197
3485
  font-size: var(--fb-font-size-small);
3198
3486
  color: var(--fb-text-secondary-color);
3199
3487
  `;
3200
- hint.textContent = makeFieldHint(element);
3488
+ hint.textContent = makeFieldHint(element, state);
3201
3489
  wrapper.appendChild(hint);
3202
3490
  }
3203
3491
  function validateSliderElement(element, key, context) {
@@ -3254,11 +3542,13 @@ function validateSliderElement(element, key, context) {
3254
3542
  }
3255
3543
  };
3256
3544
  const validateSliderValue = (slider, fieldKey) => {
3545
+ const { state } = context;
3257
3546
  const rawValue = slider.value;
3258
3547
  if (!rawValue) {
3259
3548
  if (!skipValidation && element.required) {
3260
- errors.push(`${fieldKey}: required`);
3261
- markValidity(slider, "required");
3549
+ const msg = t("required", state);
3550
+ errors.push(`${fieldKey}: ${msg}`);
3551
+ markValidity(slider, msg);
3262
3552
  return null;
3263
3553
  }
3264
3554
  markValidity(slider, null);
@@ -3275,13 +3565,15 @@ function validateSliderElement(element, key, context) {
3275
3565
  }
3276
3566
  if (!skipValidation) {
3277
3567
  if (value < min) {
3278
- errors.push(`${fieldKey}: value ${value} < min ${min}`);
3279
- markValidity(slider, `value must be >= ${min}`);
3568
+ const msg = t("minValue", state, { min });
3569
+ errors.push(`${fieldKey}: ${msg}`);
3570
+ markValidity(slider, msg);
3280
3571
  return value;
3281
3572
  }
3282
3573
  if (value > max) {
3283
- errors.push(`${fieldKey}: value ${value} > max ${max}`);
3284
- markValidity(slider, `value must be <= ${max}`);
3574
+ const msg = t("maxValue", state, { max });
3575
+ errors.push(`${fieldKey}: ${msg}`);
3576
+ markValidity(slider, msg);
3285
3577
  return value;
3286
3578
  }
3287
3579
  }
@@ -3298,17 +3590,18 @@ function validateSliderElement(element, key, context) {
3298
3590
  values.push(value);
3299
3591
  });
3300
3592
  if (!skipValidation) {
3593
+ const { state } = context;
3301
3594
  const minCount = element.minCount ?? 1;
3302
3595
  const maxCount = element.maxCount ?? Infinity;
3303
3596
  const filteredValues = values.filter((v) => v !== null);
3304
3597
  if (element.required && filteredValues.length === 0) {
3305
- errors.push(`${key}: required`);
3598
+ errors.push(`${key}: ${t("required", state)}`);
3306
3599
  }
3307
3600
  if (filteredValues.length < minCount) {
3308
- errors.push(`${key}: minimum ${minCount} items required`);
3601
+ errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
3309
3602
  }
3310
3603
  if (filteredValues.length > maxCount) {
3311
- errors.push(`${key}: maximum ${maxCount} items allowed`);
3604
+ errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
3312
3605
  }
3313
3606
  }
3314
3607
  return { value: values, errors };
@@ -3318,7 +3611,7 @@ function validateSliderElement(element, key, context) {
3318
3611
  );
3319
3612
  if (!slider) {
3320
3613
  if (!skipValidation && element.required) {
3321
- errors.push(`${key}: required`);
3614
+ errors.push(`${key}: ${t("required", context.state)}`);
3322
3615
  }
3323
3616
  return { value: null, errors };
3324
3617
  }
@@ -3470,10 +3763,6 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
3470
3763
  const containerWrap = document.createElement("div");
3471
3764
  containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
3472
3765
  containerWrap.setAttribute("data-container", pathKey);
3473
- const header = document.createElement("div");
3474
- header.className = "flex justify-between items-center mb-4";
3475
- const left = document.createElement("div");
3476
- left.className = "flex-1";
3477
3766
  const itemsWrap = document.createElement("div");
3478
3767
  const columns = element.columns || 1;
3479
3768
  if (columns === 1) {
@@ -3481,8 +3770,6 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
3481
3770
  } else {
3482
3771
  itemsWrap.className = `grid grid-cols-${columns} gap-4`;
3483
3772
  }
3484
- containerWrap.appendChild(header);
3485
- header.appendChild(left);
3486
3773
  if (!ctx.state.config.readonly) {
3487
3774
  const hintsElement = createPrefillHints(element, pathKey);
3488
3775
  if (hintsElement) {
@@ -3503,21 +3790,16 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
3503
3790
  }
3504
3791
  });
3505
3792
  containerWrap.appendChild(itemsWrap);
3506
- left.innerHTML = `<span>${element.label || element.key}</span>`;
3507
3793
  wrapper.appendChild(containerWrap);
3508
3794
  }
3509
3795
  function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
3510
3796
  const state = ctx.state;
3511
3797
  const containerWrap = document.createElement("div");
3512
3798
  containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
3513
- const header = document.createElement("div");
3514
- header.className = "flex justify-between items-center mb-4";
3515
- const left = document.createElement("div");
3516
- left.className = "flex-1";
3799
+ const countDisplay = document.createElement("span");
3800
+ countDisplay.className = "text-sm text-gray-500";
3517
3801
  const itemsWrap = document.createElement("div");
3518
3802
  itemsWrap.className = "space-y-4";
3519
- containerWrap.appendChild(header);
3520
- header.appendChild(left);
3521
3803
  if (!ctx.state.config.readonly) {
3522
3804
  const hintsElement = createPrefillHints(element, element.key);
3523
3805
  if (hintsElement) {
@@ -3531,7 +3813,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
3531
3813
  const createAddButton = () => {
3532
3814
  const add = document.createElement("button");
3533
3815
  add.type = "button";
3534
- add.className = "add-container-btn mt-2 px-3 py-1 rounded";
3816
+ add.className = "add-container-btn px-3 py-1 rounded";
3535
3817
  add.style.cssText = `
3536
3818
  color: var(--fb-primary-color);
3537
3819
  border: var(--fb-border-width) solid var(--fb-primary-color);
@@ -3612,7 +3894,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
3612
3894
  existingAddBtn.style.opacity = currentCount >= max ? "0.5" : "1";
3613
3895
  existingAddBtn.style.pointerEvents = currentCount >= max ? "none" : "auto";
3614
3896
  }
3615
- left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-sm text-gray-500">(${currentCount}/${max === Infinity ? "\u221E" : max})</span>`;
3897
+ countDisplay.textContent = `${currentCount}/${max === Infinity ? "\u221E" : max}`;
3616
3898
  };
3617
3899
  if (pre && Array.isArray(pre)) {
3618
3900
  pre.forEach((prefillObj, idx) => {
@@ -3719,7 +4001,11 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
3719
4001
  }
3720
4002
  containerWrap.appendChild(itemsWrap);
3721
4003
  if (!state.config.readonly) {
3722
- containerWrap.appendChild(createAddButton());
4004
+ const addRow = document.createElement("div");
4005
+ addRow.className = "flex items-center gap-3 mt-2";
4006
+ addRow.appendChild(createAddButton());
4007
+ addRow.appendChild(countDisplay);
4008
+ containerWrap.appendChild(addRow);
3723
4009
  }
3724
4010
  updateAddButton();
3725
4011
  wrapper.appendChild(containerWrap);
@@ -3744,16 +4030,17 @@ function validateContainerElement(element, key, context) {
3744
4030
  }
3745
4031
  const validateContainerCount = (key2, items, element2) => {
3746
4032
  if (skipValidation) return;
4033
+ const { state } = context;
3747
4034
  const minItems = "minCount" in element2 ? element2.minCount ?? 0 : 0;
3748
4035
  const maxItems = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
3749
4036
  if (element2.required && items.length === 0) {
3750
- errors.push(`${key2}: required`);
4037
+ errors.push(`${key2}: ${t("required", state)}`);
3751
4038
  }
3752
4039
  if (items.length < minItems) {
3753
- errors.push(`${key2}: minimum ${minItems} items required`);
4040
+ errors.push(`${key2}: ${t("minItems", state, { min: minItems })}`);
3754
4041
  }
3755
4042
  if (items.length > maxItems) {
3756
- errors.push(`${key2}: maximum ${maxItems} items allowed`);
4043
+ errors.push(`${key2}: ${t("maxItems", state, { max: maxItems })}`);
3757
4044
  }
3758
4045
  };
3759
4046
  if ("multiple" in element && element.multiple) {
@@ -4300,7 +4587,7 @@ function dispatchToRenderer(element, ctx, wrapper, pathKey) {
4300
4587
  default: {
4301
4588
  const unsupported = document.createElement("div");
4302
4589
  unsupported.className = "text-red-500 text-sm";
4303
- unsupported.textContent = `Unsupported field type: ${element.type}`;
4590
+ unsupported.textContent = t("unsupportedFieldType", ctx.state, { type: element.type });
4304
4591
  wrapper.appendChild(unsupported);
4305
4592
  }
4306
4593
  }
@@ -4344,31 +4631,112 @@ var defaultConfig = {
4344
4631
  locale: "en",
4345
4632
  translations: {
4346
4633
  en: {
4347
- addElement: "Add Element",
4634
+ // UI texts
4348
4635
  removeElement: "Remove",
4349
- uploadText: "Upload",
4350
- dragDropText: "or drag and drop files",
4351
- dragDropTextSingle: "or drag and drop file",
4352
4636
  clickDragText: "Click or drag file",
4637
+ clickDragTextMultiple: "Click or drag files",
4353
4638
  noFileSelected: "No file selected",
4354
4639
  noFilesSelected: "No files selected",
4355
- downloadButton: "Download"
4640
+ downloadButton: "Download",
4641
+ changeButton: "Change",
4642
+ placeholderText: "Enter text",
4643
+ previewAlt: "Preview",
4644
+ previewUnavailable: "Preview unavailable",
4645
+ previewError: "Preview error",
4646
+ videoNotSupported: "Your browser does not support the video tag.",
4647
+ // Field hints
4648
+ hintLengthRange: "{min}-{max} chars",
4649
+ hintMaxLength: "\u2264{max} chars",
4650
+ hintMinLength: "\u2265{min} chars",
4651
+ hintValueRange: "{min}-{max}",
4652
+ hintMaxValue: "\u2264{max}",
4653
+ hintMinValue: "\u2265{min}",
4654
+ hintMaxSize: "\u2264{size}MB",
4655
+ hintFormats: "{formats}",
4656
+ hintRequired: "Required",
4657
+ hintOptional: "Optional",
4658
+ hintPattern: "Format: {pattern}",
4659
+ fileCountSingle: "{count} file",
4660
+ fileCountPlural: "{count} files",
4661
+ fileCountRange: "({min}-{max})",
4662
+ // Validation errors
4663
+ required: "Required",
4664
+ minItems: "Minimum {min} items required",
4665
+ maxItems: "Maximum {max} items allowed",
4666
+ minLength: "Minimum {min} characters",
4667
+ maxLength: "Maximum {max} characters",
4668
+ minValue: "Must be at least {min}",
4669
+ maxValue: "Must be at most {max}",
4670
+ patternMismatch: "Invalid format",
4671
+ invalidPattern: "Invalid pattern in schema",
4672
+ notANumber: "Must be a number",
4673
+ invalidHexColour: "Invalid hex color",
4674
+ minFiles: "Minimum {min} files required",
4675
+ maxFiles: "Maximum {max} files allowed",
4676
+ unsupportedFieldType: "Unsupported field type: {type}"
4356
4677
  },
4357
4678
  ru: {
4358
- addElement: "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C \u044D\u043B\u0435\u043C\u0435\u043D\u0442",
4679
+ // UI texts
4359
4680
  removeElement: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
4360
- uploadText: "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u0435",
4361
- dragDropText: "\u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B\u044B",
4362
- dragDropTextSingle: "\u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B",
4363
4681
  clickDragText: "\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B",
4682
+ clickDragTextMultiple: "\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B\u044B",
4364
4683
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
4365
4684
  noFilesSelected: "\u041D\u0435\u0442 \u0444\u0430\u0439\u043B\u043E\u0432",
4366
- downloadButton: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C"
4685
+ downloadButton: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
4686
+ changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
4687
+ placeholderText: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442",
4688
+ previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
4689
+ previewUnavailable: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D",
4690
+ previewError: "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440\u0430",
4691
+ videoNotSupported: "\u0412\u0430\u0448 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0434\u0435\u043E.",
4692
+ // Field hints
4693
+ hintLengthRange: "{min}-{max} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432",
4694
+ hintMaxLength: "\u2264{max} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432",
4695
+ hintMinLength: "\u2265{min} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432",
4696
+ hintValueRange: "{min}-{max}",
4697
+ hintMaxValue: "\u2264{max}",
4698
+ hintMinValue: "\u2265{min}",
4699
+ hintMaxSize: "\u2264{size}\u041C\u0411",
4700
+ hintFormats: "{formats}",
4701
+ hintRequired: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435",
4702
+ hintOptional: "\u041D\u0435\u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435",
4703
+ hintPattern: "\u0424\u043E\u0440\u043C\u0430\u0442: {pattern}",
4704
+ fileCountSingle: "{count} \u0444\u0430\u0439\u043B",
4705
+ fileCountPlural: "{count} \u0444\u0430\u0439\u043B\u043E\u0432",
4706
+ fileCountRange: "({min}-{max})",
4707
+ // Validation errors
4708
+ required: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435",
4709
+ minItems: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",
4710
+ maxItems: "\u041C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",
4711
+ minLength: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432",
4712
+ maxLength: "\u041C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432",
4713
+ minValue: "\u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043D\u0435 \u043C\u0435\u043D\u0435\u0435 {min}",
4714
+ maxValue: "\u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043D\u0435 \u0431\u043E\u043B\u0435\u0435 {max}",
4715
+ patternMismatch: "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442",
4716
+ invalidPattern: "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u043F\u0430\u0442\u0442\u0435\u0440\u043D \u0432 \u0441\u0445\u0435\u043C\u0435",
4717
+ notANumber: "\u0414\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u0447\u0438\u0441\u043B\u043E\u043C",
4718
+ invalidHexColour: "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442 \u0446\u0432\u0435\u0442\u0430",
4719
+ minFiles: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u0444\u0430\u0439\u043B\u043E\u0432",
4720
+ maxFiles: "\u041C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u0444\u0430\u0439\u043B\u043E\u0432",
4721
+ unsupportedFieldType: "\u041D\u0435\u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u0439 \u0442\u0438\u043F \u043F\u043E\u043B\u044F: {type}"
4367
4722
  }
4368
4723
  },
4369
4724
  theme: {}
4370
4725
  };
4371
4726
  function createInstanceState(config) {
4727
+ const mergedTranslations = {
4728
+ ...defaultConfig.translations
4729
+ };
4730
+ if (config?.translations) {
4731
+ for (const [locale, userTranslations] of Object.entries(
4732
+ config.translations
4733
+ )) {
4734
+ mergedTranslations[locale] = {
4735
+ ...defaultConfig.translations[locale] || {},
4736
+ ...userTranslations
4737
+ };
4738
+ }
4739
+ }
4372
4740
  return {
4373
4741
  schema: null,
4374
4742
  formRoot: null,
@@ -4377,7 +4745,8 @@ function createInstanceState(config) {
4377
4745
  version: "1.0.0",
4378
4746
  config: {
4379
4747
  ...defaultConfig,
4380
- ...config
4748
+ ...config,
4749
+ translations: mergedTranslations
4381
4750
  },
4382
4751
  debounceTimer: null
4383
4752
  };